410 lines
17 KiB
TypeScript
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;
|
|
}
|