loki-mode 10.7.0 → 10.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +1 -0
  2. package/SKILL.md +2 -2
  3. package/VERSION +1 -1
  4. package/autonomy/loki +4 -1
  5. package/autonomy/run.sh +9 -5
  6. package/dashboard/__init__.py +1 -1
  7. package/dashboard/server.py +6 -8
  8. package/docs/INSTALLATION.md +1 -1
  9. package/docs/v10/CONTROL-PLANE.md +7 -0
  10. package/docs/v10/CP-ENTERPRISE-UI.md +263 -2
  11. package/docs/v10/DECISIONS.md +41 -0
  12. package/docs/v10/ENGINE-LAWS.md +155 -0
  13. package/docs/v10/FAILURE-CLASSES.md +166 -0
  14. package/docs/v10/GUIDE.md +4 -2
  15. package/docs/v10/L0-WAVE1.md +81 -0
  16. package/docs/v10/METRICS.md +6 -0
  17. package/docs/v10/MORNING-BRIEF-1003.md +11 -25
  18. package/docs/v10/OPERATING-MODEL.md +1 -0
  19. package/docs/v10/PROGRESS.md +28 -0
  20. package/loki-ts/data/model-pricing.json +6 -6
  21. package/loki-ts/dist/loki.js +851 -837
  22. package/loki-ts/package.json +2 -1
  23. package/mcp/__init__.py +1 -1
  24. package/package.json +2 -2
  25. package/packages/control-plane/dist/server.js +17103 -5411
  26. package/packages/control-plane/drizzle/0002_tiresome_fixer.sql +7 -0
  27. package/packages/control-plane/drizzle/0003_integrity.sql +4 -0
  28. package/packages/control-plane/drizzle/0004_cpe03_local_repos_actions.sql +17 -0
  29. package/packages/control-plane/drizzle/meta/0002_snapshot.json +409 -0
  30. package/packages/control-plane/drizzle/meta/0003_snapshot.json +437 -0
  31. package/packages/control-plane/drizzle/meta/0004_snapshot.json +542 -0
  32. package/packages/control-plane/drizzle/meta/_journal.json +22 -1
  33. package/packages/control-plane/package.json +4 -1
  34. package/packages/control-plane/src/db/migrate.ts +6 -2
  35. package/packages/control-plane/src/db/prune.ts +91 -0
  36. package/packages/control-plane/src/db/schema.ts +32 -0
  37. package/packages/control-plane/src/server/app.ts +30 -22
  38. package/packages/control-plane/src/server/audit.ts +15 -0
  39. package/packages/control-plane/src/server/auth.ts +7 -1
  40. package/packages/control-plane/src/server/integrity.ts +147 -0
  41. package/packages/control-plane/src/server/legacy/routes-metrics.ts +18 -0
  42. package/packages/control-plane/src/server/legacy/routes.ts +237 -0
  43. package/packages/control-plane/src/server/legacy/shim.ts +195 -0
  44. package/packages/control-plane/src/server/repos.ts +27 -0
  45. package/packages/control-plane/src/server/routes/artifacts.ts +104 -0
  46. package/packages/control-plane/src/server/routes/audit.ts +17 -0
  47. package/packages/control-plane/src/server/routes/config.ts +319 -0
  48. package/packages/control-plane/src/server/routes/control.ts +75 -0
  49. package/packages/control-plane/src/server/routes/cost.ts +70 -0
  50. package/packages/control-plane/src/server/routes/cost_ledger.ts +210 -0
  51. package/packages/control-plane/src/server/routes/fleet.ts +101 -0
  52. package/packages/control-plane/src/server/routes/index.ts +68 -0
  53. package/packages/control-plane/src/server/routes/integrations.ts +123 -0
  54. package/packages/control-plane/src/server/routes/merge_risk.ts +209 -0
  55. package/packages/control-plane/src/server/routes/metrics.ts +93 -0
  56. package/packages/control-plane/src/server/routes/notify.ts +48 -0
  57. package/packages/control-plane/src/server/routes/providers.ts +111 -0
  58. package/packages/control-plane/src/server/routes/start.ts +39 -0
  59. package/packages/control-plane/src/server/routes/stats.ts +76 -0
  60. package/packages/control-plane/src/server/routes/stream.ts +85 -0
  61. package/packages/control-plane/src/server/routes/verify.ts +66 -0
  62. package/packages/control-plane/src/server/runs.ts +46 -4
  63. package/packages/control-plane/src/server/spawn.ts +79 -0
  64. package/packages/control-plane/src/shipper/discover.ts +50 -3
  65. package/packages/control-plane/src/shipper/ship.ts +15 -0
  66. package/packages/control-plane/src/shipper/watch.ts +3 -1
  67. package/packages/control-plane/ui/dist/assets/index-BQbFv65M.css +1 -0
  68. package/packages/control-plane/ui/dist/assets/index-Dbhu244h.js +155 -0
  69. package/packages/control-plane/ui/dist/index.html +2 -2
  70. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  71. package/web-app/dist/assets/{AdminPage-BQPb5rc5.js → AdminPage-DDDvUz0N.js} +1 -1
  72. package/web-app/dist/assets/{Avatar-Cttco6FC.js → Avatar-Dcdz46i6.js} +1 -1
  73. package/web-app/dist/assets/{Badge-CPb6kpbH.js → Badge-BB24cCpK.js} +1 -1
  74. package/web-app/dist/assets/{Button-caTL28a4.js → Button-D0-DmPZL.js} +1 -1
  75. package/web-app/dist/assets/{CockpitPage-bEbLx6ph.js → CockpitPage-CdjpU2JW.js} +1 -1
  76. package/web-app/dist/assets/{ComparePage-DNtJSij-.js → ComparePage-DT5U0y0d.js} +1 -1
  77. package/web-app/dist/assets/{ErrorBoundary-BrgC3K44.js → ErrorBoundary-lEHcl0EO.js} +1 -1
  78. package/web-app/dist/assets/{EvidenceReceiptPanel-Q-mSrUUQ.js → EvidenceReceiptPanel-GpnWvdCr.js} +1 -1
  79. package/web-app/dist/assets/{GitHubIssuesPanel-DbmYl_Er.js → GitHubIssuesPanel-C7VOh1B7.js} +1 -1
  80. package/web-app/dist/assets/{GitHubPRsPanel-C6PCHM4B.js → GitHubPRsPanel-G_kOuINv.js} +1 -1
  81. package/web-app/dist/assets/{HomePage-7zsL_k_e.js → HomePage-PqRisOny.js} +2 -2
  82. package/web-app/dist/assets/{LoginPage-Cl4ON8RQ.js → LoginPage-DU7pFAkf.js} +1 -1
  83. package/web-app/dist/assets/{MagicPage-BNEAhTqH.js → MagicPage-C5JWIWMk.js} +1 -1
  84. package/web-app/dist/assets/{MetricsPage-DyBH-88q.js → MetricsPage-CbIGS_qo.js} +1 -1
  85. package/web-app/dist/assets/{NotFoundPage-CIVr21yL.js → NotFoundPage-vU1pvdSt.js} +1 -1
  86. package/web-app/dist/assets/{ProjectPage-BMeVYocF.js → ProjectPage-SsixIPPv.js} +3 -3
  87. package/web-app/dist/assets/{ProjectsPage-D-H2WRVU.js → ProjectsPage-BfQKSR9k.js} +1 -1
  88. package/web-app/dist/assets/{SettingsPage-o0kR9RAt.js → SettingsPage-B8YfXAom.js} +1 -1
  89. package/web-app/dist/assets/{ShowcasePage-DtfMDMjp.js → ShowcasePage-Ba407f2-.js} +1 -1
  90. package/web-app/dist/assets/{SystemSettingsPage-CkXdpZ_T.js → SystemSettingsPage-DzNQhhBS.js} +1 -1
  91. package/web-app/dist/assets/{TeamsPage-Cu5H9cQ0.js → TeamsPage-tqUnpZ15.js} +1 -1
  92. package/web-app/dist/assets/{TemplatesPage-3wdbVwil.js → TemplatesPage-ClB5EpI3.js} +1 -1
  93. package/web-app/dist/assets/{TerminalOutput-CWn301xh.js → TerminalOutput-CBvpYfEu.js} +1 -1
  94. package/web-app/dist/assets/{activity-YGaAt4zJ.js → activity-C1s0L_X9.js} +1 -1
  95. package/web-app/dist/assets/{bell-C28q0GO7.js → bell-DMEiXr73.js} +1 -1
  96. package/web-app/dist/assets/{bot-DLcQGI92.js → bot-BF_xIOxu.js} +1 -1
  97. package/web-app/dist/assets/{check-w0sBNlcm.js → check-Dv1Ynvrs.js} +1 -1
  98. package/web-app/dist/assets/{chevron-left-W7O0vC_m.js → chevron-left-Ddhsod7S.js} +1 -1
  99. package/web-app/dist/assets/{circle-alert-Cxjq9fxB.js → circle-alert-OATHUkBZ.js} +1 -1
  100. package/web-app/dist/assets/{clock-C8EqzmNl.js → clock-C1Ltva07.js} +1 -1
  101. package/web-app/dist/assets/{cloud-xNgp4g3Y.js → cloud-B-S4oOy9.js} +1 -1
  102. package/web-app/dist/assets/{code-xml-B-lyNDLJ.js → code-xml-D3Femn_F.js} +1 -1
  103. package/web-app/dist/assets/{database-DoW5lmWc.js → database-DBSeFgO3.js} +1 -1
  104. package/web-app/dist/assets/{dollar-sign-BcARA5sC.js → dollar-sign-sgDgNuGE.js} +1 -1
  105. package/web-app/dist/assets/{file-code-corner-CehcII0J.js → file-code-corner-C-MbdZEc.js} +1 -1
  106. package/web-app/dist/assets/{file-plus-DI9Zo5hP.js → file-plus-CCB6yqXv.js} +1 -1
  107. package/web-app/dist/assets/{globe-CiZWBk3H.js → globe-DlbrNAj1.js} +1 -1
  108. package/web-app/dist/assets/{hammer-3qv_T2N-.js → hammer-B-DWm695.js} +1 -1
  109. package/web-app/dist/assets/{index-gF9grAz-.js → index-Dvny-1SU.js} +3 -3
  110. package/web-app/dist/assets/{layers-CN7d0By9.js → layers-99qsKpOT.js} +1 -1
  111. package/web-app/dist/assets/{loader-circle-DywcM-im.js → loader-circle-ZvZyZl3J.js} +1 -1
  112. package/web-app/dist/assets/{lock-_hdSmkU4.js → lock-DosGjcxP.js} +1 -1
  113. package/web-app/dist/assets/{package-BV5zgqxJ.js → package-C-PumesN.js} +1 -1
  114. package/web-app/dist/assets/{plus-CU_Hh4-x.js → plus-CTYYskTB.js} +1 -1
  115. package/web-app/dist/assets/{refresh-cw-DMK-fNl-.js → refresh-cw-qW2ztTFS.js} +1 -1
  116. package/web-app/dist/assets/{rotate-ccw-CEB3_v5_.js → rotate-ccw-ZIKrf-vO.js} +1 -1
  117. package/web-app/dist/assets/{scroll-text-BSdXxcRN.js → scroll-text-BamlXjAT.js} +1 -1
  118. package/web-app/dist/assets/{server-CAMlP1yJ.js → server-BljXb79I.js} +1 -1
  119. package/web-app/dist/assets/{shield-alert-B-r1Dt3h.js → shield-alert-Dr6eT9c_.js} +1 -1
  120. package/web-app/dist/assets/{thumbs-up-CYfxw9o4.js → thumbs-up-v3oBXZgU.js} +1 -1
  121. package/web-app/dist/assets/{trash-2-DX3nGPS8.js → trash-2-BOuIrCpi.js} +1 -1
  122. package/web-app/dist/assets/{trending-down-Bt-hSntd.js → trending-down-Dp7jMCgH.js} +1 -1
  123. package/web-app/dist/assets/{trending-up-DVSlvQp8.js → trending-up-CDiur-nP.js} +1 -1
  124. package/web-app/dist/assets/{upload-D45RRHAP.js → upload-DxIHXhNE.js} +1 -1
  125. package/web-app/dist/assets/{usePolling-fq_Xj8iw.js → usePolling-D2Uu-YZb.js} +1 -1
  126. package/web-app/dist/assets/{user-BWcJkLlr.js → user-BVWCUAfF.js} +1 -1
  127. package/web-app/dist/index.html +1 -1
  128. package/packages/control-plane/src/server/actions.ts +0 -60
  129. package/packages/control-plane/ui/dist/assets/index-CUQ65vJY.css +0 -1
  130. package/packages/control-plane/ui/dist/assets/index-CwwGfp6E.js +0 -129
@@ -0,0 +1,209 @@
1
+ // CPE-25 merge queue and CPE-26 PR risk. The CLI is the source of truth: each route runs `loki merge ...` or `loki review --risk --json`
2
+ // with execFile and a fixed argv array (no shell), a timeout and an output cap, in the selected repo. Nothing is reimplemented here.
3
+ import { spawn } from "node:child_process";
4
+ import { realpathSync } from "node:fs";
5
+ import { basename, resolve } from "node:path";
6
+ import type { Context } from "hono";
7
+ import { audit } from "../audit.ts";
8
+ import { childEnv, registryRepos } from "../spawn.ts";
9
+ import { isLoopbackHost } from "../auth.ts";
10
+ import { originOk } from "./start.ts";
11
+ import type { RouteCtx } from "./index.ts";
12
+
13
+ const PR = /^[0-9]{1,7}$/;
14
+ const REF = /^[A-Za-z0-9._/][A-Za-z0-9._/-]{0,99}$/;
15
+ const MAX_OUT = 256 * 1024;
16
+ const MAX_PRS = 20;
17
+
18
+ export interface CliResult { code: number | null; stdout: string; stderr: string; timedOut: boolean; capped: boolean; error?: string }
19
+
20
+ /** Tunables, exported so tests can pin the production values and inject a short timeout. */
21
+ export const limits = { mergeRunMs: 600_000, dryRunMs: 60_000, shortMs: 15_000, riskMs: 60_000, graceMs: 2_000, bodyMax: 5_000, maxConcurrent: 2 };
22
+
23
+ const killGroup = (pid: number | undefined, sig: NodeJS.Signals): void => {
24
+ if (!pid) return;
25
+ try { process.kill(-pid, sig); } catch { /* group already gone */ }
26
+ };
27
+
28
+ /** Run the CLI with a fixed argv, detached in its own process group so a timeout or output-cap kill reaches every descendant. Never rejects. */
29
+ export function runCli(bin: string, args: string[], cwd: string, timeoutMs: number, extraEnv: Record<string, string> = {}): Promise<CliResult> {
30
+ return new Promise((done) => {
31
+ let out = "", err = "", size = 0, timedOut = false, capped = false, settled = false, spawnErr: string | undefined;
32
+ let timer: ReturnType<typeof setTimeout> | undefined, hard: ReturnType<typeof setTimeout> | undefined;
33
+ const finish = (code: number | null): void => {
34
+ if (settled) return;
35
+ settled = true;
36
+ if (timer) clearTimeout(timer);
37
+ if (hard) clearTimeout(hard);
38
+ const error = spawnErr ?? (timedOut ? "timed out" : capped ? "output exceeded the size cap" : undefined);
39
+ done({ code: timedOut || capped ? null : code, stdout: out, stderr: err, timedOut, capped, error });
40
+ };
41
+ try {
42
+ const child = spawn(bin, args, { cwd, detached: true, stdio: ["ignore", "pipe", "pipe"], shell: false, env: { ...childEnv(), ...extraEnv }, windowsHide: true });
43
+ const stop = (): void => {
44
+ killGroup(child.pid, "SIGTERM");
45
+ hard = setTimeout(() => { killGroup(child.pid, "SIGKILL"); finish(null); }, limits.graceMs);
46
+ };
47
+ const take = (which: "o" | "e") => (d: Buffer): void => {
48
+ if (capped) return;
49
+ size += d.length;
50
+ if (size > MAX_OUT) { capped = true; stop(); return; }
51
+ if (which === "o") out += d.toString("utf8"); else err += d.toString("utf8");
52
+ };
53
+ child.stdout!.on("data", take("o"));
54
+ child.stderr!.on("data", take("e"));
55
+ child.once("error", (e) => { spawnErr = e.message; finish(null); });
56
+ child.once("close", (code) => { killGroup(child.pid, "SIGKILL"); finish(code); });
57
+ timer = setTimeout(() => { timedOut = true; stop(); }, timeoutMs);
58
+ } catch (e) { spawnErr = (e as Error).message; finish(null); }
59
+ });
60
+ }
61
+
62
+ export interface RiskReport { score: number; level: string; source: string; files: number | null; factors: Array<{ factor: string; points: number; max: number; detail: string }> }
63
+
64
+ /** Parse `loki review --risk --json`. Anything that is not a well-formed 0-100 report is null (the caller says "not measured"). */
65
+ export function parseRisk(out: string): RiskReport | null {
66
+ try {
67
+ const j = JSON.parse(out.trim()) as Record<string, unknown>;
68
+ const factors = j["factors"];
69
+ if (typeof j["score"] !== "number" || !Number.isFinite(j["score"]) || j["score"] < 0 || j["score"] > 100 || !Array.isArray(factors)) return null;
70
+ const fs = factors.map((f) => (f && typeof f === "object" ? (f as Record<string, unknown>) : {}));
71
+ if (!fs.every((f) => typeof f["factor"] === "string" && typeof f["points"] === "number" && typeof f["max"] === "number")) return null;
72
+ return {
73
+ score: j["score"], level: typeof j["level"] === "string" ? j["level"] : "unknown", source: typeof j["source"] === "string" ? j["source"] : "",
74
+ files: typeof j["files"] === "number" && Number.isFinite(j["files"]) && j["files"] >= 0 ? j["files"] : null,
75
+ factors: fs.map((f) => ({ factor: f["factor"] as string, points: f["points"] as number, max: f["max"] as number, detail: typeof f["detail"] === "string" ? f["detail"] : "" })),
76
+ };
77
+ } catch { return null; }
78
+ }
79
+
80
+ export function mount(ctx: RouteCtx): void {
81
+ const { act, db } = ctx;
82
+ const bin = () => ctx.startBin ?? process.env["LOKI_BIN"] ?? "loki";
83
+ const busy = new Set<string>();
84
+ const active = { run: 0, queue: 0, risk: 0 };
85
+ const real = (p: string): string | null => { try { return realpathSync(p); } catch { return null; } };
86
+
87
+ /** The repo must be one the server knows (cwd or the project registry), never an arbitrary path. Returns the REAL path so every lock and cwd is symlink-proof. */
88
+ const repoFor = (want: unknown): string | null => {
89
+ if (want === undefined || want === null || want === "") return real(ctx.repoDir);
90
+ if (typeof want !== "string" || want.includes("\0")) return null;
91
+ const known = new Map<string, string>();
92
+ for (const k of [ctx.repoDir, ...registryRepos()]) { const r = real(k); if (r) known.set(r, basename(resolve(k))); }
93
+ const wantReal = real(want);
94
+ if (wantReal && known.has(wantReal)) return wantReal;
95
+ const named = [...known].filter(([r, label]) => label === want || basename(r) === want).map(([r]) => r);
96
+ return named.length === 1 ? named[0]! : null;
97
+ };
98
+ /** Small per-route cap on concurrent CLI spawns. */
99
+ const slot = async <T,>(c: Context, kind: "run" | "queue" | "risk", auditKind: string, fn: () => Promise<T>): Promise<T | Response> => {
100
+ if (active[kind] >= limits.maxConcurrent) { audit(db, { kind: auditKind, result: "refused", detail: "too many concurrent requests" }); return c.json({ error: "too many concurrent requests" }, 429); }
101
+ active[kind]++;
102
+ try { return await fn(); } finally { active[kind]--; }
103
+ };
104
+ const refuse = (c: Context, kind: string, target: string | undefined, status: 400 | 403 | 413 | 422, error: string) => {
105
+ audit(db, { kind, target, result: "refused", detail: error });
106
+ return c.json({ error }, status);
107
+ };
108
+ /** Shared guard for mutating calls: loopback JSON, loopback Origin, bounded JSON body. */
109
+ const readBody = async (c: Context, kind: string): Promise<Record<string, unknown> | Response> => {
110
+ if (!ctx.local(c)) return refuse(c, kind, undefined, 403, "loopback JSON requests only");
111
+ if (!originOk(c.req.header("origin"))) return refuse(c, kind, undefined, 403, "origin not allowed");
112
+ const text = await c.req.text();
113
+ if (text.length > limits.bodyMax) return refuse(c, kind, undefined, 413, "body too large");
114
+ try {
115
+ const b = JSON.parse(text) as unknown;
116
+ return b && typeof b === "object" && !Array.isArray(b) ? (b as Record<string, unknown>) : refuse(c, kind, undefined, 400, "body must be a JSON object");
117
+ } catch { return refuse(c, kind, undefined, 400, "invalid JSON"); }
118
+ };
119
+
120
+ /** Spawning reads (no body, so no JSON check): loopback peer AND loopback Host, so DNS rebinding cannot drive a CLI spawn. Origin is checked after. */
121
+ const readGuard = (c: Context): boolean => ctx.peerIsLoopback(c) && isLoopbackHost(c.req.header("host"));
122
+
123
+ act.get("/v1/merge/queue", async (c) => {
124
+ if (!readGuard(c)) return refuse(c, "merge.list", undefined, 403, "loopback only");
125
+ if (!originOk(c.req.header("origin"))) return refuse(c, "merge.list", undefined, 403, "origin not allowed");
126
+ const cwd = repoFor(c.req.query("repo"));
127
+ if (!cwd) return refuse(c, "merge.list", undefined, 422, "repo is not a known project");
128
+ return slot(c, "queue", "merge.list", async () => {
129
+ const r = await runCli(bin(), ["merge", "list"], cwd, limits.shortMs);
130
+ if (r.code !== 0) return c.json({ measured: false, error: r.error ?? `loki merge list exited ${r.code}`, queue: [] });
131
+ const queue = r.stdout.split("\n").map((l) => /^#([0-9]{1,7})$/.exec(l.trim())?.[1]).filter((n): n is string => n !== undefined).map(Number);
132
+ return c.json({ measured: true, queue });
133
+ });
134
+ });
135
+
136
+ act.post("/v1/merge/queue", async (c) => {
137
+ const b = await readBody(c, "merge.add");
138
+ if (b instanceof Response) return b;
139
+ const raw = Array.isArray(b["prs"]) ? (b["prs"] as unknown[]) : b["pr"] !== undefined ? [b["pr"]] : [];
140
+ const prs = raw.map((p) => (typeof p === "number" && Number.isInteger(p) ? String(p) : p));
141
+ if (prs.length === 0 || prs.length > MAX_PRS || !prs.every((p) => typeof p === "string" && PR.test(p))) return refuse(c, "merge.add", undefined, 422, `prs must be 1 to ${MAX_PRS} PR numbers (digits only, 7 max)`);
142
+ const cwd = repoFor(b["repo"]);
143
+ if (!cwd) return refuse(c, "merge.add", undefined, 422, "repo is not a known project");
144
+ const list = prs as string[];
145
+ return slot(c, "queue", "merge.add", async () => {
146
+ const r = await runCli(bin(), ["merge", "add", ...list], cwd, limits.shortMs);
147
+ const ok = r.code === 0;
148
+ const error = ok ? undefined : (r.error ?? (r.stderr.trim() || `exit ${r.code}`));
149
+ audit(db, { kind: "merge.add", target: list.map((p) => `#${p}`).join(" "), result: ok ? "ok" : "error", detail: error });
150
+ return c.json({ ok, exit: r.code, output: r.stdout.trim(), error }, ok ? 200 : 500);
151
+ });
152
+ });
153
+
154
+ // dryRun is required and must be a boolean: a missing flag never silently becomes a real merge.
155
+ act.post("/v1/merge/run", async (c) => {
156
+ const b = await readBody(c, "merge.run");
157
+ if (b instanceof Response) return b;
158
+ if (typeof b["dryRun"] !== "boolean") return refuse(c, "merge.run", undefined, 422, "dryRun must be a boolean");
159
+ const dry = b["dryRun"];
160
+ const kind = dry ? "merge.dry_run" : "merge.run";
161
+ const cwd = repoFor(b["repo"]);
162
+ if (!cwd) return refuse(c, kind, undefined, 422, "repo is not a known project");
163
+ if (!dry && busy.has(cwd)) { audit(db, { kind, result: "conflict", detail: "merge already running" }); return c.json({ error: "a merge run is already in progress for this repo" }, 409); }
164
+ return slot(c, "run", kind, async () => {
165
+ if (!dry) busy.add(cwd);
166
+ try {
167
+ const ms = dry ? limits.dryRunMs : limits.mergeRunMs;
168
+ // The CLI polls checks per PR; keep its own budget well inside the route timeout so it ends itself before the group kill.
169
+ const poll = 20, tries = Math.max(1, Math.floor((ms / 1000) * 0.8 / poll));
170
+ const r = await runCli(bin(), dry ? ["merge", "run", "--dry-run"] : ["merge", "run"], cwd, ms, { LOKI_MERGE_POLL_S: String(poll), LOKI_MERGE_MAX_POLLS: String(tries) });
171
+ const lines = r.stdout.split("\n").filter(Boolean);
172
+ // Only exit 1 means "PRs left in the queue" (a result). Any other non-zero exit, a timeout or a cap kill is an error.
173
+ const noGh = /gh CLI is required/.test(r.stdout + r.stderr);
174
+ const ran = (r.code === 0 || r.code === 1) && !noGh;
175
+ if (!dry) for (const l of lines) { const m = /^#([0-9]{1,7}): merged$/.exec(l.trim()); if (m) audit(db, { kind: "merge.pr_merged", target: `#${m[1]}`, result: "ok", detail: r.timedOut ? "recorded before timeout" : undefined }); }
176
+ const reason = r.timedOut ? "merge run timed out; the outcome is partial, check the queue and the PRs" : r.capped ? "merge run output exceeded the size cap and was stopped; the outcome is partial" : noGh ? "the gh CLI is required and was not found; nothing was merged" : r.error ?? (!ran ? `loki merge run exited ${r.code}: ${r.stderr.trim().slice(0, 300)}` : undefined);
177
+ audit(db, { kind, result: !ran ? "error" : r.code === 0 ? "ok" : "blocked", detail: reason ?? lines.slice(-1)[0] });
178
+ return c.json({ ok: r.code === 0, ran, dryRun: dry, exit: r.code, lines, timedOut: r.timedOut, capped: r.capped, partial: r.timedOut || r.capped, error: reason }, ran ? 200 : 500);
179
+ } finally { if (!dry) busy.delete(cwd); }
180
+ });
181
+ });
182
+
183
+ act.get("/v1/review/risk", async (c) => {
184
+ if (!readGuard(c)) return refuse(c, "risk.read", undefined, 403, "loopback only");
185
+ if (!originOk(c.req.header("origin"))) return refuse(c, "risk.read", undefined, 403, "origin not allowed");
186
+ const pr = c.req.query("pr"), since = c.req.query("since"), staged = c.req.query("staged");
187
+ const given = [pr !== undefined, since !== undefined, staged !== undefined].filter(Boolean).length;
188
+ if (given !== 1) return refuse(c, "risk.read", undefined, 422, "give exactly one of pr, since or staged=1");
189
+ let flags: string[], target: string;
190
+ if (pr !== undefined) {
191
+ if (!PR.test(pr)) return refuse(c, "risk.read", undefined, 422, "pr must be digits only (7 max)");
192
+ flags = ["--pr", pr]; target = `#${pr}`;
193
+ } else if (since !== undefined) {
194
+ if (!REF.test(since)) return refuse(c, "risk.read", undefined, 422, "since must match [A-Za-z0-9._/-]{1,100} with no leading -");
195
+ flags = ["--since", since]; target = `since ${since}`;
196
+ } else {
197
+ if (staged !== "1") return refuse(c, "risk.read", undefined, 422, "staged must be 1");
198
+ flags = ["--staged"]; target = "staged";
199
+ }
200
+ const cwd = repoFor(c.req.query("repo"));
201
+ if (!cwd) return refuse(c, "risk.read", target, 422, "repo is not a known project");
202
+ return slot(c, "risk", "risk.read", async () => {
203
+ const r = await runCli(bin(), ["review", "--risk", "--json", ...flags], cwd, limits.riskMs);
204
+ const parsed = r.code === 0 ? parseRisk(r.stdout) : null;
205
+ if (!parsed) return c.json({ measured: false, reason: r.error ?? (r.code !== 0 ? `loki review exited ${r.code}: ${(r.stderr.trim() || r.stdout.trim()).slice(0, 300)}` : "output was not a risk report") });
206
+ return c.json({ measured: true, ...parsed });
207
+ });
208
+ });
209
+ }
@@ -0,0 +1,93 @@
1
+ // CPE24-P1: GET /v1/metrics, the Prometheus text exposition ported from dashboard/server.py (_build_metrics_text), same .loki/ flat-file sources.
2
+ // Honest data: a gauge whose source is absent or unmeasured emits NO sample (only HELP/TYPE plus a "not measured" comment), never a 0.
3
+ import { readdirSync, readFileSync, statSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { probeAllowed, type RouteCtx } from "./index.ts";
6
+ import { NOT_MEASURED, costSnapshot, lokiDirExists, lokiDirOf, readJson } from "./cost_ledger.ts";
7
+ import { pidAlive } from "./fleet.ts";
8
+
9
+ type Rec = Record<string, unknown>;
10
+ const isObj = (v: unknown): v is Rec => typeof v === "object" && v !== null && !Array.isArray(v);
11
+ const arr = (v: unknown) => (Array.isArray(v) ? v : []);
12
+
13
+ export function buildMetricsText(repoDir: string, now = Date.now()): string {
14
+ if (!lokiDirExists(repoDir)) return "# loki_up 0\n";
15
+ const loki = lokiDirOf(repoDir);
16
+ const L: string[] = [];
17
+ const gauge = (name: string, help: string, type: string, samples: string[] | null) => {
18
+ L.push(`# HELP ${name} ${help}`, `# TYPE ${name} ${type}`);
19
+ if (samples === null) L.push(`# ${name} ${NOT_MEASURED}`); else L.push(...samples);
20
+ L.push("");
21
+ };
22
+ const stRaw = readJson(join(loki, "dashboard-state.json"));
23
+ const state: Rec | null = isObj(stRaw) ? stRaw : null;
24
+
25
+ let status = 0;
26
+ if (state?.mode === "paused") status = 2;
27
+ else if (state?.mode === "autonomous" || state?.mode === "running") status = 1;
28
+ else {
29
+ try { if (pidAlive(parseInt(readFileSync(join(loki, "loki.pid"), "utf8").trim(), 10))) status = 1; } catch { /* stopped */ }
30
+ }
31
+ gauge("loki_session_status", "Current session status (0=stopped, 1=running, 2=paused)", "gauge", [`loki_session_status ${status}`]);
32
+ gauge("loki_iteration_current", "Current iteration number", "gauge", typeof state?.iteration === "number" ? [`loki_iteration_current ${state.iteration}`] : null);
33
+ const maxIt = Number.parseInt(process.env.LOKI_MAX_ITERATIONS ?? "", 10);
34
+ gauge("loki_iteration_max", "Maximum configured iterations", "gauge", [`loki_iteration_max ${Number.isFinite(maxIt) ? maxIt : 1000}`]);
35
+
36
+ const tasks = state && isObj(state.tasks) ? state.tasks : null;
37
+ gauge("loki_tasks_total", "Number of tasks by status", "gauge", tasks ? [
38
+ `loki_tasks_total{status="pending"} ${arr(tasks.pending).length}`, `loki_tasks_total{status="in_progress"} ${arr(tasks.inProgress).length}`,
39
+ `loki_tasks_total{status="completed"} ${arr(tasks.completed).length}`, `loki_tasks_total{status="failed"} ${arr(tasks.failed).length}`,
40
+ ] : null);
41
+
42
+ let agents: unknown[] | null = null;
43
+ const af = readJson(join(loki, "state", "agents.json"));
44
+ if (Array.isArray(af) && af.length) agents = af;
45
+ else if (state && Array.isArray(state.agents) && state.agents.length) agents = state.agents;
46
+ const active = (agents ?? []).filter((a) => isObj(a) && a.status === "active").length;
47
+ gauge("loki_agents_active", "Number of currently active agents", "gauge", agents ? [`loki_agents_active ${active}`] : null);
48
+ gauge("loki_agents_total", "Total number of agents registered", "gauge", agents ? [`loki_agents_total ${agents.length}`] : null);
49
+
50
+ const cost = costSnapshot(loki);
51
+ gauge("loki_cost_usd", "Estimated total cost in USD", "gauge", cost.estimated_cost_usd !== null ? [`loki_cost_usd ${cost.estimated_cost_usd}`] : null);
52
+
53
+ let events: number | null = null;
54
+ try { events = readFileSync(join(loki, "events.jsonl"), "utf8").split("\n").filter((l) => l.trim()).length; } catch { /* absent */ }
55
+ gauge("loki_events_total", "Total number of events recorded", "counter", events !== null ? [`loki_events_total ${events}`] : null);
56
+
57
+ let uptime: number | null = null;
58
+ if (typeof state?.startedAt === "string") {
59
+ const s = Date.parse(state.startedAt);
60
+ if (!Number.isNaN(s)) uptime = Math.max(0, Math.round(((now - s) / 1000) * 10) / 10);
61
+ }
62
+ gauge("loki_uptime_seconds", "Seconds since session started", "gauge", uptime !== null ? [`loki_uptime_seconds ${uptime}`] : null);
63
+
64
+ let total = 0, ver = 0, gaps = 0, notver = 0, unk = 0, att = 0, sig = 0;
65
+ const pdir = join(loki, "proofs");
66
+ let entries: string[] = [];
67
+ try { entries = readdirSync(pdir).sort(); } catch { /* none */ }
68
+ for (const e of entries) {
69
+ try { if (!statSync(join(pdir, e)).isDirectory()) continue; } catch { continue; }
70
+ const d = readJson(join(pdir, e, "proof.json"));
71
+ if (!isObj(d)) continue;
72
+ total++;
73
+ const head = isObj(d.honesty) ? d.honesty.headline : null;
74
+ if (head === "VERIFIED") ver++; else if (head === "VERIFIED WITH GAPS") gaps++; else if (head === "NOT VERIFIED") notver++; else unk++;
75
+ if (isObj(d.verification)) { if (d.verification.attestation) att++; if (d.verification.gpg_signature) sig++; }
76
+ }
77
+ gauge("loki_receipts_total", "Evidence Receipts on disk", "gauge", [`loki_receipts_total ${total}`]);
78
+ gauge("loki_receipts_by_verdict", "Receipts bucketed on the recorded honesty headline", "gauge", [
79
+ `loki_receipts_by_verdict{verdict="verified"} ${ver}`, `loki_receipts_by_verdict{verdict="with_gaps"} ${gaps}`,
80
+ `loki_receipts_by_verdict{verdict="not_verified"} ${notver}`, `loki_receipts_by_verdict{verdict="unknown"} ${unk}`,
81
+ ]);
82
+ gauge("loki_receipts_with_provenance", "Receipts carrying a checkable provenance record", "gauge", [
83
+ `loki_receipts_with_provenance{kind="attestation"} ${att}`, `loki_receipts_with_provenance{kind="gpg"} ${sig}`,
84
+ ]);
85
+ return L.join("\n") + "\n";
86
+ }
87
+
88
+ export function mount(ctx: RouteCtx): void {
89
+ ctx.app.get("/v1/metrics", (c) => {
90
+ if (!probeAllowed(ctx, c)) return c.json({ error: "loopback only without a token" }, 403);
91
+ return c.text(buildMetricsText(ctx.repoDir), 200, { "content-type": "text/plain; version=0.0.4; charset=utf-8" });
92
+ });
93
+ }
@@ -0,0 +1,48 @@
1
+ // GET /v1/notifications: derived from the runs projection and stored events. Nothing is stored or invented; unread state is client-side.
2
+ import { desc, inArray } from "drizzle-orm";
3
+ import type { Db } from "../../db/migrate.ts";
4
+ import { events, runs } from "../../db/schema.ts";
5
+ import { effectiveVerdict } from "../integrity.ts";
6
+ import type { RouteCtx } from "./index.ts";
7
+
8
+ export type NotificationKind = "blocked" | "finished" | "tampered" | "conflict" | "budget";
9
+ export interface Notification {
10
+ id: string; kind: NotificationKind; ts: string; source_id: string; run_id: string;
11
+ verdict: string | null; title: string; link: string;
12
+ }
13
+
14
+ const BUDGET_TYPES = ["cap.hit", "budget.hit"];
15
+
16
+ export function deriveNotifications(db: Db): Notification[] {
17
+ const out: Notification[] = [];
18
+ const mk = (kind: NotificationKind, s: string, r: string, ts: string | null, verdict: string | null, title: string, extra = ""): void => {
19
+ out.push({ id: `${kind}:${s}:${r}${extra}`, kind, ts: ts ?? "", source_id: s, run_id: r, verdict, title, link: `/runs/${encodeURIComponent(s)}/${encodeURIComponent(r)}` });
20
+ };
21
+ for (const r of db.select().from(runs).all()) {
22
+ const when = r.endedAt ?? r.lastEventAt ?? r.startedAt;
23
+ const name = r.originRepo ?? r.runId;
24
+ if (r.verdict === "SPEC_CONFLICT") mk("blocked", r.sourceId, r.runId, when, r.verdict, `Blocked, waiting for your answer: ${name}`);
25
+ else if (r.endedAt && r.verdict) {
26
+ const ev = effectiveVerdict({ verdict: r.verdict, tampered: r.tampered === 1, attested: r.attested === 1, sig_checked: r.sigChecked === 1 }) ?? r.verdict;
27
+ mk("finished", r.sourceId, r.runId, r.endedAt, ev, `Finished ${ev}: ${name}`);
28
+ }
29
+ if (r.tampered === 1) mk("tampered", r.sourceId, r.runId, when, r.verdict, `Event log failed its integrity check: ${name}`);
30
+ if (r.conflict === 1) mk("conflict", r.sourceId, r.runId, when, r.verdict, `Ingest conflict, a stored event was contradicted: ${name}`);
31
+ }
32
+ for (const e of db.select({ s: events.sourceId, r: events.runId, seq: events.seq, ts: events.ts, type: events.type }).from(events).where(inArray(events.type, BUDGET_TYPES)).orderBy(desc(events.ts)).all()) {
33
+ mk("budget", e.s, e.r, e.ts, null, `Stopped at its ${e.type === "cap.hit" ? "time" : "budget"} cap: ${e.r}`, `:${e.seq}`);
34
+ }
35
+ return out.sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : a.id < b.id ? -1 : 1));
36
+ }
37
+
38
+ export function mount(ctx: RouteCtx): void {
39
+ ctx.app.get("/v1/notifications", (c) => {
40
+ const all = deriveNotifications(ctx.db);
41
+ const limit = Math.min(Math.max(Number.parseInt(c.req.query("limit") ?? "50", 10) || 50, 1), 200);
42
+ const offset = Math.max(Number.parseInt(c.req.query("cursor") ?? "0", 10) || 0, 0);
43
+ const kind = c.req.query("kind");
44
+ const list = kind ? all.filter((n) => n.kind === kind) : all;
45
+ const page = list.slice(offset, offset + limit);
46
+ return c.json({ notifications: page, total: list.length, next_cursor: offset + page.length < list.length ? String(offset + page.length) : null });
47
+ });
48
+ }
@@ -0,0 +1,111 @@
1
+ // GET /v1/providers (CPE-15): which provider CLIs are installed, their versions, which auth env var NAMES are set (never values), and the model tiers.
2
+ // Behind the /v1 token guard. Every probe is bounded by a timeout; nothing here reads or returns a secret value.
3
+ import { spawn } from "node:child_process";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { delimiter, join } from "node:path";
6
+ import { probeAllowed, ttlCache, type RouteCtx } from "./index.ts";
7
+
8
+ const ORDER = ["claude", "codex", "cline", "aider", "opencode"] as const;
9
+ const DEPRECATED = ["gemini"] as const;
10
+ const AUTH_ENV: Record<string, string[]> = {
11
+ claude: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"],
12
+ codex: ["OPENAI_API_KEY"],
13
+ cline: ["ANTHROPIC_API_KEY", "OPENROUTER_API_KEY", "OPENAI_API_KEY"],
14
+ aider: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY"],
15
+ opencode: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY"],
16
+ gemini: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
17
+ };
18
+ const DEFAULT_TIMEOUT_MS = 3000;
19
+
20
+ export interface ProbeResult { installed: boolean; version: string | null; probe: "ok" | "timeout" | "error" | "not_found" }
21
+
22
+ function findOnPath(bin: string, pathVar: string): string | null {
23
+ for (const d of pathVar.split(delimiter)) {
24
+ if (!d) continue;
25
+ const p = join(d, bin);
26
+ if (existsSync(p)) return p;
27
+ }
28
+ return null;
29
+ }
30
+
31
+ /** Run `<bin> --version` with a hard timeout. Only a version-looking token is returned, never raw output. */
32
+ export function probeCli(bin: string, timeoutMs: number, pathVar = process.env.PATH ?? ""): Promise<ProbeResult> {
33
+ const exe = findOnPath(bin, pathVar);
34
+ if (!exe) return Promise.resolve({ installed: false, version: null, probe: "not_found" });
35
+ return new Promise((resolve) => {
36
+ let out = "";
37
+ let done = false;
38
+ let timer: ReturnType<typeof setTimeout> | undefined;
39
+ const finish = (r: ProbeResult): void => { if (!done) { done = true; clearTimeout(timer); resolve(r); } };
40
+ let child: ReturnType<typeof spawn>;
41
+ try {
42
+ child = spawn(exe, ["--version"], { stdio: ["ignore", "pipe", "ignore"], env: { ...process.env, NO_COLOR: "1" } });
43
+ } catch {
44
+ return resolve({ installed: true, version: null, probe: "error" });
45
+ }
46
+ timer = setTimeout(() => {
47
+ try { child.kill("SIGKILL"); } catch { /* already gone */ }
48
+ finish({ installed: true, version: null, probe: "timeout" });
49
+ }, timeoutMs);
50
+ child.stdout?.on("data", (d: Buffer) => { if (out.length < 2048) out += d.toString("utf8"); });
51
+ child.on("error", () => finish({ installed: true, version: null, probe: "error" }));
52
+ child.on("close", () => {
53
+ const m = /\d+\.\d+(?:\.\d+)?(?:[-+.][0-9A-Za-z.]+)?/.exec(out);
54
+ finish({ installed: true, version: m ? m[0] : null, probe: "ok" });
55
+ });
56
+ });
57
+ }
58
+
59
+ function providersDir(): string | null {
60
+ const cands = [process.env.LOKI_PROVIDERS_DIR, join(import.meta.dir, "../../../../../providers"), join(import.meta.dir, "../../../providers")];
61
+ return cands.find((d): d is string => !!d && existsSync(join(d, "model_catalog.json"))) ?? null;
62
+ }
63
+
64
+ interface CatalogModel { id?: string; alias?: string; tier?: string }
65
+ interface CatalogProvider { models?: CatalogModel[]; latest_planning?: string; latest_development?: string; latest_fast?: string }
66
+
67
+ function loadCatalog(dir: string | null): { providers: Record<string, CatalogProvider>; updated: string | null } {
68
+ if (!dir) return { providers: {}, updated: null };
69
+ try {
70
+ const j = JSON.parse(readFileSync(join(dir, "model_catalog.json"), "utf8"));
71
+ return { providers: j.providers ?? {}, updated: typeof j.updated === "string" ? j.updated : null };
72
+ } catch { return { providers: {}, updated: null }; }
73
+ }
74
+
75
+ export async function listProviders(timeoutMs = DEFAULT_TIMEOUT_MS): Promise<unknown> {
76
+ const cat = loadCatalog(providersDir());
77
+ const names = [...ORDER, ...DEPRECATED];
78
+ const rows = await Promise.all(names.map(async (name) => {
79
+ const probe = await probeCli(name, timeoutMs);
80
+ const envNames = AUTH_ENV[name] ?? [];
81
+ const envSet = envNames.filter((n) => (process.env[n] ?? "") !== "");
82
+ const cp = cat.providers[name];
83
+ const tiers: Record<string, string | null> = cp
84
+ ? { planning: cp.latest_planning ?? null, development: cp.latest_development ?? null, fast: cp.latest_fast ?? null }
85
+ : {};
86
+ const models = (cp?.models ?? []).filter((m) => m.id).map((m) => ({ id: m.id as string, alias: m.alias ?? null, tier: m.tier ?? null }));
87
+ return {
88
+ id: name,
89
+ deprecated: (DEPRECATED as readonly string[]).includes(name),
90
+ installed: probe.installed,
91
+ version: probe.version,
92
+ probe: probe.probe,
93
+ auth_env_names: envNames,
94
+ auth_env_set: envSet,
95
+ auth_present: envSet.length > 0,
96
+ tiers,
97
+ models,
98
+ };
99
+ }));
100
+ return { providers: rows, catalog_updated: cat.updated, probe_timeout_ms: timeoutMs };
101
+ }
102
+
103
+ export function mount(ctx: RouteCtx): void {
104
+ const cached = ttlCache<unknown>();
105
+ ctx.app.get("/v1/providers", async (c) => {
106
+ if (!probeAllowed(ctx, c)) return c.json({ error: "loopback only without a token" }, 403);
107
+ const t = Number.parseInt(process.env.LOKI_PROVIDER_PROBE_TIMEOUT_MS ?? "", 10);
108
+ const ms = t > 0 ? t : DEFAULT_TIMEOUT_MS;
109
+ return c.json(await cached(String(ms), () => listProviders(ms)));
110
+ });
111
+ }
@@ -0,0 +1,39 @@
1
+ // CPE-07: POST /v1/runs (and the /v1/start alias) starts a run. Registered on the loopback-only router, so a non-loopback bind never has it.
2
+ import type { Context } from "hono";
3
+ import { isLoopbackHost } from "../auth.ts";
4
+ import { audit } from "../audit.ts";
5
+ import { planStart, registryRepos, spawnStart } from "../spawn.ts";
6
+ import type { RouteCtx } from "./index.ts";
7
+
8
+ /** A browser always sends Origin on a cross-site POST. Absent is a non-browser client (allowed, the peer check still applies); present must be a loopback http(s) origin. */
9
+ export function originOk(origin: string | undefined | null): boolean {
10
+ if (origin === undefined || origin === null) return true;
11
+ try {
12
+ const u = new URL(origin);
13
+ return (u.protocol === "http:" || u.protocol === "https:") && isLoopbackHost(u.host);
14
+ } catch { return false; }
15
+ }
16
+
17
+ export function mount(ctx: RouteCtx): void {
18
+ const { act, db, repoDir, local } = ctx;
19
+ const inflight = new Set<string>();
20
+ const handler = async (c: Context) => {
21
+ if (!local(c)) return c.json({ error: "loopback JSON requests only" }, 403);
22
+ if (!originOk(c.req.header("origin"))) return c.json({ error: "origin not allowed" }, 403);
23
+ const text = await c.req.text();
24
+ if (text.length > 20_000) return c.json({ error: "body too large" }, 413);
25
+ let body: unknown;
26
+ try { body = JSON.parse(text); } catch { return c.json({ error: "invalid JSON" }, 400); }
27
+ const target = typeof (body as { target?: unknown } | null)?.target === "string" ? (body as { target: string }).target : undefined;
28
+ const plan = planStart(body, [repoDir, ...registryRepos()], ctx.startBin);
29
+ if (!plan.ok) { audit(db, { kind: "run.start", target, result: "refused", detail: plan.error }); return c.json({ error: plan.error }, 400); }
30
+ if (inflight.has(plan.cwd)) { audit(db, { kind: "run.start", target, result: "conflict", detail: "start already in flight" }); return c.json({ error: "a run is already starting or running in this repo" }, 409); }
31
+ inflight.add(plan.cwd);
32
+ const r = await (ctx.spawnImpl ?? spawnStart)(plan.argv, plan.cwd, () => inflight.delete(plan.cwd), plan.env);
33
+ if ("error" in r) { inflight.delete(plan.cwd); audit(db, { kind: "run.start", target, result: "error", detail: r.error }); return c.json({ error: r.error }, 500); }
34
+ audit(db, { kind: "run.start", target, result: "started", detail: `pid ${r.pid}; ${plan.argv.slice(1).join(" ")}` });
35
+ return c.json({ ok: true, pid: r.pid, command: plan.argv.slice(1).join(" ") });
36
+ };
37
+ act.post("/v1/runs", handler);
38
+ act.post("/v1/start", handler);
39
+ }
@@ -0,0 +1,76 @@
1
+ // GET /v1/stats?since=<ISO>: counts, verified rate, cost (measured and partial kept apart) and the receipts count, folded from the runs projection and stored events.
2
+ // An unpriced run is never summed as zero: its known part is reported as partial, and a window with nothing priced reads null.
3
+ import { eq } from "drizzle-orm";
4
+ import type { Db } from "../../db/migrate.ts";
5
+ import { events, runs } from "../../db/schema.ts";
6
+ import { effectiveVerdict, pubkeysFromEnv } from "../integrity.ts";
7
+ import type { RouteCtx } from "./index.ts";
8
+
9
+ export interface Stats {
10
+ since: string | null;
11
+ runs_total: number;
12
+ runs_finished: number;
13
+ runs_running: number;
14
+ by_verdict: Record<string, number>;
15
+ blocked_waiting: number;
16
+ /** Plain VERIFIED (attested, signature-checked) over finished runs that carry a verdict; null when there are none. */
17
+ verified_rate: number | null;
18
+ /** Finished successes whose signature could not be checked (VERIFIED (signature not checked)); a rate of 0 with these is "not measured", not "failing". */
19
+ verified_unchecked: number;
20
+ /** At least one receipt verification key is configured (LOKI_CP_RECEIPT_PUBKEYS). */
21
+ keys_configured: boolean;
22
+ cost: {
23
+ /** Sum over runs whose cost is fully measured; null when no run in the window is. */
24
+ measured_usd: number | null;
25
+ measured_runs: number;
26
+ /** Sum of the known part of runs with an unpriced session (a lower bound); null when there are none. */
27
+ partial_usd: number | null;
28
+ partial_runs: number;
29
+ label: "measured" | "partial" | "not measured";
30
+ };
31
+ receipts: { total: number; signed: number };
32
+ }
33
+
34
+ export function computeStats(db: Db, since: string | null): Stats {
35
+ const all = db.select().from(runs).all().filter((r) => !since || (r.startedAt !== null && r.startedAt >= since));
36
+ const by: Record<string, number> = {};
37
+ let finished = 0, running = 0, blocked = 0, withVerdict = 0, verified = 0, unchecked = 0;
38
+ let mUsd = 0, mRuns = 0, pUsd = 0, pRuns = 0;
39
+ for (const r of all) {
40
+ if (r.endedAt) finished++; else running++;
41
+ // FC-08 effective verdict: a tampered log reads TAMPERED, an unattested or unchecked success is never counted as plain VERIFIED (L3, L7)
42
+ const verdict = r.verdict ? effectiveVerdict({ verdict: r.verdict, tampered: r.tampered === 1, attested: r.attested === 1, sig_checked: r.sigChecked === 1 }) : null;
43
+ if (verdict) {
44
+ by[verdict] = (by[verdict] ?? 0) + 1;
45
+ if (r.endedAt) { withVerdict++; if (verdict === "VERIFIED") verified++; else if (verdict === "VERIFIED (signature not checked)") unchecked++; }
46
+ }
47
+ if (r.verdict === "SPEC_CONFLICT") blocked++;
48
+ if (r.costUsd !== null) { mUsd += r.costUsd; mRuns++; }
49
+ else if (r.totalSessions > 0) { pUsd += r.partialUsd; pRuns++; }
50
+ }
51
+ const keys = new Set(all.map((r) => `${r.sourceId}/${r.runId}`));
52
+ let total = 0, signed = 0;
53
+ for (const e of db.select({ s: events.sourceId, r: events.runId, data: events.data }).from(events).where(eq(events.type, "receipt.sealed")).all()) {
54
+ if (!keys.has(`${e.s}/${e.r}`)) continue;
55
+ total++;
56
+ if ((e.data as { signed?: unknown } | null)?.signed === true) signed++;
57
+ }
58
+ const r6 = (n: number): number => Math.round(n * 1e6) / 1e6;
59
+ return {
60
+ since, runs_total: all.length, runs_finished: finished, runs_running: running, by_verdict: by, blocked_waiting: blocked,
61
+ verified_rate: withVerdict ? verified / withVerdict : null, verified_unchecked: unchecked, keys_configured: pubkeysFromEnv().configured === true,
62
+ cost: {
63
+ measured_usd: mRuns ? r6(mUsd) : null, measured_runs: mRuns, partial_usd: pRuns ? r6(pUsd) : null, partial_runs: pRuns,
64
+ label: pRuns ? "partial" : mRuns ? "measured" : "not measured",
65
+ },
66
+ receipts: { total, signed },
67
+ };
68
+ }
69
+
70
+ export function mount(ctx: RouteCtx): void {
71
+ ctx.app.get("/v1/stats", (c) => {
72
+ const since = c.req.query("since") ?? null;
73
+ if (since !== null && (!/^\d{4}-\d{2}-\d{2}/.test(since) || Number.isNaN(Date.parse(since)))) return c.json({ error: "since must be an ISO date or timestamp" }, 400);
74
+ return c.json(computeStats(ctx.db, since === null ? null : new Date(since).toISOString()));
75
+ });
76
+ }