loki-mode 10.7.1 → 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 (111) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/autonomy/loki +4 -1
  4. package/autonomy/run.sh +9 -5
  5. package/dashboard/__init__.py +1 -1
  6. package/dashboard/server.py +6 -8
  7. package/docs/INSTALLATION.md +1 -1
  8. package/docs/v10/CP-ENTERPRISE-UI.md +263 -2
  9. package/docs/v10/FAILURE-CLASSES.md +21 -0
  10. package/docs/v10/METRICS.md +6 -0
  11. package/loki-ts/data/model-pricing.json +6 -6
  12. package/loki-ts/dist/loki.js +800 -807
  13. package/loki-ts/package.json +1 -1
  14. package/mcp/__init__.py +1 -1
  15. package/package.json +2 -2
  16. package/packages/control-plane/dist/server.js +17090 -5672
  17. package/packages/control-plane/drizzle/0004_cpe03_local_repos_actions.sql +17 -0
  18. package/packages/control-plane/drizzle/meta/0004_snapshot.json +542 -0
  19. package/packages/control-plane/drizzle/meta/_journal.json +9 -2
  20. package/packages/control-plane/package.json +4 -1
  21. package/packages/control-plane/src/db/schema.ts +19 -0
  22. package/packages/control-plane/src/server/app.ts +11 -22
  23. package/packages/control-plane/src/server/audit.ts +15 -0
  24. package/packages/control-plane/src/server/auth.ts +7 -1
  25. package/packages/control-plane/src/server/legacy/routes-metrics.ts +18 -0
  26. package/packages/control-plane/src/server/legacy/routes.ts +237 -0
  27. package/packages/control-plane/src/server/legacy/shim.ts +195 -0
  28. package/packages/control-plane/src/server/repos.ts +27 -0
  29. package/packages/control-plane/src/server/routes/artifacts.ts +104 -0
  30. package/packages/control-plane/src/server/routes/audit.ts +17 -0
  31. package/packages/control-plane/src/server/routes/config.ts +319 -0
  32. package/packages/control-plane/src/server/routes/control.ts +75 -0
  33. package/packages/control-plane/src/server/routes/cost.ts +70 -0
  34. package/packages/control-plane/src/server/routes/cost_ledger.ts +210 -0
  35. package/packages/control-plane/src/server/routes/fleet.ts +101 -0
  36. package/packages/control-plane/src/server/routes/index.ts +68 -0
  37. package/packages/control-plane/src/server/routes/integrations.ts +123 -0
  38. package/packages/control-plane/src/server/routes/merge_risk.ts +209 -0
  39. package/packages/control-plane/src/server/routes/metrics.ts +93 -0
  40. package/packages/control-plane/src/server/routes/notify.ts +48 -0
  41. package/packages/control-plane/src/server/routes/providers.ts +111 -0
  42. package/packages/control-plane/src/server/routes/start.ts +39 -0
  43. package/packages/control-plane/src/server/routes/stats.ts +76 -0
  44. package/packages/control-plane/src/server/routes/stream.ts +85 -0
  45. package/packages/control-plane/src/server/routes/verify.ts +66 -0
  46. package/packages/control-plane/src/server/spawn.ts +79 -0
  47. package/packages/control-plane/src/shipper/discover.ts +19 -1
  48. package/packages/control-plane/ui/dist/assets/index-BQbFv65M.css +1 -0
  49. package/packages/control-plane/ui/dist/assets/index-Dbhu244h.js +155 -0
  50. package/packages/control-plane/ui/dist/index.html +2 -2
  51. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  52. package/web-app/dist/assets/{AdminPage-BJSU5hY1.js → AdminPage-DDDvUz0N.js} +1 -1
  53. package/web-app/dist/assets/{Avatar-CSOSpw9c.js → Avatar-Dcdz46i6.js} +1 -1
  54. package/web-app/dist/assets/{Badge-COr7vj0D.js → Badge-BB24cCpK.js} +1 -1
  55. package/web-app/dist/assets/{Button-DGoWFERp.js → Button-D0-DmPZL.js} +1 -1
  56. package/web-app/dist/assets/{CockpitPage-CBpWkNXY.js → CockpitPage-CdjpU2JW.js} +1 -1
  57. package/web-app/dist/assets/{ComparePage-BwT8vqrL.js → ComparePage-DT5U0y0d.js} +1 -1
  58. package/web-app/dist/assets/{ErrorBoundary-D7SicDDA.js → ErrorBoundary-lEHcl0EO.js} +1 -1
  59. package/web-app/dist/assets/{EvidenceReceiptPanel-bx-5liG2.js → EvidenceReceiptPanel-GpnWvdCr.js} +1 -1
  60. package/web-app/dist/assets/{GitHubIssuesPanel-BAeeWLqY.js → GitHubIssuesPanel-C7VOh1B7.js} +1 -1
  61. package/web-app/dist/assets/{GitHubPRsPanel-CssD8lGI.js → GitHubPRsPanel-G_kOuINv.js} +1 -1
  62. package/web-app/dist/assets/{HomePage-SL5PT3m5.js → HomePage-PqRisOny.js} +2 -2
  63. package/web-app/dist/assets/{LoginPage-C8hFOZOM.js → LoginPage-DU7pFAkf.js} +1 -1
  64. package/web-app/dist/assets/{MagicPage-xHbHkrPf.js → MagicPage-C5JWIWMk.js} +1 -1
  65. package/web-app/dist/assets/{MetricsPage-B_2CKFcC.js → MetricsPage-CbIGS_qo.js} +1 -1
  66. package/web-app/dist/assets/{NotFoundPage-CXwXMq35.js → NotFoundPage-vU1pvdSt.js} +1 -1
  67. package/web-app/dist/assets/{ProjectPage-AaisMQI-.js → ProjectPage-SsixIPPv.js} +3 -3
  68. package/web-app/dist/assets/{ProjectsPage-Cej5ResX.js → ProjectsPage-BfQKSR9k.js} +1 -1
  69. package/web-app/dist/assets/{SettingsPage-DKv4mn9H.js → SettingsPage-B8YfXAom.js} +1 -1
  70. package/web-app/dist/assets/{ShowcasePage-Dkiyzne4.js → ShowcasePage-Ba407f2-.js} +1 -1
  71. package/web-app/dist/assets/{SystemSettingsPage-DaBLQ5dU.js → SystemSettingsPage-DzNQhhBS.js} +1 -1
  72. package/web-app/dist/assets/{TeamsPage-B2yUr31k.js → TeamsPage-tqUnpZ15.js} +1 -1
  73. package/web-app/dist/assets/{TemplatesPage-CfYiANhU.js → TemplatesPage-ClB5EpI3.js} +1 -1
  74. package/web-app/dist/assets/{TerminalOutput-CMXoexLM.js → TerminalOutput-CBvpYfEu.js} +1 -1
  75. package/web-app/dist/assets/{activity-B0dzoZtY.js → activity-C1s0L_X9.js} +1 -1
  76. package/web-app/dist/assets/{bell-DFOTaSoo.js → bell-DMEiXr73.js} +1 -1
  77. package/web-app/dist/assets/{bot-Dz-WSQyP.js → bot-BF_xIOxu.js} +1 -1
  78. package/web-app/dist/assets/{check-L1QIRueJ.js → check-Dv1Ynvrs.js} +1 -1
  79. package/web-app/dist/assets/{chevron-left-1TrnIEZS.js → chevron-left-Ddhsod7S.js} +1 -1
  80. package/web-app/dist/assets/{circle-alert-8Bf6xEDx.js → circle-alert-OATHUkBZ.js} +1 -1
  81. package/web-app/dist/assets/{clock-DB8NE6P5.js → clock-C1Ltva07.js} +1 -1
  82. package/web-app/dist/assets/{cloud-DUSL3wZS.js → cloud-B-S4oOy9.js} +1 -1
  83. package/web-app/dist/assets/{code-xml-DbjryWNl.js → code-xml-D3Femn_F.js} +1 -1
  84. package/web-app/dist/assets/{database-DrezrMSs.js → database-DBSeFgO3.js} +1 -1
  85. package/web-app/dist/assets/{dollar-sign-CGbaLMzR.js → dollar-sign-sgDgNuGE.js} +1 -1
  86. package/web-app/dist/assets/{file-code-corner-Dfypa-r4.js → file-code-corner-C-MbdZEc.js} +1 -1
  87. package/web-app/dist/assets/{file-plus-CzV5L2Gd.js → file-plus-CCB6yqXv.js} +1 -1
  88. package/web-app/dist/assets/{globe-BoYjTwPQ.js → globe-DlbrNAj1.js} +1 -1
  89. package/web-app/dist/assets/{hammer-WO-HuiyJ.js → hammer-B-DWm695.js} +1 -1
  90. package/web-app/dist/assets/{index-CjIOUjnd.js → index-Dvny-1SU.js} +3 -3
  91. package/web-app/dist/assets/{layers-Bn6jtKNk.js → layers-99qsKpOT.js} +1 -1
  92. package/web-app/dist/assets/{loader-circle-gERWRnmW.js → loader-circle-ZvZyZl3J.js} +1 -1
  93. package/web-app/dist/assets/{lock-BIiMLJq8.js → lock-DosGjcxP.js} +1 -1
  94. package/web-app/dist/assets/{package-dLvDSVys.js → package-C-PumesN.js} +1 -1
  95. package/web-app/dist/assets/{plus-BHM3ZLhZ.js → plus-CTYYskTB.js} +1 -1
  96. package/web-app/dist/assets/{refresh-cw-BAp_alv6.js → refresh-cw-qW2ztTFS.js} +1 -1
  97. package/web-app/dist/assets/{rotate-ccw-CBhwq_MG.js → rotate-ccw-ZIKrf-vO.js} +1 -1
  98. package/web-app/dist/assets/{scroll-text-DoS-6dfU.js → scroll-text-BamlXjAT.js} +1 -1
  99. package/web-app/dist/assets/{server-5JgdDWQO.js → server-BljXb79I.js} +1 -1
  100. package/web-app/dist/assets/{shield-alert-DjmlLT34.js → shield-alert-Dr6eT9c_.js} +1 -1
  101. package/web-app/dist/assets/{thumbs-up-BtiEXl3p.js → thumbs-up-v3oBXZgU.js} +1 -1
  102. package/web-app/dist/assets/{trash-2-ClsWU_Vg.js → trash-2-BOuIrCpi.js} +1 -1
  103. package/web-app/dist/assets/{trending-down-DPS4M7_X.js → trending-down-Dp7jMCgH.js} +1 -1
  104. package/web-app/dist/assets/{trending-up-D6OJ38-V.js → trending-up-CDiur-nP.js} +1 -1
  105. package/web-app/dist/assets/{upload-DOCzLUpF.js → upload-DxIHXhNE.js} +1 -1
  106. package/web-app/dist/assets/{usePolling-CljQTWj_.js → usePolling-D2Uu-YZb.js} +1 -1
  107. package/web-app/dist/assets/{user-C0G82Weu.js → user-BVWCUAfF.js} +1 -1
  108. package/web-app/dist/index.html +1 -1
  109. package/packages/control-plane/src/server/actions.ts +0 -60
  110. package/packages/control-plane/ui/dist/assets/index-DHDI_dHM.css +0 -1
  111. package/packages/control-plane/ui/dist/assets/index-DaQr0MPS.js +0 -129
@@ -0,0 +1,15 @@
1
+ // Audit helper: one row per UI-initiated action. Never throws (an audit failure must not break the action) and never stores secrets.
2
+ import type { Db } from "../db/migrate.ts";
3
+ import { actions } from "../db/schema.ts";
4
+
5
+ export type AuditEntry = { actor?: string; kind: string; target?: string; result: string; detail?: string };
6
+
7
+ const clip = (s: string | undefined, n: number): string | null => (s === undefined ? null : s.slice(0, n));
8
+
9
+ /** Records an action. Returns true when the row was written. */
10
+ export function audit(db: Db, e: AuditEntry): boolean {
11
+ try {
12
+ db.insert(actions).values({ ts: new Date().toISOString(), actor: e.actor ?? "local", kind: e.kind, target: clip(e.target, 500), result: e.result, detail: clip(e.detail, 2000) }).run();
13
+ return true;
14
+ } catch { return false; }
15
+ }
@@ -1,6 +1,6 @@
1
1
  // Control Plane auth: bearer token on /v1, Host allowlist on loopback (DNS rebinding), bind refusal without a token.
2
2
  import { createHash, timingSafeEqual } from "node:crypto";
3
- import type { MiddlewareHandler } from "hono";
3
+ import type { Context, MiddlewareHandler } from "hono";
4
4
 
5
5
  const LOOPBACK_BINDS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
6
6
 
@@ -9,6 +9,12 @@ export function isLoopbackHost(host: string | undefined | null): boolean {
9
9
  return !!host && /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(host);
10
10
  }
11
11
 
12
+ /** True when the real socket peer (not the spoofable Host header) is loopback. An unknown peer fails closed. */
13
+ export function peerIsLoopback(c: Context): boolean {
14
+ const ip = (c.env as { requestIP?: (r: Request) => { address?: string } | null } | undefined)?.requestIP?.(c.req.raw)?.address;
15
+ return typeof ip === "string" && /^(127\.\d+\.\d+\.\d+|::1|::ffff:127\.\d+\.\d+\.\d+)$/.test(ip);
16
+ }
17
+
12
18
  /** Constant-time check of an Authorization header against the token. Hashing first makes the compare length-safe. */
13
19
  export function tokenMatches(header: string | undefined | null, token: string): boolean {
14
20
  const m = /^Bearer (.+)$/i.exec(header ?? "");
@@ -0,0 +1,18 @@
1
+ // CPE24-P1: legacy (port 57374) mappings for metrics, cost and fleet spend. Each reads the same source as its /v1 route (routes/metrics.ts,
2
+ // routes/cost_ledger.ts, routes/fleet.ts) and keeps the legacy response shape. The shim's guard (legacyGuard) has already run.
3
+ import type { Context } from "hono";
4
+ import { costSnapshot, costTimeline, lokiDirOf } from "../routes/cost_ledger.ts";
5
+ import { fleetRuns, fleetSummary, parseInactive } from "../routes/fleet.ts";
6
+ import { buildMetricsText } from "../routes/metrics.ts";
7
+
8
+ export function metricsMapped(repoDir: string): Record<string, (c: Context) => Response> {
9
+ const inactive = (c: Context) => parseInactive(c.req.query("include_inactive"));
10
+ return {
11
+ "GET /api/cost": (c) => c.json(costSnapshot(lokiDirOf(repoDir))),
12
+ "GET /api/cost/timeline": (c) => c.json(costTimeline(lokiDirOf(repoDir))),
13
+ "GET /metrics": (c) => c.text(buildMetricsText(repoDir), 200, { "content-type": "text/plain; charset=utf-8" }),
14
+ // Legacy rows carry the project path; the guarded shim serves only authenticated or loopback callers, like legacy.
15
+ "GET /api/fleet/runs": (c) => c.json(fleetRuns(inactive(c))),
16
+ "GET /api/fleet/summary": (c) => c.json(fleetSummary(inactive(c))),
17
+ };
18
+ }
@@ -0,0 +1,237 @@
1
+ // GENERATED from docs/v10/CP-ENTERPRISE-UI.md section 5.2 (one row per legacy dashboard route that the shim answers).
2
+ // test/server/legacy-shim.test.ts asserts this table and the doc table agree. Regenerate both together, never one alone.
3
+ export type LegacyAction = "map" | "308" | "410" | "501";
4
+ export interface LegacyRoute { method: string; path: string; action: LegacyAction; open: boolean; migrate: string }
5
+
6
+ export const LEGACY_ROUTES: readonly LegacyRoute[] = [
7
+ { method: "GET", path: "/openapi.json", action: "410", open: true, migrate: "" },
8
+ { method: "GET", path: "/docs", action: "410", open: true, migrate: "" },
9
+ { method: "GET", path: "/docs/oauth2-redirect", action: "410", open: true, migrate: "" },
10
+ { method: "GET", path: "/redoc", action: "410", open: true, migrate: "" },
11
+ { method: "POST", path: "/api/v2/tenants", action: "501", open: false, migrate: "" },
12
+ { method: "GET", path: "/api/v2/tenants", action: "501", open: false, migrate: "" },
13
+ { method: "GET", path: "/api/v2/tenants/{tenant_id}", action: "501", open: false, migrate: "" },
14
+ { method: "PUT", path: "/api/v2/tenants/{tenant_id}", action: "410", open: false, migrate: "" },
15
+ { method: "DELETE", path: "/api/v2/tenants/{tenant_id}", action: "501", open: false, migrate: "" },
16
+ { method: "GET", path: "/api/v2/tenants/{tenant_id}/projects", action: "410", open: false, migrate: "" },
17
+ { method: "POST", path: "/api/v2/runs", action: "501", open: false, migrate: "" },
18
+ { method: "GET", path: "/api/v2/runs", action: "map", open: false, migrate: "/v1/runs" },
19
+ { method: "GET", path: "/api/v2/runs/{run_id}", action: "map", open: false, migrate: "/v1/runs/:id" },
20
+ { method: "POST", path: "/api/v2/runs/{run_id}/cancel", action: "501", open: false, migrate: "" },
21
+ { method: "POST", path: "/api/v2/runs/{run_id}/replay", action: "501", open: false, migrate: "" },
22
+ { method: "GET", path: "/api/v2/runs/{run_id}/timeline", action: "map", open: false, migrate: "/v1/runs/:id" },
23
+ { method: "POST", path: "/api/v2/api-keys", action: "501", open: false, migrate: "" },
24
+ { method: "GET", path: "/api/v2/api-keys", action: "501", open: false, migrate: "" },
25
+ { method: "GET", path: "/api/v2/api-keys/{identifier}", action: "410", open: false, migrate: "" },
26
+ { method: "PUT", path: "/api/v2/api-keys/{identifier}", action: "410", open: false, migrate: "" },
27
+ { method: "DELETE", path: "/api/v2/api-keys/{identifier}", action: "501", open: false, migrate: "" },
28
+ { method: "POST", path: "/api/v2/api-keys/{identifier}/rotate", action: "501", open: false, migrate: "" },
29
+ { method: "GET", path: "/api/v2/policies", action: "410", open: false, migrate: "" },
30
+ { method: "PUT", path: "/api/v2/policies", action: "410", open: false, migrate: "" },
31
+ { method: "POST", path: "/api/v2/policies/evaluate", action: "410", open: false, migrate: "" },
32
+ { method: "GET", path: "/api/v2/audit", action: "map", open: false, migrate: "/v1/audit" },
33
+ { method: "GET", path: "/api/v2/audit/verify", action: "501", open: false, migrate: "" },
34
+ { method: "GET", path: "/api/v2/audit/export", action: "410", open: false, migrate: "" },
35
+ { method: "GET", path: "/api/operator/runs/{run_id}", action: "410", open: false, migrate: "" },
36
+ { method: "GET", path: "/api/operator/tests", action: "410", open: false, migrate: "" },
37
+ { method: "GET", path: "/api/operator/receipts", action: "410", open: false, migrate: "" },
38
+ { method: "GET", path: "/api/operator/releases", action: "410", open: false, migrate: "" },
39
+ { method: "GET", path: "/api/operator/phases", action: "410", open: false, migrate: "" },
40
+ { method: "GET", path: "/api/operator/workspaces/runs", action: "410", open: false, migrate: "" },
41
+ { method: "GET", path: "/api/operator/workspaces/runs/{ws}/{run_id}", action: "410", open: false, migrate: "" },
42
+ { method: "GET", path: "/api/onboarding/state", action: "410", open: false, migrate: "" },
43
+ { method: "POST", path: "/api/onboarding/provider", action: "410", open: false, migrate: "" },
44
+ { method: "POST", path: "/api/onboarding/github", action: "410", open: false, migrate: "" },
45
+ { method: "GET", path: "/api/onboarding/repos", action: "410", open: false, migrate: "" },
46
+ { method: "POST", path: "/api/onboarding/repo", action: "410", open: false, migrate: "" },
47
+ { method: "GET", path: "/api/backlog/issues", action: "410", open: false, migrate: "" },
48
+ { method: "POST", path: "/api/backlog/run", action: "410", open: false, migrate: "" },
49
+ { method: "GET", path: "/api/backlog/status", action: "410", open: false, migrate: "" },
50
+ { method: "GET", path: "/api/v1/runs", action: "410", open: false, migrate: "" },
51
+ { method: "GET", path: "/api/v1/runs/{run_id}", action: "410", open: false, migrate: "" },
52
+ { method: "POST", path: "/api/v1/runs", action: "410", open: false, migrate: "" },
53
+ { method: "POST", path: "/api/v1/runs/{run_id}/stop", action: "410", open: false, migrate: "" },
54
+ { method: "GET", path: "/start", action: "308", open: false, migrate: "/" },
55
+ { method: "MOUNT", path: "/lab", action: "308", open: true, migrate: "/" },
56
+ { method: "GET", path: "/api/providers/models", action: "410", open: true, migrate: "" },
57
+ { method: "GET", path: "/.well-known/agent.json", action: "map", open: true, migrate: "/v1/runs" },
58
+ { method: "GET", path: "/api/status", action: "map", open: false, migrate: "/v1/runs" },
59
+ { method: "GET", path: "/api/projects", action: "501", open: false, migrate: "" },
60
+ { method: "POST", path: "/api/projects", action: "501", open: false, migrate: "" },
61
+ { method: "GET", path: "/api/projects/{project_id}", action: "501", open: false, migrate: "" },
62
+ { method: "PUT", path: "/api/projects/{project_id}", action: "501", open: false, migrate: "" },
63
+ { method: "DELETE", path: "/api/projects/{project_id}", action: "501", open: false, migrate: "" },
64
+ { method: "GET", path: "/api/tasks", action: "501", open: false, migrate: "" },
65
+ { method: "POST", path: "/api/tasks", action: "501", open: false, migrate: "" },
66
+ { method: "GET", path: "/api/tasks/{task_id}", action: "501", open: false, migrate: "" },
67
+ { method: "PUT", path: "/api/tasks/{task_id}", action: "501", open: false, migrate: "" },
68
+ { method: "DELETE", path: "/api/tasks/{task_id}", action: "501", open: false, migrate: "" },
69
+ { method: "POST", path: "/api/tasks/{task_id}/move", action: "501", open: false, migrate: "" },
70
+ { method: "WS", path: "/ws", action: "501", open: true, migrate: "" },
71
+ { method: "GET", path: "/api/registry/projects", action: "410", open: false, migrate: "" },
72
+ { method: "POST", path: "/api/registry/projects", action: "501", open: false, migrate: "" },
73
+ { method: "GET", path: "/api/registry/projects/{identifier}", action: "410", open: false, migrate: "" },
74
+ { method: "DELETE", path: "/api/registry/projects/{identifier}", action: "410", open: false, migrate: "" },
75
+ { method: "GET", path: "/api/registry/projects/{identifier}/health", action: "410", open: false, migrate: "" },
76
+ { method: "POST", path: "/api/registry/projects/{identifier}/access", action: "410", open: false, migrate: "" },
77
+ { method: "GET", path: "/api/registry/discover", action: "410", open: false, migrate: "" },
78
+ { method: "POST", path: "/api/registry/sync", action: "501", open: false, migrate: "" },
79
+ { method: "GET", path: "/api/registry/tasks", action: "410", open: false, migrate: "" },
80
+ { method: "GET", path: "/api/registry/learnings", action: "410", open: false, migrate: "" },
81
+ { method: "GET", path: "/api/fleet/runs", action: "map", open: false, migrate: "/v1/fleet/runs" },
82
+ { method: "GET", path: "/api/fleet/summary", action: "map", open: false, migrate: "/v1/fleet/summary" },
83
+ { method: "GET", path: "/api/fleet/runs/{identifier}", action: "410", open: false, migrate: "" },
84
+ { method: "POST", path: "/api/focus", action: "501", open: false, migrate: "" },
85
+ { method: "GET", path: "/api/focus", action: "410", open: false, migrate: "" },
86
+ { method: "DELETE", path: "/api/focus", action: "410", open: false, migrate: "" },
87
+ { method: "GET", path: "/api/session/model", action: "410", open: false, migrate: "" },
88
+ { method: "POST", path: "/api/session/model", action: "501", open: false, migrate: "" },
89
+ { method: "GET", path: "/api/running-projects", action: "410", open: false, migrate: "" },
90
+ { method: "POST", path: "/api/control/start", action: "501", open: false, migrate: "" },
91
+ { method: "GET", path: "/api/control/builds/{execution_id}", action: "410", open: false, migrate: "" },
92
+ { method: "GET", path: "/api/control/builds/{execution_id}/proof", action: "410", open: false, migrate: "" },
93
+ { method: "POST", path: "/api/control/builds/{execution_id}/stop", action: "410", open: false, migrate: "" },
94
+ { method: "POST", path: "/api/running-projects/stop", action: "410", open: false, migrate: "" },
95
+ { method: "POST", path: "/api/fleet/runs/{identifier}/retry", action: "410", open: false, migrate: "" },
96
+ { method: "POST", path: "/api/fleet/runs/{identifier}/cancel", action: "410", open: false, migrate: "" },
97
+ { method: "GET", path: "/api/enterprise/status", action: "410", open: true, migrate: "" },
98
+ { method: "GET", path: "/api/auth/info", action: "410", open: true, migrate: "" },
99
+ { method: "POST", path: "/api/enterprise/tokens", action: "410", open: false, migrate: "" },
100
+ { method: "GET", path: "/api/enterprise/tokens", action: "410", open: false, migrate: "" },
101
+ { method: "DELETE", path: "/api/enterprise/tokens/{identifier}", action: "410", open: false, migrate: "" },
102
+ { method: "GET", path: "/api/enterprise/audit", action: "410", open: false, migrate: "" },
103
+ { method: "GET", path: "/api/enterprise/audit/summary", action: "410", open: false, migrate: "" },
104
+ { method: "GET", path: "/api/compliance", action: "410", open: false, migrate: "" },
105
+ { method: "GET", path: "/api/memory/summary", action: "410", open: false, migrate: "" },
106
+ { method: "GET", path: "/api/memory/episodes", action: "501", open: false, migrate: "" },
107
+ { method: "GET", path: "/api/memory/episodes/{episode_id}", action: "501", open: false, migrate: "" },
108
+ { method: "GET", path: "/api/memory/patterns", action: "501", open: false, migrate: "" },
109
+ { method: "GET", path: "/api/memory/patterns/{pattern_id}", action: "501", open: false, migrate: "" },
110
+ { method: "GET", path: "/api/memory/skills", action: "501", open: false, migrate: "" },
111
+ { method: "GET", path: "/api/memory/skills/{skill_id}", action: "410", open: false, migrate: "" },
112
+ { method: "GET", path: "/api/memory/economics", action: "501", open: false, migrate: "" },
113
+ { method: "POST", path: "/api/memory/consolidate", action: "501", open: false, migrate: "" },
114
+ { method: "POST", path: "/api/memory/retrieve", action: "501", open: false, migrate: "" },
115
+ { method: "GET", path: "/api/memory/index", action: "410", open: false, migrate: "" },
116
+ { method: "GET", path: "/api/memory/timeline", action: "410", open: false, migrate: "" },
117
+ { method: "GET", path: "/api/memory/files", action: "410", open: false, migrate: "" },
118
+ { method: "GET", path: "/api/memory/file", action: "410", open: false, migrate: "" },
119
+ { method: "GET", path: "/api/memory/search", action: "410", open: false, migrate: "" },
120
+ { method: "GET", path: "/api/memory/stats", action: "410", open: false, migrate: "" },
121
+ { method: "GET", path: "/api/learning/metrics", action: "501", open: false, migrate: "" },
122
+ { method: "GET", path: "/api/learning/trends", action: "410", open: false, migrate: "" },
123
+ { method: "GET", path: "/api/learning/signals", action: "410", open: false, migrate: "" },
124
+ { method: "GET", path: "/api/learning/aggregation", action: "410", open: false, migrate: "" },
125
+ { method: "POST", path: "/api/learning/aggregate", action: "501", open: false, migrate: "" },
126
+ { method: "GET", path: "/api/learning/preferences", action: "410", open: false, migrate: "" },
127
+ { method: "GET", path: "/api/learning/errors", action: "410", open: false, migrate: "" },
128
+ { method: "GET", path: "/api/learning/success", action: "410", open: false, migrate: "" },
129
+ { method: "GET", path: "/api/learning/tools", action: "410", open: false, migrate: "" },
130
+ { method: "POST", path: "/api/control/pause", action: "501", open: false, migrate: "" },
131
+ { method: "POST", path: "/api/control/resume", action: "501", open: false, migrate: "" },
132
+ { method: "POST", path: "/api/control/stop", action: "501", open: false, migrate: "" },
133
+ { method: "GET", path: "/api/cost", action: "map", open: false, migrate: "/v1/cost/snapshot" },
134
+ { method: "GET", path: "/api/budget", action: "501", open: false, migrate: "" },
135
+ { method: "GET", path: "/api/cost/timeline", action: "map", open: false, migrate: "/v1/cost/timeline" },
136
+ { method: "GET", path: "/api/trust/trajectory", action: "410", open: false, migrate: "" },
137
+ { method: "GET", path: "/api/gate-policy", action: "410", open: false, migrate: "" },
138
+ { method: "GET", path: "/api/pricing", action: "410", open: false, migrate: "" },
139
+ { method: "GET", path: "/api/council/state", action: "410", open: false, migrate: "" },
140
+ { method: "GET", path: "/api/council/verdicts", action: "410", open: false, migrate: "" },
141
+ { method: "GET", path: "/api/council/convergence", action: "410", open: false, migrate: "" },
142
+ { method: "GET", path: "/api/council/report", action: "410", open: false, migrate: "" },
143
+ { method: "POST", path: "/api/council/force-review", action: "501", open: false, migrate: "" },
144
+ { method: "GET", path: "/api/council/transcripts", action: "501", open: false, migrate: "" },
145
+ { method: "GET", path: "/api/council/transcripts/{iteration_id}", action: "410", open: false, migrate: "" },
146
+ { method: "GET", path: "/api/context", action: "501", open: false, migrate: "" },
147
+ { method: "GET", path: "/api/notifications", action: "410", open: false, migrate: "" },
148
+ { method: "GET", path: "/api/notifications/triggers", action: "410", open: false, migrate: "" },
149
+ { method: "PUT", path: "/api/notifications/triggers", action: "501", open: false, migrate: "" },
150
+ { method: "POST", path: "/api/notifications/{notification_id}/acknowledge", action: "410", open: false, migrate: "" },
151
+ { method: "POST", path: "/api/notifications/{notification_id}/unacknowledge", action: "410", open: false, migrate: "" },
152
+ { method: "GET", path: "/api/checkpoints", action: "501", open: false, migrate: "" },
153
+ { method: "GET", path: "/api/checkpoints/{checkpoint_id}", action: "410", open: false, migrate: "" },
154
+ { method: "POST", path: "/api/checkpoints", action: "501", open: false, migrate: "" },
155
+ { method: "POST", path: "/api/checkpoints/{checkpoint_id}/rollback", action: "501", open: false, migrate: "" },
156
+ { method: "GET", path: "/api/agents", action: "410", open: false, migrate: "" },
157
+ { method: "POST", path: "/api/agents/{agent_id}/kill", action: "501", open: false, migrate: "" },
158
+ { method: "POST", path: "/api/agents/{agent_id}/pause", action: "501", open: false, migrate: "" },
159
+ { method: "POST", path: "/api/agents/{agent_id}/resume", action: "501", open: false, migrate: "" },
160
+ { method: "GET", path: "/api/logs", action: "410", open: false, migrate: "" },
161
+ { method: "POST", path: "/api/collab/join", action: "410", open: false, migrate: "" },
162
+ { method: "POST", path: "/api/collab/leave", action: "410", open: false, migrate: "" },
163
+ { method: "GET", path: "/api/collab/users", action: "410", open: false, migrate: "" },
164
+ { method: "GET", path: "/api/collab/users/{user_id}", action: "410", open: false, migrate: "" },
165
+ { method: "POST", path: "/api/collab/users/{user_id}/heartbeat", action: "410", open: false, migrate: "" },
166
+ { method: "POST", path: "/api/collab/users/{user_id}/cursor", action: "410", open: false, migrate: "" },
167
+ { method: "POST", path: "/api/collab/users/{user_id}/status", action: "410", open: false, migrate: "" },
168
+ { method: "GET", path: "/api/collab/presence", action: "410", open: false, migrate: "" },
169
+ { method: "GET", path: "/api/collab/file/{file_path:path}", action: "410", open: false, migrate: "" },
170
+ { method: "GET", path: "/api/collab/state", action: "410", open: false, migrate: "" },
171
+ { method: "GET", path: "/api/collab/state/value", action: "410", open: false, migrate: "" },
172
+ { method: "POST", path: "/api/collab/operation", action: "410", open: false, migrate: "" },
173
+ { method: "POST", path: "/api/collab/sync", action: "410", open: false, migrate: "" },
174
+ { method: "GET", path: "/api/collab/history", action: "410", open: false, migrate: "" },
175
+ { method: "WS", path: "/ws/collab", action: "410", open: true, migrate: "" },
176
+ { method: "GET", path: "/api/collab/status", action: "410", open: false, migrate: "" },
177
+ { method: "GET", path: "/api/secrets/status", action: "410", open: false, migrate: "" },
178
+ { method: "GET", path: "/api/github/status", action: "410", open: false, migrate: "" },
179
+ { method: "GET", path: "/api/github/tasks", action: "410", open: false, migrate: "" },
180
+ { method: "GET", path: "/api/github/sync-log", action: "410", open: false, migrate: "" },
181
+ { method: "GET", path: "/api/health/processes", action: "410", open: false, migrate: "" },
182
+ { method: "GET", path: "/metrics", action: "map", open: false, migrate: "/v1/metrics" },
183
+ { method: "GET", path: "/api/checklist", action: "410", open: false, migrate: "" },
184
+ { method: "GET", path: "/api/usage", action: "410", open: false, migrate: "" },
185
+ { method: "GET", path: "/api/checklist/summary", action: "410", open: false, migrate: "" },
186
+ { method: "GET", path: "/api/prd-observations", action: "410", open: false, migrate: "" },
187
+ { method: "GET", path: "/api/checklist/waivers", action: "410", open: false, migrate: "" },
188
+ { method: "POST", path: "/api/checklist/waivers", action: "501", open: false, migrate: "" },
189
+ { method: "DELETE", path: "/api/checklist/waivers/{item_id}", action: "501", open: false, migrate: "" },
190
+ { method: "GET", path: "/api/council/gate", action: "501", open: false, migrate: "" },
191
+ { method: "GET", path: "/api/app-runner/status", action: "410", open: false, migrate: "" },
192
+ { method: "GET", path: "/api/app-runner/logs", action: "410", open: false, migrate: "" },
193
+ { method: "GET", path: "/api/app-runner/errors", action: "410", open: false, migrate: "" },
194
+ { method: "POST", path: "/api/control/app-restart", action: "501", open: false, migrate: "" },
195
+ { method: "POST", path: "/api/control/app-stop", action: "501", open: false, migrate: "" },
196
+ { method: "GET", path: "/api/playwright/results", action: "410", open: false, migrate: "" },
197
+ { method: "GET", path: "/api/playwright/screenshot", action: "410", open: false, migrate: "" },
198
+ { method: "GET", path: "/api/failures", action: "410", open: false, migrate: "" },
199
+ { method: "GET", path: "/api/prompt-versions", action: "410", open: false, migrate: "" },
200
+ { method: "POST", path: "/api/prompt-optimize", action: "501", open: false, migrate: "" },
201
+ { method: "GET", path: "/api/activity", action: "501", open: false, migrate: "" },
202
+ { method: "GET", path: "/api/session-diff", action: "410", open: false, migrate: "" },
203
+ { method: "POST", path: "/api/activity", action: "410", open: false, migrate: "" },
204
+ { method: "GET", path: "/cost", action: "308", open: false, migrate: "/" },
205
+ { method: "GET", path: "/trust", action: "308", open: false, migrate: "/" },
206
+ { method: "GET", path: "/", action: "308", open: false, migrate: "/" },
207
+ { method: "GET", path: "/api/quality-score", action: "410", open: false, migrate: "" },
208
+ { method: "GET", path: "/api/quality-score/history", action: "410", open: false, migrate: "" },
209
+ { method: "POST", path: "/api/quality-scan", action: "501", open: false, migrate: "" },
210
+ { method: "GET", path: "/api/quality-report", action: "501", open: false, migrate: "" },
211
+ { method: "GET", path: "/api/migration/list", action: "501", open: false, migrate: "" },
212
+ { method: "POST", path: "/api/migration/start", action: "410", open: false, migrate: "" },
213
+ { method: "GET", path: "/api/migration/{migration_id}/status", action: "501", open: false, migrate: "" },
214
+ { method: "GET", path: "/api/migration/{migration_id}/plan", action: "410", open: false, migrate: "" },
215
+ { method: "GET", path: "/api/migration/{migration_id}/features", action: "410", open: false, migrate: "" },
216
+ { method: "GET", path: "/api/migration/{migration_id}/seams", action: "410", open: false, migrate: "" },
217
+ { method: "POST", path: "/api/migration/{migration_id}/advance", action: "410", open: false, migrate: "" },
218
+ { method: "POST", path: "/api/migration/{migration_id}/start-phase", action: "410", open: false, migrate: "" },
219
+ { method: "GET", path: "/api/managed/events", action: "501", open: false, migrate: "" },
220
+ { method: "GET", path: "/api/managed/status", action: "501", open: false, migrate: "" },
221
+ { method: "GET", path: "/api/managed/memory_versions/{memory_id}", action: "410", open: false, migrate: "" },
222
+ { method: "GET", path: "/api/findings/{iteration}", action: "410", open: false, migrate: "" },
223
+ { method: "GET", path: "/api/quality/architecture", action: "410", open: false, migrate: "" },
224
+ { method: "GET", path: "/api/learnings", action: "501", open: false, migrate: "" },
225
+ { method: "GET", path: "/api/escalations", action: "501", open: false, migrate: "" },
226
+ { method: "GET", path: "/api/escalations/{filename}", action: "410", open: false, migrate: "" },
227
+ { method: "GET", path: "/api/phases", action: "410", open: false, migrate: "" },
228
+ { method: "GET", path: "/api/proofs", action: "501", open: false, migrate: "" },
229
+ { method: "GET", path: "/api/proofs/summary", action: "501", open: false, migrate: "" },
230
+ { method: "GET", path: "/api/proofs/{run_id}", action: "501", open: false, migrate: "" },
231
+ { method: "GET", path: "/api/proofs/{run_id}/html", action: "410", open: false, migrate: "" },
232
+ { method: "GET", path: "/api/spec", action: "410", open: false, migrate: "" },
233
+ { method: "GET", path: "/api/spec/history", action: "410", open: false, migrate: "" },
234
+ { method: "GET", path: "/api/wiki", action: "410", open: false, migrate: "" },
235
+ { method: "GET", path: "/api/wiki/{section}", action: "410", open: false, migrate: "" },
236
+ { method: "POST", path: "/api/wiki/ask", action: "501", open: false, migrate: "" },
237
+ ];
@@ -0,0 +1,195 @@
1
+ // Legacy dashboard shim (CPE-24, docs/v10/CP-ENTERPRISE-UI.md section 5). A Hono router, mounted on the CP app before the SPA fallback, that answers every route
2
+ // dashboard/server.py served: map onto CP data, 308 the HTML pages, 501 what the CP cannot back yet (never a faked success), 410 what nothing consumes.
3
+ // It binds no port and adds no machine-touching action. Auth is never looser than legacy (see legacyGuard).
4
+ import { and, desc, eq, gte, isNotNull, isNull, lt, sql } from "drizzle-orm";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { Hono, type Context } from "hono";
8
+ import type { Db } from "../../db/migrate.ts";
9
+ import { audit, runs } from "../../db/schema.ts";
10
+ import { peerIsLoopback, tokenMatches } from "../auth.ts";
11
+ import { liveInfo, listRuns, loadEvents, runDetail } from "../runs.ts";
12
+ import { LEGACY_ROUTES, type LegacyRoute } from "./routes.ts";
13
+ import { metricsMapped } from "./routes-metrics.ts";
14
+
15
+ export interface LegacyShimOpts {
16
+ db: Db;
17
+ /** Checkout whose .loki/ the metrics and cost mappings read (CPE24-P1); defaults to the process cwd like createApp. */
18
+ repoDir?: string;
19
+ /** The CP bearer token (LOKI_CONTROL_TOKEN). */
20
+ token?: string;
21
+ /** Environment used to read the legacy auth flags; defaults to process.env. */
22
+ env?: Record<string, string | undefined>;
23
+ /** Origin of the CP UI for 308 targets. Empty (default) means the shim is mounted on the CP app itself, where "/" is already the UI. */
24
+ uiBase?: string;
25
+ log?: (line: string) => void;
26
+ version?: string;
27
+ }
28
+
29
+ const flagOn = (v: string | undefined) => ["true", "1", "yes"].includes((v ?? "").toLowerCase());
30
+ /** The legacy dashboard enforced scopes when LOKI_ENTERPRISE_AUTH was on or OIDC was configured (dashboard/auth.py). */
31
+ export const legacyAuthEnabled = (env: Record<string, string | undefined>) => flagOn(env.LOKI_ENTERPRISE_AUTH) || !!(env.LOKI_OIDC_ISSUER && env.LOKI_OIDC_CLIENT_ID);
32
+
33
+ const logged = new Set<string>();
34
+ /** Test hook: forget which routes already logged their first hit. */
35
+ export const resetLegacyLog = () => logged.clear();
36
+
37
+ const toHono = (p: string) => p.replace(/\{([A-Za-z_]+):path\}/g, ":$1{.+}").replace(/\{([A-Za-z_]+)\}/g, ":$1");
38
+ const NOT_SUPPORTED = "not yet supported by the Control Plane";
39
+
40
+ const repoVersion = (): string => {
41
+ for (const f of [join(import.meta.dir, "../../../../../VERSION")]) {
42
+ try { if (existsSync(f)) return readFileSync(f, "utf8").trim(); } catch { /* fall through */ }
43
+ }
44
+ return "unknown";
45
+ };
46
+
47
+ const intParam = (v: string | undefined, def: number, min: number, max: number): number | null => {
48
+ if (v === undefined) return def;
49
+ if (!/^\d+$/.test(v)) return null;
50
+ const n = Number(v);
51
+ return n >= min && n <= max ? n : null;
52
+ };
53
+
54
+ export function queryAudit(db: Db, q: { start_date?: string; end_date?: string; action?: string; limit: number; offset: number }) {
55
+ const where = and(
56
+ q.action ? eq(audit.action, q.action) : undefined,
57
+ q.start_date ? gte(audit.ts, q.start_date) : undefined,
58
+ q.end_date ? lt(audit.ts, `${q.end_date}￿`) : undefined,
59
+ );
60
+ return db.select().from(audit).where(where).orderBy(desc(audit.id)).limit(q.limit).offset(q.offset).all()
61
+ .map((r) => ({ id: r.id, timestamp: r.ts, action: r.action, actor: r.actor, details: r.detail }));
62
+ }
63
+
64
+ export function legacyShim(opts: LegacyShimOpts) {
65
+ const { db } = opts;
66
+ const env = opts.env ?? process.env;
67
+ const log = opts.log ?? ((l: string) => console.warn(l));
68
+ const version = opts.version ?? repoVersion();
69
+ const bootedAt = Date.now();
70
+ const uiBase = (opts.uiBase ?? "").replace(/\/+$/, "");
71
+ const authOn = legacyAuthEnabled(env);
72
+ const app = new Hono();
73
+
74
+ /** Never looser than legacy: a CP token, when set, is always required; with none set, legacy enterprise auth or OIDC being on fails closed (the CP cannot check loki_* tokens); with neither, only a loopback socket peer is served (legacy 403s any other peer, whatever Host says; unknown peer fails closed). */
75
+ const denied = (c: Context): Response | null => {
76
+ if (opts.token) return tokenMatches(c.req.header("authorization"), opts.token) ? null : c.json({ detail: "Not authenticated" }, 401, { "www-authenticate": "Bearer" });
77
+ if (authOn) return c.json({ detail: "Not authenticated" }, 401, { "www-authenticate": "Bearer" });
78
+ return peerIsLoopback(c) ? null : c.json({ detail: "Forbidden" }, 403);
79
+ };
80
+ const once = (r: Pick<LegacyRoute, "method" | "path" | "action" | "migrate">) => {
81
+ const key = `${r.method} ${r.path}`;
82
+ if (logged.has(key)) return;
83
+ logged.add(key);
84
+ if (r.action === "501") log(`legacy dashboard route ${r.path} is not served by the Control Plane (501); no /v1 equivalent yet`);
85
+ else if (r.action === "410") log(`legacy dashboard route ${r.path} is retired (410); nothing consumes it`);
86
+ else log(`legacy dashboard route ${r.path} is served by the Control Plane; migrate to ${r.migrate}`);
87
+ };
88
+
89
+ const runKey = (id: string): { source: string; run: string } | null => {
90
+ const i = id.indexOf(":");
91
+ if (i > 0) return { source: id.slice(0, i), run: id.slice(i + 1) };
92
+ const row = db.select({ s: runs.sourceId, r: runs.runId }).from(runs).where(eq(runs.runId, id)).orderBy(desc(runs.startedAt)).get();
93
+ return row ? { source: row.s, run: row.r } : null;
94
+ };
95
+ const withId = <T extends { source_id: string; run_id: string; started_at: string | null }>(r: T) => ({ ...r, id: `${r.source_id}:${r.run_id}`, created_at: r.started_at });
96
+
97
+ const auditList = (c: Context) => {
98
+ const q = c.req.query();
99
+ const limit = intParam(q.limit, 100, 1, 10000), offset = intParam(q.offset, 0, 0, Number.MAX_SAFE_INTEGER);
100
+ if (limit === null || offset === null) return { err: c.json({ detail: "limit must be 1..10000 and offset a non-negative integer" }, 422) };
101
+ if (q.resource_type) return { err: c.json({ detail: "resource_type filter is not supported by the Control Plane audit log" }, 400) };
102
+ return { rows: queryAudit(db, { start_date: q.start_date, end_date: q.end_date, action: q.action, limit, offset }), limit, offset };
103
+ };
104
+
105
+ const mapped: Record<string, (c: Context) => Response> = {
106
+ "GET /.well-known/agent.json": (c) => c.json({
107
+ name: "Loki Mode", version, url: "https://www.autonomi.dev/",
108
+ description: "Multi-agent autonomous system by Autonomi. Served by the Loki Control Plane.",
109
+ capabilities: { streaming: false, pushNotifications: false, stateTransitionHistory: true },
110
+ protocols: { a2a: "0.1" },
111
+ endpoints: { health: "/health", status: "/api/status", runs: "/v1/runs" },
112
+ enterprise: { multi_tenant: false, rbac: false, audit_log: true, sso: false },
113
+ authentication: { schemes: ["bearer"] },
114
+ defaultInputModes: ["text/plain", "application/json"], defaultOutputModes: ["text/plain", "application/json"],
115
+ }),
116
+ "GET /api/status": (c) => {
117
+ const running = db.select({ n: sql<number>`count(*)` }).from(runs).where(isNull(runs.endedAt)).get()?.n ?? 0;
118
+ const latest = db.select().from(runs).where(isNull(runs.endedAt)).orderBy(desc(runs.startedAt)).get();
119
+ const live = latest ? liveInfo(loadEvents(db, latest.sourceId, latest.runId)) : null;
120
+ return c.json({
121
+ status: running > 0 ? "running" : "idle", version, uptime_seconds: (Date.now() - bootedAt) / 1000,
122
+ active_sessions: running, database_connected: true,
123
+ phase: live?.current_stage ?? "", provider: latest?.provider ?? "",
124
+ });
125
+ },
126
+ "GET /api/v2/runs": (c) => {
127
+ const q = c.req.query();
128
+ const limit = intParam(q.limit, 50, 1, 1000), offset = intParam(q.offset, 0, 0, Number.MAX_SAFE_INTEGER);
129
+ if (limit === null || offset === null) return c.json({ detail: "limit must be 1..1000 and offset a non-negative integer" }, 422);
130
+ if (q.project_id !== undefined) return c.json({ detail: "project_id filter is not supported by the Control Plane (runs are not grouped into legacy projects)" }, 400);
131
+ if (q.status !== undefined && q.status !== "running" && q.status !== "completed") return c.json({ detail: "status must be running or completed" }, 400);
132
+ if (q.status === undefined) return c.json(listRuns(db, { limit, cursor: String(offset) }).runs.map(withId));
133
+ const keys = db.select({ s: runs.sourceId, r: runs.runId }).from(runs).where(q.status === "running" ? isNull(runs.endedAt) : isNotNull(runs.endedAt))
134
+ .orderBy(desc(runs.startedAt)).limit(Math.min(limit, 200)).offset(offset).all();
135
+ return c.json(keys.flatMap((k) => { const d = runDetail(db, k.s, k.r); return d ? [withId(d)] : []; }));
136
+ },
137
+ "GET /api/v2/runs/{run_id}": (c) => {
138
+ const k = runKey(c.req.param("run_id") ?? "");
139
+ const d = k ? runDetail(db, k.source, k.run) : null;
140
+ return d ? c.json(withId(d)) : c.json({ detail: "Run not found" }, 404);
141
+ },
142
+ "GET /api/v2/runs/{run_id}/timeline": (c) => {
143
+ const k = runKey(c.req.param("run_id") ?? "");
144
+ const d = k ? runDetail(db, k.source, k.run) : null;
145
+ if (!k || !d) return c.json({ detail: "Run not found" }, 404);
146
+ return c.json({
147
+ run_id: `${k.source}:${k.run}`,
148
+ phases: d.stages.map((s) => ({ name: s.stage, status: s.status, started_at: s.started_at, ended_at: s.ended_at })),
149
+ current_phase: d.current_stage,
150
+ events: loadEvents(db, k.source, k.run).map((e) => ({ seq: e.seq, ts: e.ts, type: e.type, stage: e.stage })),
151
+ });
152
+ },
153
+ "GET /api/v2/audit": (c) => { const r = auditList(c); return r.err ?? c.json(r.rows); },
154
+ ...metricsMapped(opts.repoDir ?? process.cwd()),
155
+ };
156
+
157
+ // /lab/api/* is data, not a page: 501 JSON on every method (a 308 to the SPA would hand a client HTML). Registered before the /lab mount.
158
+ app.all("/lab/api/*", (c) => {
159
+ const d = denied(c);
160
+ if (d) return d;
161
+ return c.json({ error: NOT_SUPPORTED, detail: "The legacy dashboard route exists, but the Control Plane has no data or mechanism for it yet.", legacy_route: "/lab/api/*", method: c.req.method }, 501);
162
+ });
163
+
164
+ for (const r of LEGACY_ROUTES) {
165
+ // On the CP app itself "/" is the UI already; redirecting it would loop. Only a separately mounted shim (uiBase set) redirects it.
166
+ if (r.action === "308" && uiBase === "" && r.path === "/") continue;
167
+ const hp = toHono(r.path);
168
+ const handle = (c: Context): Response => {
169
+ if (!r.open) { const d = denied(c); if (d) return d; }
170
+ once(r);
171
+ const body = { legacy_route: r.path, method: r.method };
172
+ if (r.action === "map") return (mapped[`${r.method} ${r.path}`] as (c: Context) => Response)(c);
173
+ if (r.action === "308") return c.redirect(`${uiBase}/`, 308);
174
+ if (r.action === "410") return c.json({ error: "gone", detail: "This legacy dashboard route was retired; nothing consumes it.", ...body }, 410);
175
+ return c.json({ error: NOT_SUPPORTED, detail: "The legacy dashboard route exists, but the Control Plane has no data or mechanism for it yet.", ...body }, 501);
176
+ };
177
+ const m = r.method === "WS" || r.method === "MOUNT" ? "GET" : r.method;
178
+ app.on(m, hp, handle);
179
+ if (hp !== "/" && !hp.endsWith("}")) app.on(m, `${hp}/`, handle); // legacy stripped a trailing slash
180
+ if (r.method === "MOUNT") app.on(m, `${hp}/*`, handle);
181
+ }
182
+
183
+ // /v1/audit: the CP already stores the operator-action log; this exposes it (guarded by the /v1 bearer middleware and by the shim guard).
184
+ app.get("/v1/audit", (c) => {
185
+ const d = denied(c);
186
+ if (d) return d;
187
+ const r = auditList(c);
188
+ return r.err ?? c.json({ entries: r.rows, limit: r.limit, offset: r.offset });
189
+ });
190
+
191
+ // Unknown /api/* is a JSON 404 like legacy, never the SPA shell.
192
+ app.all("/api/*", (c) => { const d = denied(c); return d ?? c.json({ detail: "Not Found" }, 404); });
193
+
194
+ return app;
195
+ }
@@ -0,0 +1,27 @@
1
+ // local_repos: the server-side map from source_id to a real repo path. Filled only by local discovery (never by /v1/ingest);
2
+ // the path never leaves the server. The API exposes display names only.
3
+ import type { Hono } from "hono";
4
+ import type { Db } from "../db/migrate.ts";
5
+ import { localRepos } from "../db/schema.ts";
6
+ import { discoverLocalRepos } from "../shipper/discover.ts";
7
+
8
+ /** Upsert every locally discovered repo. Returns the number of repos known. */
9
+ export function syncLocalRepos(db: Db, repoDir: string, env: NodeJS.ProcessEnv = process.env): number {
10
+ const now = new Date().toISOString();
11
+ const found = discoverLocalRepos(repoDir, env);
12
+ for (const r of found) {
13
+ db.insert(localRepos).values({ sourceId: r.sourceId, realpath: r.realpath, name: r.name, discoveredAt: now })
14
+ .onConflictDoUpdate({ target: localRepos.sourceId, set: { realpath: r.realpath, name: r.name } }).run();
15
+ }
16
+ return found.length;
17
+ }
18
+
19
+ /** Display names only, sorted and de-duplicated. */
20
+ export function repoNames(db: Db): string[] {
21
+ return [...new Set(db.select({ name: localRepos.name }).from(localRepos).all().map((r) => r.name))].sort();
22
+ }
23
+
24
+ /** GET /v1/repos. `act` is the loopback-only router; `peerIsLoopback` checks the real socket address. */
25
+ export function mountRepos(act: Hono, db: Db, peerIsLoopback: (c: Parameters<Parameters<Hono["get"]>[1]>[0]) => boolean): void {
26
+ act.get("/v1/repos", (c) => peerIsLoopback(c) ? c.json({ repos: repoNames(db) }) : c.json({ error: "loopback only" }, 403));
27
+ }