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.
- package/README.md +1 -0
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/loki +4 -1
- package/autonomy/run.sh +9 -5
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +6 -8
- package/docs/INSTALLATION.md +1 -1
- package/docs/v10/CONTROL-PLANE.md +7 -0
- package/docs/v10/CP-ENTERPRISE-UI.md +263 -2
- package/docs/v10/DECISIONS.md +41 -0
- package/docs/v10/ENGINE-LAWS.md +155 -0
- package/docs/v10/FAILURE-CLASSES.md +166 -0
- package/docs/v10/GUIDE.md +4 -2
- package/docs/v10/L0-WAVE1.md +81 -0
- package/docs/v10/METRICS.md +6 -0
- package/docs/v10/MORNING-BRIEF-1003.md +11 -25
- package/docs/v10/OPERATING-MODEL.md +1 -0
- package/docs/v10/PROGRESS.md +28 -0
- package/loki-ts/data/model-pricing.json +6 -6
- package/loki-ts/dist/loki.js +851 -837
- package/loki-ts/package.json +2 -1
- package/mcp/__init__.py +1 -1
- package/package.json +2 -2
- package/packages/control-plane/dist/server.js +17103 -5411
- package/packages/control-plane/drizzle/0002_tiresome_fixer.sql +7 -0
- package/packages/control-plane/drizzle/0003_integrity.sql +4 -0
- package/packages/control-plane/drizzle/0004_cpe03_local_repos_actions.sql +17 -0
- package/packages/control-plane/drizzle/meta/0002_snapshot.json +409 -0
- package/packages/control-plane/drizzle/meta/0003_snapshot.json +437 -0
- package/packages/control-plane/drizzle/meta/0004_snapshot.json +542 -0
- package/packages/control-plane/drizzle/meta/_journal.json +22 -1
- package/packages/control-plane/package.json +4 -1
- package/packages/control-plane/src/db/migrate.ts +6 -2
- package/packages/control-plane/src/db/prune.ts +91 -0
- package/packages/control-plane/src/db/schema.ts +32 -0
- package/packages/control-plane/src/server/app.ts +30 -22
- package/packages/control-plane/src/server/audit.ts +15 -0
- package/packages/control-plane/src/server/auth.ts +7 -1
- package/packages/control-plane/src/server/integrity.ts +147 -0
- package/packages/control-plane/src/server/legacy/routes-metrics.ts +18 -0
- package/packages/control-plane/src/server/legacy/routes.ts +237 -0
- package/packages/control-plane/src/server/legacy/shim.ts +195 -0
- package/packages/control-plane/src/server/repos.ts +27 -0
- package/packages/control-plane/src/server/routes/artifacts.ts +104 -0
- package/packages/control-plane/src/server/routes/audit.ts +17 -0
- package/packages/control-plane/src/server/routes/config.ts +319 -0
- package/packages/control-plane/src/server/routes/control.ts +75 -0
- package/packages/control-plane/src/server/routes/cost.ts +70 -0
- package/packages/control-plane/src/server/routes/cost_ledger.ts +210 -0
- package/packages/control-plane/src/server/routes/fleet.ts +101 -0
- package/packages/control-plane/src/server/routes/index.ts +68 -0
- package/packages/control-plane/src/server/routes/integrations.ts +123 -0
- package/packages/control-plane/src/server/routes/merge_risk.ts +209 -0
- package/packages/control-plane/src/server/routes/metrics.ts +93 -0
- package/packages/control-plane/src/server/routes/notify.ts +48 -0
- package/packages/control-plane/src/server/routes/providers.ts +111 -0
- package/packages/control-plane/src/server/routes/start.ts +39 -0
- package/packages/control-plane/src/server/routes/stats.ts +76 -0
- package/packages/control-plane/src/server/routes/stream.ts +85 -0
- package/packages/control-plane/src/server/routes/verify.ts +66 -0
- package/packages/control-plane/src/server/runs.ts +46 -4
- package/packages/control-plane/src/server/spawn.ts +79 -0
- package/packages/control-plane/src/shipper/discover.ts +50 -3
- package/packages/control-plane/src/shipper/ship.ts +15 -0
- package/packages/control-plane/src/shipper/watch.ts +3 -1
- package/packages/control-plane/ui/dist/assets/index-BQbFv65M.css +1 -0
- package/packages/control-plane/ui/dist/assets/index-Dbhu244h.js +155 -0
- package/packages/control-plane/ui/dist/index.html +2 -2
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/web-app/dist/assets/{AdminPage-BQPb5rc5.js → AdminPage-DDDvUz0N.js} +1 -1
- package/web-app/dist/assets/{Avatar-Cttco6FC.js → Avatar-Dcdz46i6.js} +1 -1
- package/web-app/dist/assets/{Badge-CPb6kpbH.js → Badge-BB24cCpK.js} +1 -1
- package/web-app/dist/assets/{Button-caTL28a4.js → Button-D0-DmPZL.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-bEbLx6ph.js → CockpitPage-CdjpU2JW.js} +1 -1
- package/web-app/dist/assets/{ComparePage-DNtJSij-.js → ComparePage-DT5U0y0d.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-BrgC3K44.js → ErrorBoundary-lEHcl0EO.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-Q-mSrUUQ.js → EvidenceReceiptPanel-GpnWvdCr.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-DbmYl_Er.js → GitHubIssuesPanel-C7VOh1B7.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-C6PCHM4B.js → GitHubPRsPanel-G_kOuINv.js} +1 -1
- package/web-app/dist/assets/{HomePage-7zsL_k_e.js → HomePage-PqRisOny.js} +2 -2
- package/web-app/dist/assets/{LoginPage-Cl4ON8RQ.js → LoginPage-DU7pFAkf.js} +1 -1
- package/web-app/dist/assets/{MagicPage-BNEAhTqH.js → MagicPage-C5JWIWMk.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-DyBH-88q.js → MetricsPage-CbIGS_qo.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-CIVr21yL.js → NotFoundPage-vU1pvdSt.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-BMeVYocF.js → ProjectPage-SsixIPPv.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-D-H2WRVU.js → ProjectsPage-BfQKSR9k.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-o0kR9RAt.js → SettingsPage-B8YfXAom.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-DtfMDMjp.js → ShowcasePage-Ba407f2-.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-CkXdpZ_T.js → SystemSettingsPage-DzNQhhBS.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-Cu5H9cQ0.js → TeamsPage-tqUnpZ15.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-3wdbVwil.js → TemplatesPage-ClB5EpI3.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-CWn301xh.js → TerminalOutput-CBvpYfEu.js} +1 -1
- package/web-app/dist/assets/{activity-YGaAt4zJ.js → activity-C1s0L_X9.js} +1 -1
- package/web-app/dist/assets/{bell-C28q0GO7.js → bell-DMEiXr73.js} +1 -1
- package/web-app/dist/assets/{bot-DLcQGI92.js → bot-BF_xIOxu.js} +1 -1
- package/web-app/dist/assets/{check-w0sBNlcm.js → check-Dv1Ynvrs.js} +1 -1
- package/web-app/dist/assets/{chevron-left-W7O0vC_m.js → chevron-left-Ddhsod7S.js} +1 -1
- package/web-app/dist/assets/{circle-alert-Cxjq9fxB.js → circle-alert-OATHUkBZ.js} +1 -1
- package/web-app/dist/assets/{clock-C8EqzmNl.js → clock-C1Ltva07.js} +1 -1
- package/web-app/dist/assets/{cloud-xNgp4g3Y.js → cloud-B-S4oOy9.js} +1 -1
- package/web-app/dist/assets/{code-xml-B-lyNDLJ.js → code-xml-D3Femn_F.js} +1 -1
- package/web-app/dist/assets/{database-DoW5lmWc.js → database-DBSeFgO3.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-BcARA5sC.js → dollar-sign-sgDgNuGE.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-CehcII0J.js → file-code-corner-C-MbdZEc.js} +1 -1
- package/web-app/dist/assets/{file-plus-DI9Zo5hP.js → file-plus-CCB6yqXv.js} +1 -1
- package/web-app/dist/assets/{globe-CiZWBk3H.js → globe-DlbrNAj1.js} +1 -1
- package/web-app/dist/assets/{hammer-3qv_T2N-.js → hammer-B-DWm695.js} +1 -1
- package/web-app/dist/assets/{index-gF9grAz-.js → index-Dvny-1SU.js} +3 -3
- package/web-app/dist/assets/{layers-CN7d0By9.js → layers-99qsKpOT.js} +1 -1
- package/web-app/dist/assets/{loader-circle-DywcM-im.js → loader-circle-ZvZyZl3J.js} +1 -1
- package/web-app/dist/assets/{lock-_hdSmkU4.js → lock-DosGjcxP.js} +1 -1
- package/web-app/dist/assets/{package-BV5zgqxJ.js → package-C-PumesN.js} +1 -1
- package/web-app/dist/assets/{plus-CU_Hh4-x.js → plus-CTYYskTB.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-DMK-fNl-.js → refresh-cw-qW2ztTFS.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-CEB3_v5_.js → rotate-ccw-ZIKrf-vO.js} +1 -1
- package/web-app/dist/assets/{scroll-text-BSdXxcRN.js → scroll-text-BamlXjAT.js} +1 -1
- package/web-app/dist/assets/{server-CAMlP1yJ.js → server-BljXb79I.js} +1 -1
- package/web-app/dist/assets/{shield-alert-B-r1Dt3h.js → shield-alert-Dr6eT9c_.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-CYfxw9o4.js → thumbs-up-v3oBXZgU.js} +1 -1
- package/web-app/dist/assets/{trash-2-DX3nGPS8.js → trash-2-BOuIrCpi.js} +1 -1
- package/web-app/dist/assets/{trending-down-Bt-hSntd.js → trending-down-Dp7jMCgH.js} +1 -1
- package/web-app/dist/assets/{trending-up-DVSlvQp8.js → trending-up-CDiur-nP.js} +1 -1
- package/web-app/dist/assets/{upload-D45RRHAP.js → upload-DxIHXhNE.js} +1 -1
- package/web-app/dist/assets/{usePolling-fq_Xj8iw.js → usePolling-D2Uu-YZb.js} +1 -1
- package/web-app/dist/assets/{user-BWcJkLlr.js → user-BVWCUAfF.js} +1 -1
- package/web-app/dist/index.html +1 -1
- package/packages/control-plane/src/server/actions.ts +0 -60
- package/packages/control-plane/ui/dist/assets/index-CUQ65vJY.css +0 -1
- package/packages/control-plane/ui/dist/assets/index-CwwGfp6E.js +0 -129
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Run artifacts read API (CPE-04): a run's events (paged) and an allowlist of files from <repo>/.loki/runs/<run>/.
|
|
2
|
+
// Events come from the store. Files are read from disk, so that route is loopback-only (on `act`) and every path is contained:
|
|
3
|
+
// the run id must be a known ingested run with a discovered local repo, the name must match a fixed allowlist, and the
|
|
4
|
+
// realpath of the file must stay under the realpath of <repo>/.loki/runs/<run>. The repo path never appears in a response.
|
|
5
|
+
import { closeSync, fstatSync, openSync, readSync, realpathSync } from "node:fs";
|
|
6
|
+
import { join, sep } from "node:path";
|
|
7
|
+
import { and, asc, eq, gt } from "drizzle-orm";
|
|
8
|
+
import type { Context } from "hono";
|
|
9
|
+
import { events, localRepos, runs } from "../../db/schema.ts";
|
|
10
|
+
import type { RouteCtx } from "./index.ts";
|
|
11
|
+
|
|
12
|
+
export const MAX_ARTIFACT_BYTES = 5 * 1024 * 1024;
|
|
13
|
+
export const EVENTS_DEFAULT = 500;
|
|
14
|
+
export const EVENTS_MAX = 1000;
|
|
15
|
+
|
|
16
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
17
|
+
const NAME = /^(?:issue\.json|plan\.json|receipt\.json|receipt\.md|report\.md|task\.md|diff\.patch|evidence\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}\.png)$/;
|
|
18
|
+
|
|
19
|
+
const TYPES: Record<string, string> = {
|
|
20
|
+
json: "application/json; charset=utf-8",
|
|
21
|
+
md: "text/plain; charset=utf-8",
|
|
22
|
+
patch: "text/plain; charset=utf-8",
|
|
23
|
+
png: "image/png",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const safeId = (s: string) => ID.test(s) && !s.includes("..");
|
|
27
|
+
|
|
28
|
+
/** Reads a regular file up to the cap through one descriptor (the size check and the read see the same file). */
|
|
29
|
+
function readCapped(path: string): { status: 200; body: Buffer } | { status: 404 | 413 } {
|
|
30
|
+
let fd: number;
|
|
31
|
+
try { fd = openSync(path, "r"); } catch { return { status: 404 }; }
|
|
32
|
+
try {
|
|
33
|
+
const st = fstatSync(fd);
|
|
34
|
+
if (!st.isFile()) return { status: 404 };
|
|
35
|
+
if (st.size > MAX_ARTIFACT_BYTES) return { status: 413 };
|
|
36
|
+
const buf = Buffer.alloc(st.size);
|
|
37
|
+
let off = 0;
|
|
38
|
+
while (off < st.size) {
|
|
39
|
+
const n = readSync(fd, buf, off, st.size - off, off);
|
|
40
|
+
if (n <= 0) break;
|
|
41
|
+
off += n;
|
|
42
|
+
}
|
|
43
|
+
return { status: 200, body: buf.subarray(0, off) };
|
|
44
|
+
} finally { closeSync(fd); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function mount(ctx: RouteCtx): void {
|
|
48
|
+
const { app, act, db } = ctx;
|
|
49
|
+
const known = (source: string, run: string) =>
|
|
50
|
+
safeId(source) && safeId(run) && !!db.select({ r: runs.runId }).from(runs).where(and(eq(runs.sourceId, source), eq(runs.runId, run))).get();
|
|
51
|
+
|
|
52
|
+
// Paged events for the log: seq > after, ascending. DB only, so it serves on `app` behind the normal guards.
|
|
53
|
+
app.get("/v1/runs/:source/:run/events", (c) => {
|
|
54
|
+
const source = c.req.param("source"), run = c.req.param("run");
|
|
55
|
+
if (!known(source, run)) return c.json({ error: "run not found" }, 404);
|
|
56
|
+
const rawAfter = c.req.query("after"), rawLimit = c.req.query("limit");
|
|
57
|
+
if (rawAfter !== undefined && !/^\d{1,15}$/.test(rawAfter)) return c.json({ error: "after must be a non-negative integer" }, 400);
|
|
58
|
+
if (rawLimit !== undefined && !/^\d{1,6}$/.test(rawLimit)) return c.json({ error: "limit must be a positive integer" }, 400);
|
|
59
|
+
const after = rawAfter === undefined ? -1 : Number(rawAfter);
|
|
60
|
+
const limit = Math.min(rawLimit === undefined ? EVENTS_DEFAULT : Number(rawLimit), EVENTS_MAX);
|
|
61
|
+
if (limit < 1) return c.json({ error: "limit must be a positive integer" }, 400);
|
|
62
|
+
const rows = db.select().from(events).where(and(eq(events.sourceId, source), eq(events.runId, run), gt(events.seq, after))).orderBy(asc(events.seq)).limit(limit + 1).all();
|
|
63
|
+
const out = rows.slice(0, limit).map((r) => ({ v: 1, seq: r.seq, ts: r.ts, run: r.runId, type: r.type, stage: r.stage, data: r.data }));
|
|
64
|
+
return c.json({ events: out, next_after: out.length ? out[out.length - 1]?.seq ?? null : null, has_more: rows.length > limit });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// One allowlisted artifact file. Loopback peer only; a non-loopback bind never registers this route (act is detached).
|
|
68
|
+
act.get("/v1/runs/:source/:run/artifact/*", (c: Context) => {
|
|
69
|
+
if (!ctx.peerIsLoopback(c)) return c.json({ error: "loopback only" }, 403);
|
|
70
|
+
const source = c.req.param("source"), run = c.req.param("run");
|
|
71
|
+
const path = new URL(c.req.url).pathname;
|
|
72
|
+
const at = path.indexOf("/artifact/");
|
|
73
|
+
const raw = at < 0 ? "" : path.slice(at + "/artifact/".length);
|
|
74
|
+
let name: string;
|
|
75
|
+
try { name = decodeURIComponent(raw); } catch { return c.json({ error: "malformed URL encoding" }, 400); }
|
|
76
|
+
// Traversal, absolute names, backslashes, NUL and anything off the allowlist all fail this one exact match.
|
|
77
|
+
if (!NAME.test(name)) return c.json({ error: "artifact not on the allowlist" }, 404);
|
|
78
|
+
if (!known(source, run)) return c.json({ error: "run not found" }, 404);
|
|
79
|
+
const repo = db.select({ p: localRepos.realpath }).from(localRepos).where(eq(localRepos.sourceId, source)).get();
|
|
80
|
+
if (!repo) return c.json({ error: "artifact not available" }, 404);
|
|
81
|
+
let runsRoot: string, runDir: string, file: string;
|
|
82
|
+
try {
|
|
83
|
+
runsRoot = realpathSync(join(repo.p, ".loki", "runs"));
|
|
84
|
+
runDir = realpathSync(join(runsRoot, run));
|
|
85
|
+
} catch { return c.json({ error: "artifact not found" }, 404); }
|
|
86
|
+
if (runDir !== join(runsRoot, run)) return c.json({ error: "artifact not found" }, 404);
|
|
87
|
+
try { file = realpathSync(join(runDir, name)); } catch { return c.json({ error: "artifact not found" }, 404); }
|
|
88
|
+
if (!file.startsWith(runDir + sep)) return c.json({ error: "artifact not found" }, 404);
|
|
89
|
+
const r = readCapped(file);
|
|
90
|
+
if (r.status === 413) return c.json({ error: `artifact over ${MAX_ARTIFACT_BYTES} bytes` }, 413);
|
|
91
|
+
if (r.status !== 200) return c.json({ error: "artifact not found" }, 404);
|
|
92
|
+
const ext = name.slice(name.lastIndexOf(".") + 1);
|
|
93
|
+
return new Response(new Uint8Array(r.body), {
|
|
94
|
+
status: 200,
|
|
95
|
+
headers: {
|
|
96
|
+
"content-type": TYPES[ext] ?? "application/octet-stream",
|
|
97
|
+
"content-length": String(r.body.length),
|
|
98
|
+
"x-content-type-options": "nosniff",
|
|
99
|
+
"content-security-policy": "default-src 'none'; sandbox",
|
|
100
|
+
"cache-control": "no-store",
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// GET /v1/audit: the actions table written by src/server/audit.ts, newest first, paged. The helper never stores secrets.
|
|
2
|
+
import { desc, sql } from "drizzle-orm";
|
|
3
|
+
import { actions } from "../../db/schema.ts";
|
|
4
|
+
import type { RouteCtx } from "./index.ts";
|
|
5
|
+
|
|
6
|
+
export function mount(ctx: RouteCtx): void {
|
|
7
|
+
ctx.app.get("/v1/audit", (c) => {
|
|
8
|
+
const limit = Math.min(Math.max(Number.parseInt(c.req.query("limit") ?? "50", 10) || 50, 1), 200);
|
|
9
|
+
const offset = Math.max(Number.parseInt(c.req.query("cursor") ?? "0", 10) || 0, 0);
|
|
10
|
+
const total = ctx.db.select({ n: sql<number>`count(*)` }).from(actions).get()?.n ?? 0;
|
|
11
|
+
const rows = ctx.db.select().from(actions).orderBy(desc(actions.ts), desc(actions.id)).limit(limit).offset(offset).all();
|
|
12
|
+
return c.json({
|
|
13
|
+
actions: rows.map((r) => ({ id: r.id, ts: r.ts, actor: r.actor, kind: r.kind, target: r.target, result: r.result, detail: r.detail })),
|
|
14
|
+
total, next_cursor: offset + rows.length < total ? String(offset + rows.length) : null,
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
}
|