maestro/src/bridge/reflection-api.ts
oss-sync 29ccaf1e92
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (dfadcd5f)
2026-06-23 06:38:48 +00:00

410 lines
17 KiB
TypeScript

/**
* reflection-api.ts — REST router for reflection history + metrics
*
* Mounted at /api/local/reflection
*
* Routes:
* GET /history — paged index listing (limit, before cursor)
* GET /history/:snapshotId — full detail (meta + before/after files + diff)
* POST /history/:snapshotId/revert — idempotent revert
* GET /metrics — outcome counts + token sums (?days=30)
* GET /latest-for-task/:taskId — feeds the ReflectionBadge on OverviewTab
*
* Auth: all routes require an authenticated user (req.user).
* Owner: every operation is scoped to req.user.id — 404 on mismatch (no existence leak).
*/
import { Router, type Request, type Response } from 'express';
import { logger } from '../logger.js';
import {
listSnapshots,
readSnapshot,
revertSnapshotForUser,
type SnapshotDeps,
type SnapshotIndexEntry,
type SnapshotDetail,
} from '../engine/reflection/snapshot.js';
import type { Repository } from '../db/repository.js';
import { resolveSpaceFolder } from '../spaces/folder-resolver.js';
import { canEditInSpace } from './visibility.js';
// ── Types ──────────────────────────────────────────────────────────────────────
interface AuthedUser { id: string; role: string; }
function getUser(req: Request): AuthedUser | null {
return (req.user as AuthedUser | undefined) ?? null;
}
// ── Deps ───────────────────────────────────────────────────────────────────────
export interface ReflectionApiDeps {
/** Root data dir (same as userFolderRoot in the rest of the app). */
dataDir: string;
/** Repository for job lookups (latest-for-task, metrics). */
repo: Repository;
/** When false (local-dev mode), inject a synthetic 'local' user if req.user absent. */
authActive?: boolean;
}
// ── Helpers ────────────────────────────────────────────────────────────────────
function makeDeps(dataDir: string): SnapshotDeps {
return { dataDir };
}
// ── Factory ────────────────────────────────────────────────────────────────────
export function createReflectionApi(deps: ReflectionApiDeps): Router {
const { dataDir, repo } = deps;
const authActive = deps.authActive ?? true;
const r = Router();
// ── Auth gate ──────────────────────────────────────────────────────────────
r.use((req: Request, res: Response, next) => {
if (!authActive && !getUser(req)) {
(req as any).user = { id: 'local', role: 'user' };
}
if (!getUser(req)) {
res.status(401).json({ error: 'Unauthenticated' });
return;
}
next();
});
/**
* Resolve which snapshot store a /history request targets (Space-as-Principal).
* Without ?spaceId → the viewer's personal store (data/users/{viewerId}).
* With ?spaceId → the case space's store (data/spaces/{spaceId}); the snapshot
* leaf becomes the space id. Membership-gated: the space must be visible to the
* viewer (getSpace({viewer}) → null otherwise), and for mutations the viewer
* must have edit rights. Returns null → caller responds 404 (no existence leak).
*/
async function resolveStore(
req: Request,
forEdit: boolean,
): Promise<{ deps: SnapshotDeps; leaf: string } | null> {
const user = req.user as Express.User;
const spaceId =
typeof req.query.spaceId === 'string' && req.query.spaceId ? req.query.spaceId : null;
if (!spaceId) return { deps: makeDeps(dataDir), leaf: user.id };
// No-auth (single-user local) mode: spaces are created with owner_id=NULL and
// the synthetic user is {id:'local', role:'user'}, which can't see a private
// case space. Resolve as a 'local' admin viewer (full access), matching the
// other space-aware APIs — otherwise space history/revert is unreachable.
const viewer: Express.User = authActive
? user
: ({ ...user, role: 'admin', orgIds: (user as { orgIds?: string[] }).orgIds ?? [] } as Express.User);
const space = await repo.getSpace(spaceId, { viewer });
if (!space) return null; // not visible to this viewer
if (forEdit) {
const role = repo.getSpaceMemberRole(spaceId, user.id);
if (!canEditInSpace(viewer, { ownerId: space.ownerId }, role)) return null;
}
const fc = resolveSpaceFolder(space, dataDir);
return { deps: makeDeps(fc.rootDir), leaf: fc.leafId };
}
// ── GET /history ───────────────────────────────────────────────────────────
// Returns paged list of snapshot index entries (most recent first).
// Query params:
// limit — max items to return (default 50, max 200)
// before — ISO timestamp cursor (exclusive, for pagination)
r.get('/history', async (req: Request, res: Response) => {
const u = getUser(req)!;
const rawLimit = parseInt(String(req.query.limit ?? '50'), 10);
const limit = isNaN(rawLimit) || rawLimit < 1 ? 50 : Math.min(rawLimit, 200);
const before = typeof req.query.before === 'string' ? req.query.before : undefined;
try {
const store = await resolveStore(req, false);
if (!store) {
res.status(404).json({ error: 'not_found' });
return;
}
const items = listSnapshots(store.deps, store.leaf, { limit, before });
// Compute nextCursor from the last item's ts (if we got a full page)
const nextCursor: string | null =
items.length === limit ? (items[items.length - 1]?.ts ?? null) : null;
res.json({ items, nextCursor });
} catch (err) {
logger.error(`[reflection-api] GET /history failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to list reflection history' });
}
});
// ── GET /history/:snapshotId ───────────────────────────────────────────────
// Returns full snapshot detail for the owner. 404 for non-owner or missing.
r.get('/history/:snapshotId', async (req: Request, res: Response) => {
const u = getUser(req)!;
const { snapshotId } = req.params;
try {
const store = await resolveStore(req, false);
if (!store) {
res.status(404).json({ error: 'not_found' });
return;
}
const detail: SnapshotDetail | null = readSnapshot(store.deps, store.leaf, snapshotId);
if (!detail) {
// Either doesn't exist or belongs to another store — always 404
res.status(404).json({ error: 'not_found' });
return;
}
// Store check: the meta.json embeds the storage leaf (user id or space id)
if (detail.userId !== store.leaf) {
res.status(404).json({ error: 'not_found' });
return;
}
res.json(detail);
} catch (err) {
logger.error(`[reflection-api] GET /history/${snapshotId} failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to read snapshot' });
}
});
// ── POST /history/:snapshotId/revert ──────────────────────────────────────
// Idempotent revert. Returns { reverted: true } on first call, { reverted: false } thereafter.
r.post('/history/:snapshotId/revert', async (req: Request, res: Response) => {
const u = getUser(req)!;
const { snapshotId } = req.params;
// Resolve the target store. Reverting mutates memory → require edit rights
// when targeting a space (resolveStore gates membership/role).
let store: { deps: SnapshotDeps; leaf: string } | null;
try {
store = await resolveStore(req, true);
} catch (err) {
logger.error(`[reflection-api] POST /revert store-resolve failed user=${u.id} snapshotId=${snapshotId} err=${err}`);
res.status(500).json({ error: 'Failed to verify snapshot ownership' });
return;
}
if (!store) {
res.status(404).json({ error: 'not_found' });
return;
}
// Store check: read meta first (cheaper than a full revert attempt that fails)
try {
const detail = readSnapshot(store.deps, store.leaf, snapshotId);
if (!detail || detail.userId !== store.leaf) {
res.status(404).json({ error: 'not_found' });
return;
}
} catch (err) {
logger.error(`[reflection-api] POST /revert owner-check failed user=${u.id} snapshotId=${snapshotId} err=${err}`);
res.status(500).json({ error: 'Failed to verify snapshot ownership' });
return;
}
try {
const result = await revertSnapshotForUser(store.deps, store.leaf, snapshotId);
logger.info(`[reflection-api] POST /revert snapshotId=${snapshotId} user=${u.id} leaf=${store.leaf} reverted=${result.reverted}`);
res.json(result);
} catch (err) {
logger.error(`[reflection-api] POST /revert failed user=${u.id} snapshotId=${snapshotId} err=${err}`);
res.status(500).json({ error: 'Failed to revert snapshot' });
}
});
// ── GET /metrics ───────────────────────────────────────────────────────────
// Returns aggregated outcome counts + token sums for the caller.
// Query params:
// days — look-back window in days (default 30)
//
// Gracefully returns zero counts when the reflection_metrics table doesn't
// exist yet (Phase 8.1 creates it and starts inserting rows).
r.get('/metrics', (req: Request, res: Response) => {
const u = getUser(req)!;
const rawDays = parseInt(String(req.query.days ?? '30'), 10);
const days = isNaN(rawDays) || rawDays < 1 ? 30 : Math.min(rawDays, 365);
const zeroMetrics = {
applied: 0,
partial: 0,
abstained: 0,
rejected: 0,
failed: 0,
tokensIn: 0,
tokensOut: 0,
pieceEdits: 0,
};
try {
const db = repo.getDb();
// Check if the table exists before querying (Phase 8.1 creates it)
const tableExists = (db.prepare(`PRAGMA table_info('reflection_metrics')`).all() as Array<{ name: string }>).length > 0;
if (!tableExists) {
res.json(zeroMetrics);
return;
}
// Phase 8.1's reflection_metrics.created_at is INTEGER ms-since-epoch.
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
interface MetricsRow {
outcome: string;
piece_edited: number;
tokens_in: number | null;
tokens_out: number | null;
}
const rows = db
.prepare(
`SELECT outcome, piece_edited, tokens_in, tokens_out
FROM reflection_metrics
WHERE user_id = ? AND created_at >= ?`,
)
.all(u.id, cutoff) as MetricsRow[];
const metrics = { ...zeroMetrics };
for (const row of rows) {
switch (row.outcome) {
case 'applied': metrics.applied++; break;
case 'partial': metrics.partial++; break;
case 'abstained': metrics.abstained++; break;
case 'rejected': metrics.rejected++; break;
case 'failed': metrics.failed++; break;
}
metrics.tokensIn += row.tokens_in ?? 0;
metrics.tokensOut += row.tokens_out ?? 0;
if (row.piece_edited) metrics.pieceEdits++;
}
res.json(metrics);
} catch (err) {
// If the table doesn't exist yet (race window between check and query), return zeros
const msg = String(err);
if (msg.includes('no such table')) {
res.json(zeroMetrics);
return;
}
logger.error(`[reflection-api] GET /metrics failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to load reflection metrics' });
}
});
// ── GET /latest-for-task/:taskId ──────────────────────────────────────────
// Returns the most recent snapshot triggered by the given local task's job,
// or null if none found. Used by the ReflectionBadge on OverviewTab (Phase 7.5).
//
// Owner check: the job must belong to the caller (owner_id match), or the
// user must be an admin. Returns null (not 404) when there's no snapshot —
// the badge simply stays hidden.
r.get('/latest-for-task/:taskId', async (req: Request, res: Response) => {
const u = getUser(req)!;
const rawTaskId = parseInt(req.params.taskId, 10);
if (isNaN(rawTaskId)) {
res.status(400).json({ error: 'invalid_task_id' });
return;
}
try {
const db = repo.getDb();
// Find all jobs for this local task, owned by the caller (or any if admin)
const repoName = `local/task-${rawTaskId}`;
interface JobRow {
id: string;
owner_id: string | null;
}
// A case-space task is visible to every space member (not just its creator),
// and its reflection lives under the space — so any member who can view the
// task should get the 🧠 badge. Grant the unrestricted job scan when the
// viewer can view the task's space (admin/owner/member).
const task = await repo.getLocalTask(rawTaskId);
const canSeeViaSpace = task?.spaceId ? repo.userCanViewSpace(task.spaceId, u) : false;
let rows: JobRow[];
if (u.role === 'admin' || canSeeViaSpace) {
rows = db
.prepare(`SELECT id, owner_id FROM jobs WHERE repo = ? ORDER BY created_at DESC`)
.all(repoName) as JobRow[];
} else if (!authActive) {
// No-auth single-user mode: task jobs are created with owner_id = NULL
// (req.user is undefined), but the synthetic user id is 'local' and the
// reflection writer stores snapshots under 'local' (reflectionOwner =
// job.ownerId ?? 'local'). Match NULL-owner jobs so the badge links them
// — without this the `owner_id = 'local'` filter never matched the NULL
// owner and the 🧠 badge stayed silently empty. Safe: no-auth has exactly
// one user, and listSnapshots below already reads the 'local' namespace.
rows = db
.prepare(
`SELECT id, owner_id FROM jobs WHERE repo = ? AND (owner_id = ? OR owner_id IS NULL) ORDER BY created_at DESC`,
)
.all(repoName, u.id) as JobRow[];
} else {
rows = db
.prepare(
`SELECT id, owner_id FROM jobs WHERE repo = ? AND owner_id = ? ORDER BY created_at DESC`,
)
.all(repoName, u.id) as JobRow[];
}
if (rows.length === 0) {
res.json(null);
return;
}
// Collect all job IDs for this task
const jobIds = new Set(rows.map((r) => r.id));
// Resolve WHERE this task's snapshots live (Space-as-Principal): a case-space
// task's reflection is stored under the space (data/spaces/{spaceId}), not the
// triggering user's personal folder. Personal / no-space → data/users/{u.id}.
let store: { deps: SnapshotDeps; leaf: string } = { deps: makeDeps(dataDir), leaf: u.id };
if (task?.spaceId) {
const space = await repo.getSpace(task.spaceId);
if (space) {
const fc = resolveSpaceFolder(space, dataDir);
store = { deps: makeDeps(fc.rootDir), leaf: fc.leafId };
}
}
// List all snapshots in that store (no limit — we need to scan for a match)
const allSnapshots = listSnapshots(store.deps, store.leaf, { limit: 200 });
// Find the most recent snapshot whose originalJobId is in our job set
const match = allSnapshots.find((s: SnapshotIndexEntry) => jobIds.has(s.jobId));
if (!match) {
res.json(null);
return;
}
// Load the full detail so the badge can show outcome + counts
const detail = readSnapshot(store.deps, store.leaf, match.snapshotId);
if (!detail) {
res.json(null);
return;
}
res.json({
snapshotId: detail.snapshotId,
outcome: detail.outcome,
memoryChanges: detail.memoryChanges,
pieceEdited: detail.pieceEdited,
});
} catch (err) {
logger.error(`[reflection-api] GET /latest-for-task/${rawTaskId} failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to load latest reflection for task' });
}
});
return r;
}