Iostream-Li's picture
Add files using upload-large-folder tool
6d1fe92 verified
import { Router, type IRouter, type RequestHandler } from "express";
import { and, desc, eq, isNull } from "drizzle-orm";
import { db, inviteCodes, users } from "@workspace/db";
import { requireAuth, requireAdmin } from "../middlewares/auth";
import {
circuitSnapshot,
resetCircuit,
silenceLatencyAlert,
clearLatencyAlertSilence,
setLatencyIncidentAnnouncementId,
getLatencyIncidentAnnouncementId,
} from "../llm/gateway";
import {
approveNode as approveToolNode,
rejectNode as rejectToolNode,
listNodes as listToolNodes,
listEdges as listToolEdges,
listNodeEvidence as listToolNodeEvidence,
listGapSignals as listToolGapSignals,
listEdgeHealth as listToolEdgeHealth,
getEdgeHealth as getToolEdgeHealth,
listUnhealthyEdges as listToolUnhealthyEdges,
recomputeEdgeHealth as recomputeToolEdgeHealth,
listDeprecationCandidates,
runDeprecationDetector,
deprecateNode as deprecateToolNode,
deferDeprecationCandidate,
rejectDeprecationCandidate,
runArchiveJob,
deprecationDetectorEnabled,
listArchive,
deprecationConfig,
toolGraphEnabled,
listSummaryPaths,
setSummaryPathOverride,
plannerHierarchyEnabled,
PLANNER_MAX_SUMMARIES,
} from "../lib/tool-graph";
import { sql } from "drizzle-orm";
import { announcements } from "@workspace/db";
const router: IRouter = Router();
// ---------- Provider circuit breaker admin --------------------------------
/**
* Express handler for `/admin/providers/health` extracted so unit tests
* can exercise it without standing up the auth middleware chain. The
* production route below still gates this behind `requireAdmin`.
*/
export const providerHealthHandler: RequestHandler = (_req, res) => {
const items = circuitSnapshot()
.map((e) => ({
key: e.key,
adapter: e.adapter,
source: e.source,
state: e.state,
failures: e.failures,
cooldown_remaining_ms: e.cooldownRemainingMs,
open_until: e.openUntil > 0 ? new Date(e.openUntil).toISOString() : null,
last_success_at:
e.lastSuccessAt > 0 ? new Date(e.lastSuccessAt).toISOString() : null,
last_failure_at:
e.lastFailureAt > 0 ? new Date(e.lastFailureAt).toISOString() : null,
total_successes: e.totalSuccesses,
total_failures: e.totalFailures,
recent_errors: e.recentErrors.map((re) => ({
code: re.code,
at: new Date(re.at).toISOString(),
})),
call_timeout_ms: e.callTimeoutMs,
recent_call_count: e.recentCallCount,
p50_ms: e.p50Ms,
p95_ms: e.p95Ms,
p95_near_ceiling_since:
e.p95NearCeilingSinceMs > 0
? new Date(e.p95NearCeilingSinceMs).toISOString()
: null,
latency_alert_threshold_ms: e.latencyAlertThresholdMs,
latency_alert_silenced_until:
e.silencedUntilMs > 0 ? new Date(e.silencedUntilMs).toISOString() : null,
recent_samples: e.recentSamples.map((s) => ({
ms: s.ms,
at: new Date(s.at).toISOString(),
})),
}))
.sort((a, b) => a.key.localeCompare(b.key));
res.json({ items, fetched_at: new Date().toISOString() });
};
router.get("/admin/providers/health", requireAdmin, providerHealthHandler);
router.post("/admin/providers/:adapter/:source/reset", requireAdmin, async (req, res, next) => {
try {
const adapter = String(req.params.adapter ?? "");
const source = String(req.params.source ?? "");
// Resolve any in-flight latency announcement for this provider --
// the operator is implicitly acknowledging the incident by hitting
// reset, and we're about to wipe the underlying state anyway.
const existingId = getLatencyIncidentAnnouncementId(adapter, source);
if (existingId) {
await db
.update(announcements)
.set({ endsAt: new Date() })
.where(sql`${announcements.id} = ${existingId}`);
setLatencyIncidentAnnouncementId(adapter, source, null);
}
resetCircuit(adapter, source);
res.status(204).end();
} catch (err) {
next(err);
}
});
/**
* Silence latency alerts for one provider for a fixed window. Existing
* announcements stay (operators dismiss those through the announcement
* bar), but no new ones fire until the silence expires. POST body may
* include `{ duration_minutes: number }`; default is 60 minutes.
* `duration_minutes: 0` clears the silence immediately.
*/
router.post(
"/admin/providers/:adapter/:source/silence-latency-alert",
requireAdmin,
async (req, res, next) => {
try {
const adapter = String(req.params.adapter ?? "");
const source = String(req.params.source ?? "");
const body = (req.body ?? {}) as { duration_minutes?: unknown };
const raw = Number(body.duration_minutes);
const minutes = Number.isFinite(raw) ? Math.max(0, Math.min(24 * 60, raw)) : 60;
if (minutes === 0) {
clearLatencyAlertSilence(adapter, source);
return res.json({ silenced_until: null });
}
const { silencedUntilMs } = silenceLatencyAlert(
adapter,
source,
minutes * 60_000,
);
// Acknowledge the in-flight announcement (if any) so dismissing
// and silencing are a single click for the operator.
const existingId = getLatencyIncidentAnnouncementId(adapter, source);
if (existingId) {
await db
.update(announcements)
.set({ endsAt: new Date() })
.where(sql`${announcements.id} = ${existingId}`);
setLatencyIncidentAnnouncementId(adapter, source, null);
}
res.json({
silenced_until: new Date(silencedUntilMs).toISOString(),
});
} catch (err) {
next(err);
}
},
);
// ---------- Invite code admin --------------------------------------------
const adminGuard = [requireAuth, requireAdmin] as const;
function generateCode(): string {
// INVITE-XXXXXXXX (8 chars, base32-ish, no ambiguous chars)
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let s = "";
for (let i = 0; i < 8; i++) {
s += alphabet[Math.floor(Math.random() * alphabet.length)];
}
return `INVITE-${s}`;
}
function serializeInvite(row: typeof inviteCodes.$inferSelect, usedByUsername: string | null) {
let status: "unused" | "used" | "revoked";
// Treat the transient `__pending__` sentinel as `used` so the admin UI
// never offers to revoke a row that the revoke query (which requires
// `used_by IS NULL`) would refuse anyway.
if (row.usedBy) status = "used";
else if (row.revokedAt) status = "revoked";
else status = "unused";
return {
code: row.code,
status,
used_by: row.usedBy && row.usedBy !== "__pending__" ? row.usedBy : null,
used_by_username: usedByUsername,
used_at: row.usedAt ? row.usedAt.toISOString() : null,
revoked_at: row.revokedAt ? row.revokedAt.toISOString() : null,
created_by: row.createdBy,
note: row.note,
created_at: row.createdAt.toISOString(),
};
}
router.get("/admin/invite-codes", ...adminGuard, async (_req, res, next) => {
try {
const rows = await db
.select({
code: inviteCodes.code,
usedBy: inviteCodes.usedBy,
usedAt: inviteCodes.usedAt,
revokedAt: inviteCodes.revokedAt,
createdBy: inviteCodes.createdBy,
note: inviteCodes.note,
createdAt: inviteCodes.createdAt,
usedByUsername: users.username,
})
.from(inviteCodes)
.leftJoin(users, eq(users.id, inviteCodes.usedBy))
.orderBy(desc(inviteCodes.createdAt));
const items = rows.map((r) =>
serializeInvite(
{
code: r.code,
usedBy: r.usedBy,
usedAt: r.usedAt,
revokedAt: r.revokedAt,
createdBy: r.createdBy,
note: r.note,
createdAt: r.createdAt,
},
r.usedByUsername ?? null,
),
);
return res.json({ items });
} catch (err) {
next(err);
}
});
router.post("/admin/invite-codes", ...adminGuard, async (req, res, next) => {
try {
const body = req.body ?? {};
const note = typeof body.note === "string" ? body.note.trim().slice(0, 200) : null;
const explicit =
typeof body.code === "string" ? body.code.trim().toUpperCase() : "";
let code = explicit || generateCode();
if (!/^[A-Z0-9-]{4,64}$/.test(code)) {
return res.status(400).json({
error: "code must be 4-64 chars, A-Z/0-9/-",
error_code: "bad_request",
});
}
// Retry generation if collision when auto-generating.
for (let i = 0; i < 5; i++) {
const existing = await db
.select({ code: inviteCodes.code })
.from(inviteCodes)
.where(eq(inviteCodes.code, code))
.limit(1);
if (existing.length === 0) break;
if (explicit) {
return res.status(409).json({
error: "Code already exists",
error_code: "code_taken",
});
}
code = generateCode();
}
await db.insert(inviteCodes).values({
code,
createdBy: req.user!.id,
note: note || null,
});
const fresh = await db
.select()
.from(inviteCodes)
.where(eq(inviteCodes.code, code))
.limit(1);
return res.status(201).json(serializeInvite(fresh[0]!, null));
} catch (err) {
next(err);
}
});
router.post("/admin/invite-codes/:code/revoke", ...adminGuard, async (req, res, next) => {
try {
const code = String(req.params.code || "").trim();
if (!code) {
return res.status(400).json({ error: "code required", error_code: "bad_request" });
}
// Only revoke unused, unrevoked codes. Used codes stay as audit data.
const updated = await db
.update(inviteCodes)
.set({ revokedAt: new Date() })
.where(
and(
eq(inviteCodes.code, code),
isNull(inviteCodes.usedBy),
isNull(inviteCodes.revokedAt),
),
)
.returning();
if (updated.length === 0) {
// Determine why so the client can show a useful message.
const existing = await db
.select()
.from(inviteCodes)
.where(eq(inviteCodes.code, code))
.limit(1);
const row = existing[0];
if (!row) {
return res.status(404).json({ error: "Code not found", error_code: "not_found" });
}
if (row.usedBy && row.usedBy !== "__pending__") {
return res.status(409).json({
error: "Used codes cannot be revoked (kept as audit data)",
error_code: "code_used",
});
}
return res.status(409).json({
error: "Code already revoked",
error_code: "code_revoked",
});
}
return res.json(serializeInvite(updated[0]!, null));
} catch (err) {
next(err);
}
});
// ---------- Tool capability graph admin (Task #147) -----------------------
router.get("/admin/tool-graph", ...adminGuard, async (_req, res, next) => {
try {
const [nodes, edges, gaps, edgeHealth] = await Promise.all([
listToolNodes({ status: "any" }),
listToolEdges(),
listToolGapSignals(),
listToolEdgeHealth(),
]);
const { findTool } = await import("../mcp/tools");
// index health rows so the UI can colour-shift edges by score (#157).
const healthByEdgeId = new Map(edgeHealth.map((h) => [h.edgeId, h]));
// Re-shape edges so the UI can address them by `fromNodeId/toNodeId/kind`
// rather than the wire-level `fromNode/toNode/relation` from the DB row.
// Resolve `edgeId` by looking up the source rows so the UI can request
// per-edge health detail without needing a separate join.
const edgeIdLookup = await import("@workspace/db").then(({ db, toolEdges }) =>
db.select({ id: toolEdges.id, fromNode: toolEdges.fromNode, toNode: toolEdges.toNode, relation: toolEdges.relation })
.from(toolEdges),
);
const idLookup = new Map<string, string>();
for (const r of edgeIdLookup) {
idLookup.set(`${r.fromNode}->${r.toNode}:${r.relation}`, r.id);
}
const uiEdges = edges.map((e) => {
const id = idLookup.get(`${e.fromNode}->${e.toNode}:${e.relation}`) ?? null;
const h = id ? healthByEdgeId.get(id) : undefined;
return {
edgeId: id,
fromNodeId: e.fromNode,
toNodeId: e.toNode,
kind: e.relation,
weight: e.weight,
healthScore: h?.emaHealthScore ?? null,
traversalCount: h?.traversalCount ?? 0,
};
});
// Attach: (a) aggregate evidence stats — total count + success/failure
// — computed by summing the per-row counters, (b) the last 5 evidence
// rows pre-flattened to `{id, kind, ok, payload, createdAt}`, (c) the
// node's incoming/outgoing edges, and (d) an `executable` flag for the
// review UI (provisional nodes without a runtime handler can't be
// shadow-exercised).
const { db: rawDb, toolNodes: rawToolNodes } = await import(
"@workspace/db"
);
const dbRows = await rawDb
.select({
id: rawToolNodes.id,
updatedAt: rawToolNodes.updatedAt,
replacedBy: sql<string | null>`(${rawToolNodes.specJson}->>'replacedBy')`,
})
.from(rawToolNodes);
const metaById = new Map(
dbRows.map((r) => [
r.id,
{ updatedAt: r.updatedAt, replacedBy: r.replacedBy },
]),
);
const enriched = await Promise.all(
nodes.map(async (n) => {
const evidence = await listToolNodeEvidence(n.id, 50);
let successCount = 0;
let failureCount = 0;
for (const ev of evidence) {
successCount += ev.success ?? 0;
failureCount += ev.failure ?? 0;
}
const recent = evidence.slice(0, 5).map((ev) => ({
id: ev.id,
kind: ev.kind,
ok: (ev.success ?? 0) > 0 && (ev.failure ?? 0) === 0,
payload: ev.payload as Record<string, unknown>,
createdAt: ev.createdAt.toISOString(),
}));
const meta = metaById.get(n.id);
const isDeprecated = n.status === "deprecated";
return {
...n,
evidenceCount: evidence.length,
successCount,
failureCount,
executable: !!findTool(n.name),
recent_evidence: recent,
incoming_edges: uiEdges.filter((e) => e.toNodeId === n.id),
outgoing_edges: uiEdges.filter((e) => e.fromNodeId === n.id),
// #159: surface deprecation date + replacement to admin UI.
deprecatedAt:
isDeprecated && meta?.updatedAt
? meta.updatedAt.toISOString()
: null,
replacedBy: isDeprecated ? (meta?.replacedBy ?? null) : null,
};
}),
);
const deprecationCandidates = await listDeprecationCandidates({ status: "any" });
return res.json({
enabled: toolGraphEnabled(),
nodes: enriched,
edges: uiEdges,
gap_signals: gaps,
deprecation_candidates: deprecationCandidates,
deprecation: {
detector_enabled: deprecationDetectorEnabled(),
config: deprecationConfig(),
},
});
} catch (err) {
next(err);
}
});
router.patch(
"/admin/tool-graph/nodes/:id",
...adminGuard,
async (req, res, next) => {
try {
const id = String(req.params.id);
const body = (req.body ?? {}) as {
description?: string;
capability_tags?: string[];
spec?: Record<string, unknown>;
};
const patch: Record<string, unknown> = {};
if (typeof body.description === "string") {
patch["description"] = body.description.slice(0, 2000);
}
if (Array.isArray(body.capability_tags)) {
patch["capabilityTags"] = body.capability_tags
.filter((t) => typeof t === "string")
.map((t) => t.trim())
.filter(Boolean);
}
if (body.spec && typeof body.spec === "object") {
patch["specJson"] = body.spec;
}
if (Object.keys(patch).length === 0) {
return res
.status(400)
.json({ error: "no editable fields provided", error_code: "bad_request" });
}
const { db } = await import("@workspace/db");
const { toolNodes } = await import("@workspace/db");
const updated = await db
.update(toolNodes)
.set(patch)
.where(eq(toolNodes.id, id))
.returning();
if (updated.length === 0) {
return res
.status(404)
.json({ error: "node not found", error_code: "not_found" });
}
return res.json({ ok: true, id });
} catch (err) {
next(err);
}
},
);
router.get(
"/admin/tool-graph/nodes/:id/evidence",
...adminGuard,
async (req, res, next) => {
try {
const items = await listToolNodeEvidence(String(req.params.id), 100);
return res.json({ items });
} catch (err) {
next(err);
}
},
);
router.post(
"/admin/tool-graph/nodes/:id/approve",
...adminGuard,
async (req, res, next) => {
try {
// #161: optional body lets the reviewer record the template
// choice they made on the spawn-template banner. Defaults to
// "use_edit" when an offered template was attached, "fresh"
// otherwise (resolved inside approveNode).
const body = (req.body || {}) as {
template_choice?: string;
source_template_id?: string | null;
};
const allowed = new Set(["use", "use_edit", "fresh"]);
const tc =
body.template_choice && allowed.has(body.template_choice)
? (body.template_choice as "use" | "use_edit" | "fresh")
: undefined;
const node = await approveToolNode(String(req.params.id), {
templateChoice: tc,
sourceTemplateId:
body.source_template_id !== undefined
? body.source_template_id
: undefined,
reviewer: (req as unknown as { user?: { id?: string } }).user?.id,
});
if (!node) {
return res
.status(404)
.json({ error: "node not found", error_code: "not_found" });
}
return res.json(node);
} catch (err) {
next(err);
}
},
);
// ---------- Seam health (Task #157) --------------------------------------
router.get("/admin/tool-graph/edge-health", ...adminGuard, async (req, res, next) => {
try {
const unhealthyOnly = String(req.query?.unhealthy ?? "") === "1";
if (unhealthyOnly) {
const minT = Number(req.query?.min_traversals ?? 1);
const maxScore = Number(req.query?.max_score ?? 0.85);
const items = await listToolUnhealthyEdges({
minTraversals: Number.isFinite(minT) ? minT : 1,
maxHealthScore: Number.isFinite(maxScore) ? maxScore : 0.85,
});
return res.json({ items });
}
const items = await listToolEdgeHealth();
return res.json({ items });
} catch (err) {
next(err);
}
});
router.get(
"/admin/tool-graph/edges/:edgeId/health",
...adminGuard,
async (req, res, next) => {
try {
const sampleLimit = Math.min(100, Math.max(1, Number(req.query?.limit ?? 20)));
const data = await getToolEdgeHealth(String(req.params.edgeId), sampleLimit);
if (!data.edge) {
return res
.status(404)
.json({ error: "edge not found or no health computed yet", error_code: "not_found" });
}
return res.json(data);
} catch (err) {
next(err);
}
},
);
router.post(
"/admin/tool-graph/edge-health/recompute",
...adminGuard,
async (_req, res, next) => {
try {
const r = await recomputeToolEdgeHealth();
return res.json(r);
} catch (err) {
next(err);
}
},
);
router.post(
"/admin/tool-graph/nodes/:id/reject",
...adminGuard,
async (req, res, next) => {
try {
const body = (req.body || {}) as {
template_choice?: string;
source_template_id?: string | null;
};
const allowed = new Set(["use", "use_edit", "fresh"]);
const tc =
body.template_choice && allowed.has(body.template_choice)
? (body.template_choice as "use" | "use_edit" | "fresh")
: undefined;
const ok = await rejectToolNode(String(req.params.id), {
templateChoice: tc,
sourceTemplateId:
body.source_template_id !== undefined
? body.source_template_id
: undefined,
reviewer: (req as unknown as { user?: { id?: string } }).user?.id,
});
if (!ok) {
return res
.status(404)
.json({ error: "node not found", error_code: "not_found" });
}
return res.json({ ok: true });
} catch (err) {
next(err);
}
},
);
// ---------- Spawn templates (Task #161) -----------------------------------
router.get(
"/admin/tool-graph/spawn-templates",
...adminGuard,
async (req, res, next) => {
try {
const { listTemplates } = await import("../lib/spawn-templates");
const status = String(req.query?.status ?? "any");
const allowed = new Set(["active", "demoted", "any"]);
const filter = allowed.has(status)
? (status as "active" | "demoted" | "any")
: "any";
const items = await listTemplates({ status: filter });
return res.json({ items });
} catch (err) {
next(err);
}
},
);
router.patch(
"/admin/tool-graph/spawn-templates/:id",
...adminGuard,
async (req, res, next) => {
try {
const { setTemplateStatus } = await import("../lib/spawn-templates");
const body = (req.body || {}) as {
status?: string;
reason?: string;
};
if (body.status !== "active" && body.status !== "demoted") {
return res.status(400).json({
error: "status must be 'active' or 'demoted'",
error_code: "bad_request",
});
}
const updated = await setTemplateStatus(
String(req.params.id),
body.status,
String(body.reason || "manual_override"),
);
if (!updated) {
return res
.status(404)
.json({ error: "template not found", error_code: "not_found" });
}
return res.json(updated);
} catch (err) {
next(err);
}
},
);
// ---------- Deprecation + pruning (Task #159) -----------------------------
router.get(
"/admin/tool-graph/deprecation-candidates",
...adminGuard,
async (req, res, next) => {
try {
const status = String(req.query?.status ?? "open");
const allowed = new Set(["open", "deferred", "rejected", "approved", "any"]);
const filter = allowed.has(status)
? (status as "open" | "deferred" | "rejected" | "approved" | "any")
: "open";
const items = await listDeprecationCandidates({ status: filter });
return res.json({
items,
config: deprecationConfig(),
detector_enabled: deprecationDetectorEnabled(),
});
} catch (err) {
next(err);
}
},
);
router.post(
"/admin/tool-graph/deprecation/run",
...adminGuard,
async (_req, res, next) => {
try {
if (!deprecationDetectorEnabled()) {
return res.status(409).json({
error: "deprecation detector disabled",
hint: "Set DEPRECATION_DETECTOR_ENABLED=1 to enable.",
});
}
const summary = await runDeprecationDetector();
return res.json(summary);
} catch (err) {
next(err);
}
},
);
router.post(
"/admin/tool-graph/deprecation-candidates/:id/deprecate",
...adminGuard,
async (req, res, next) => {
try {
const reviewer = (req as unknown as { user?: { id?: string } })?.user?.id ?? "admin";
const out = await deprecateToolNode(String(req.params.id), reviewer);
if (!out) {
return res
.status(404)
.json({ error: "candidate not found", error_code: "not_found" });
}
return res.json(out);
} catch (err) {
next(err);
}
},
);
router.post(
"/admin/tool-graph/deprecation-candidates/:id/defer",
...adminGuard,
async (req, res, next) => {
try {
const reviewer = (req as unknown as { user?: { id?: string } })?.user?.id ?? "admin";
const days = Number(req.body?.days);
const out = await deferDeprecationCandidate(
String(req.params.id),
reviewer,
Number.isFinite(days) && days > 0 ? days : undefined,
);
if (!out) {
return res
.status(404)
.json({ error: "candidate not found", error_code: "not_found" });
}
return res.json(out);
} catch (err) {
next(err);
}
},
);
router.post(
"/admin/tool-graph/deprecation-candidates/:id/reject",
...adminGuard,
async (req, res, next) => {
try {
const reviewer = (req as unknown as { user?: { id?: string } })?.user?.id ?? "admin";
const days = Number(req.body?.days);
const out = await rejectDeprecationCandidate(
String(req.params.id),
reviewer,
Number.isFinite(days) && days > 0 ? days : undefined,
);
if (!out) {
return res
.status(404)
.json({ error: "candidate not found", error_code: "not_found" });
}
return res.json(out);
} catch (err) {
next(err);
}
},
);
router.post(
"/admin/tool-graph/archive/run",
...adminGuard,
async (_req, res, next) => {
try {
if (!deprecationDetectorEnabled()) {
return res.status(409).json({
error: "deprecation detector disabled",
hint: "Set DEPRECATION_DETECTOR_ENABLED=1 to enable.",
});
}
const summary = await runArchiveJob();
return res.json(summary);
} catch (err) {
next(err);
}
},
);
router.get(
"/admin/tool-graph/archive",
...adminGuard,
async (req, res, next) => {
try {
const limit = Math.min(
Math.max(Number(req.query.limit ?? 100) || 100, 1),
500,
);
const search =
typeof req.query.search === "string" && req.query.search.trim()
? req.query.search.trim()
: undefined;
const out = await listArchive({ limit, search });
return res.json(out);
} catch (err) {
next(err);
}
},
);
// ---------- Hierarchical summary paths (Task #160) ------------------------
router.get(
"/admin/tool-graph/summary-paths",
...adminGuard,
async (_req, res, next) => {
try {
const paths = await listSummaryPaths({ status: "any" });
return res.json({
enabled: toolGraphEnabled(),
hierarchy_enabled: plannerHierarchyEnabled(),
max_summaries: PLANNER_MAX_SUMMARIES,
paths,
});
} catch (err) {
next(err);
}
},
);
router.patch(
"/admin/tool-graph/summary-paths/:id",
...adminGuard,
async (req, res, next) => {
try {
const id = String(req.params.id);
const body = (req.body ?? {}) as {
description_override?: string | null;
};
if (!("description_override" in body)) {
return res.status(400).json({
error: "description_override is required (string or null)",
error_code: "bad_request",
});
}
const updated = await setSummaryPathOverride(
id,
body.description_override ?? null,
);
if (!updated) {
return res
.status(404)
.json({ error: "summary path not found", error_code: "not_found" });
}
return res.json(updated);
} catch (err) {
next(err);
}
},
);
router.post(
"/admin/tool-graph/summary-paths/rebuild",
...adminGuard,
async (_req, res, next) => {
try {
// Re-run the seed: this rebuilds chains, re-validates coverage,
// and flips any pending_rebuild rows back to active.
const { seedToolGraph } = await import("../lib/tool-graph-seed");
const out = await seedToolGraph();
const paths = await listSummaryPaths({ status: "any" });
return res.json({ ok: true, seed: out, paths });
} catch (err) {
next(err);
}
},
);
// ---------- Tool capability graph: Goals + Runs (Task #169) --------------
router.get("/admin/tool-graph/goals", ...adminGuard, async (_req, res, next) => {
try {
const { listGoals, listRuns } = await import("../lib/tool-graph-search");
const goals = await listGoals();
const withRuns = await Promise.all(
goals.map(async (g) => ({ ...g, runs: (await listRuns(g.id)).slice(0, 5) })),
);
return res.json({ goals: withRuns });
} catch (err) {
next(err);
}
});
router.post("/admin/tool-graph/goals", ...adminGuard, async (req, res, next) => {
try {
const body = (req.body ?? {}) as {
name?: string;
description?: string;
dataset_node_id?: string;
evaluator_node_id?: string;
budget?: Record<string, number>;
constraints?: Record<string, unknown>;
};
if (!body.name || !body.dataset_node_id || !body.evaluator_node_id) {
return res.status(400).json({
error: "name, dataset_node_id and evaluator_node_id are required",
error_code: "bad_request",
});
}
const { createGoal } = await import("../lib/tool-graph-search");
const g = await createGoal({
name: body.name.slice(0, 200),
description: (body.description ?? "").slice(0, 2000),
datasetNodeId: body.dataset_node_id,
evaluatorNodeId: body.evaluator_node_id,
budget: body.budget ?? {},
constraints: body.constraints ?? {},
});
return res.json(g);
} catch (err) {
if (err instanceof Error && /must be verified|not found/.test(err.message)) {
return res.status(400).json({ error: err.message, error_code: "bad_request" });
}
next(err);
}
});
router.get("/admin/tool-graph/goals/:id", ...adminGuard, async (req, res, next) => {
try {
const { getGoal, listRuns } = await import("../lib/tool-graph-search");
const g = await getGoal(String(req.params.id));
if (!g) return res.status(404).json({ error: "goal not found", error_code: "not_found" });
const runs = await listRuns(g.id);
return res.json({ goal: g, runs });
} catch (err) {
next(err);
}
});
router.post("/admin/tool-graph/goals/:id/runs", ...adminGuard, async (req, res, next) => {
try {
const body = (req.body ?? {}) as { budget?: Record<string, number> };
const { runGoalSearch } = await import("../lib/tool-graph-search");
const run = await runGoalSearch(String(req.params.id), { budget: body.budget });
return res.json(run);
} catch (err) {
if (err instanceof Error && /not found/.test(err.message)) {
return res.status(404).json({ error: err.message, error_code: "not_found" });
}
next(err);
}
});
router.post("/admin/tool-graph/goals/:id/archive", ...adminGuard, async (req, res, next) => {
try {
const { archiveGoal } = await import("../lib/tool-graph-search");
const ok = await archiveGoal(String(req.params.id));
if (!ok) return res.status(404).json({ error: "goal not found", error_code: "not_found" });
return res.json({ ok: true });
} catch (err) {
next(err);
}
});
router.get("/admin/tool-graph/runs/:id", ...adminGuard, async (req, res, next) => {
try {
const { getRun } = await import("../lib/tool-graph-search");
const out = await getRun(String(req.params.id));
if (!out) return res.status(404).json({ error: "run not found", error_code: "not_found" });
return res.json(out);
} catch (err) {
next(err);
}
});
router.get("/admin/tool-graph/candidates/:id/diff", ...adminGuard, async (req, res, next) => {
try {
const { diffSubgraphAgainstVerified } = await import("../lib/tool-graph-search");
const diff = await diffSubgraphAgainstVerified(String(req.params.id));
if (!diff) return res.status(404).json({ error: "candidate not found", error_code: "not_found" });
return res.json(diff);
} catch (err) {
next(err);
}
});
router.post(
"/admin/tool-graph/nodes/:id/auto-promote",
...adminGuard,
async (req, res, next) => {
try {
const { autoPromoteIfReady } = await import("../lib/tool-graph-search");
const decision = await autoPromoteIfReady(String(req.params.id), { actor: "admin" });
return res.json(decision);
} catch (err) {
next(err);
}
},
);
router.post(
"/admin/tool-graph/nodes/:id/rollback",
...adminGuard,
async (req, res, next) => {
try {
const { rollbackPromotion } = await import("../lib/tool-graph-search");
const r = await rollbackPromotion(String(req.params.id), "admin");
if (!r.ok) return res.status(400).json({ error: r.reason, error_code: "bad_request" });
return res.json(r);
} catch (err) {
next(err);
}
},
);
router.get(
"/admin/tool-graph/promotion-audit",
...adminGuard,
async (req, res, next) => {
try {
const { listPromotionAudit } = await import("../lib/tool-graph-search");
const nodeId = typeof req.query.node_id === "string" ? req.query.node_id : undefined;
const items = await listPromotionAudit(nodeId);
return res.json({ items });
} catch (err) {
next(err);
}
},
);
// ---- LLM 4-role plumbing (B3) — budget + recent calls ---------------------
//
// Read-only admin views. Lets ops eyeball: 谁花钱了、为什么没用 LLM、
// 哪个角色调用 invalid_response 多。
router.get("/admin/llm-roles/budgets", ...adminGuard, async (_req, res, next) => {
try {
const { llmRoleBudgets, llmRoleCalls } = await import("@workspace/db");
const budgets = await db
.select()
.from(llmRoleBudgets)
.orderBy(desc(llmRoleBudgets.updatedAt));
// Compute today's spent per (capability_id, role) — sum cost_usd
// for status='ok' rows from start-of-UTC-day.
const dayStart = new Date();
dayStart.setUTCHours(0, 0, 0, 0);
const spentRows = await db
.select({
capabilityId: llmRoleCalls.capabilityId,
role: llmRoleCalls.role,
spent: sql<string>`COALESCE(SUM(${llmRoleCalls.costUsd}), 0)`,
})
.from(llmRoleCalls)
.where(
and(
eq(llmRoleCalls.status, "ok"),
sql`${llmRoleCalls.createdAt} >= ${dayStart.toISOString()}`,
),
)
.groupBy(llmRoleCalls.capabilityId, llmRoleCalls.role);
const spentByKey = new Map<string, number>();
for (const r of spentRows) {
const key = `${r.capabilityId ?? ""}|${r.role ?? ""}`;
spentByKey.set(key, Number(r.spent ?? 0));
}
const items = budgets.map((b) => ({
id: b.id,
capabilityId: b.capabilityId,
role: b.role,
dailyLimitUsd: Number(b.dailyLimitUsd),
spentTodayUsd:
spentByKey.get(`${b.capabilityId ?? ""}|${b.role ?? ""}`) ?? 0,
updatedAt: b.updatedAt,
}));
return res.json({ items, asOf: new Date().toISOString() });
} catch (err) {
next(err);
}
});
router.get(
"/admin/llm-roles/recent-calls",
...adminGuard,
async (req, res, next) => {
try {
const { llmRoleCalls } = await import("@workspace/db");
const limit = Math.min(
500,
Math.max(1, Number(req.query.limit ?? 100) || 100),
);
const role =
typeof req.query.role === "string" ? req.query.role : undefined;
const status =
typeof req.query.status === "string" ? req.query.status : undefined;
const capabilityId =
typeof req.query.capability_id === "string"
? req.query.capability_id
: undefined;
const conds = [];
if (role) conds.push(eq(llmRoleCalls.role, role));
if (status) conds.push(eq(llmRoleCalls.status, status));
if (capabilityId)
conds.push(eq(llmRoleCalls.capabilityId, capabilityId));
const where = conds.length === 1 ? conds[0] : conds.length > 1 ? and(...conds) : undefined;
const q = db.select().from(llmRoleCalls);
const rows = await (where ? q.where(where) : q)
.orderBy(desc(llmRoleCalls.createdAt))
.limit(limit);
return res.json({ items: rows.map((r) => ({ ...r, costUsd: Number(r.costUsd) })) });
} catch (err) {
next(err);
}
},
);
export default router;