| 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(); |
|
|
| |
|
|
| |
| |
| |
| |
| |
| 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 ?? ""); |
| |
| |
| |
| 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); |
| } |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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, |
| ); |
| |
| |
| 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); |
| } |
| }, |
| ); |
|
|
| |
|
|
| const adminGuard = [requireAuth, requireAdmin] as const; |
|
|
| function generateCode(): string { |
| |
| 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"; |
| |
| |
| |
| 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", |
| }); |
| } |
|
|
| |
| 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" }); |
| } |
| |
| 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) { |
| |
| 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); |
| } |
| }); |
|
|
| |
|
|
| 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"); |
| |
| const healthByEdgeId = new Map(edgeHealth.map((h) => [h.edgeId, h])); |
| |
| |
| |
| |
| 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, |
| }; |
| }); |
| |
| |
| |
| |
| |
| |
| 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), |
| |
| 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 { |
| |
| |
| |
| |
| 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); |
| } |
| }, |
| ); |
|
|
| |
|
|
| 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); |
| } |
| }, |
| ); |
|
|
| |
|
|
| 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); |
| } |
| }, |
| ); |
|
|
| |
|
|
| 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); |
| } |
| }, |
| ); |
|
|
| |
|
|
| 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 { |
| |
| |
| 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); |
| } |
| }, |
| ); |
|
|
| |
|
|
| 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); |
| } |
| }, |
| ); |
|
|
| |
| |
| |
| |
|
|
| 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)); |
| |
| |
| 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; |
|
|