loki-mode 10.7.1 → 10.9.1
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 +2 -2
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/loki +20 -32
- package/autonomy/run.sh +9 -5
- package/autonomy/tui.sh +5 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/api_phases.py +1 -1
- package/dashboard/api_start.py +0 -1
- package/dashboard/server.py +28 -146
- package/docs/COCKPIT-SPEC.md +3 -1
- package/docs/DASHBOARD-9.12-EVIDENCE.md +1 -1
- package/docs/DASHBOARD-ARCHITECTURE.md +13 -11
- package/docs/DASHBOARD_V2_CHECKLIST.md +2 -0
- package/docs/INSTALLATION.md +12 -11
- package/docs/MERGE-DEDUP-MAP.md +1 -1
- package/docs/MERGE-ROUTE-MAP.md +1 -1
- package/docs/MERGE3-PLAN.md +2 -2
- package/docs/R3-COST-OBSERVABILITY-DESIGN.md +2 -2
- package/docs/R4-TRUST-TRAJECTORY-DESIGN.md +1 -1
- package/docs/R5-AUTO-WIKI-DESIGN.md +1 -1
- package/docs/R6-ROLLBACK-CHECKPOINT-PLAN.md +1 -1
- package/docs/RARV-C-100X-PLAN.md +3 -3
- package/docs/SONNET5-DEFAULT-PLAN.md +2 -2
- package/docs/SYNERGY-TASKS.md +1 -1
- package/docs/TOOL-INTEGRATION.md +1 -1
- package/docs/TOP-100-BACKLOG.md +1 -1
- package/docs/alternative-installations.md +1 -1
- package/docs/architecture/ADR-001-runtime-migration.md +1 -1
- package/docs/architecture/DASHBOARD_V2_ARCHITECTURE.md +2 -0
- package/docs/audit-logging.md +2 -0
- package/docs/authentication.md +2 -0
- package/docs/authorization.md +2 -0
- package/docs/certification/02-enterprise-features/lab.md +2 -0
- package/docs/certification/02-enterprise-features/lesson.md +2 -0
- package/docs/certification/04-production-deployment/lab.md +2 -0
- package/docs/certification/04-production-deployment/lesson.md +2 -0
- package/docs/control-plane-migration.md +97 -0
- package/docs/dashboard-guide.md +6 -7
- package/docs/dev/release-checklist.md +9 -11
- package/docs/enterprise/integration-cookbook.md +2 -0
- package/docs/enterprise/migration.md +1 -1
- package/docs/enterprise/performance.md +3 -1
- package/docs/enterprise/sdk-guide.md +2 -0
- package/docs/enterprise/security.md +2 -0
- package/docs/plans/FORGE-AUTONOMOUS-QUEUE.md +2 -2
- package/docs/research-2026-07/OBSERVABILITY-TOOL-SPEC.md +1 -1
- package/docs/retrospectives/v7.5.15-honesty-audit.md +2 -2
- package/docs/test-scenarios/enterprise-scenarios.md +2 -2
- package/docs/v10/ARCHITECT-CUTS.md +9 -9
- package/docs/v10/BACKLOG.md +7 -7
- package/docs/v10/BOARD.md +25 -25
- package/docs/v10/CONTROL-PLANE.md +4 -4
- package/docs/v10/CP-ENTERPRISE-UI.md +270 -9
- package/docs/v10/D51-PHASE-B.md +1 -1
- package/docs/v10/DECISIONS.md +3 -3
- package/docs/v10/DEPS.md +10 -10
- package/docs/v10/FAILURE-CLASSES.md +66 -1
- package/docs/v10/LEGACY-REMOVAL.md +2 -2
- package/docs/v10/METRICS.md +6 -0
- package/docs/v10/SWARM.md +2 -2
- package/loki-ts/data/model-pricing.json +6 -6
- package/loki-ts/dist/cockpit.js +11 -11
- package/loki-ts/dist/loki.js +819 -809
- package/loki-ts/package.json +1 -1
- package/magic/core/design_tokens.py +1 -2
- package/magic/core/generator.py +2 -2
- package/magic/tokens/README.md +1 -3
- package/mcp/__init__.py +1 -1
- package/package.json +3 -8
- package/packages/control-plane/dist/server.js +17954 -5696
- package/packages/control-plane/drizzle/0004_cpe03_local_repos_actions.sql +17 -0
- package/packages/control-plane/drizzle/meta/0004_snapshot.json +542 -0
- package/packages/control-plane/drizzle/meta/_journal.json +9 -2
- package/packages/control-plane/package.json +4 -1
- package/packages/control-plane/src/db/schema.ts +19 -0
- package/packages/control-plane/src/server/app.ts +11 -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/legacy/routes-audit.ts +50 -0
- package/packages/control-plane/src/server/legacy/routes-control.ts +26 -0
- package/packages/control-plane/src/server/legacy/routes-memory.ts +30 -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 +201 -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/checkpoints.ts +171 -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 +71 -0
- package/packages/control-plane/src/server/routes/integrations.ts +123 -0
- package/packages/control-plane/src/server/routes/memory.ts +219 -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/session_control.ts +155 -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/spawn.ts +79 -0
- package/packages/control-plane/src/shipper/discover.ts +19 -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/references/magic-modules-patterns.md +2 -2
- package/skills/factory-operations.md +3 -3
- package/skills/magic-modules.md +1 -1
- package/skills/release-cadence.md +1 -1
- package/tools/audit-docs.py +1 -1
- package/tools/index-codebase.py +1 -1
- package/web-app/dist/assets/{AdminPage-BJSU5hY1.js → AdminPage-DqMnfZrH.js} +1 -1
- package/web-app/dist/assets/{Avatar-CSOSpw9c.js → Avatar-BEof29du.js} +1 -1
- package/web-app/dist/assets/{Badge-COr7vj0D.js → Badge-WD53OL5m.js} +1 -1
- package/web-app/dist/assets/{Button-DGoWFERp.js → Button-Rzk5flhU.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-CBpWkNXY.js → CockpitPage-Bqz3PCMh.js} +1 -1
- package/web-app/dist/assets/{ComparePage-BwT8vqrL.js → ComparePage-Byo2NzAZ.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-D7SicDDA.js → ErrorBoundary-BuE0kH3W.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-bx-5liG2.js → EvidenceReceiptPanel-CKFHOPBx.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-BAeeWLqY.js → GitHubIssuesPanel-Df_kTMqI.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-CssD8lGI.js → GitHubPRsPanel-CouvcKrZ.js} +1 -1
- package/web-app/dist/assets/{HomePage-SL5PT3m5.js → HomePage-dEpLzj0x.js} +2 -2
- package/web-app/dist/assets/{LoginPage-C8hFOZOM.js → LoginPage-6DVjUND5.js} +1 -1
- package/web-app/dist/assets/{MagicPage-xHbHkrPf.js → MagicPage-BDQ9MOfY.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-B_2CKFcC.js → MetricsPage-CXiJ04G_.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-CXwXMq35.js → NotFoundPage-BaXhZvyj.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-AaisMQI-.js → ProjectPage-BzE4V7Ti.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-Cej5ResX.js → ProjectsPage-ipf6TLMX.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-DKv4mn9H.js → SettingsPage-BDxkUKN6.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-Dkiyzne4.js → ShowcasePage-YQXD72SM.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-DaBLQ5dU.js → SystemSettingsPage-Dou7zHS0.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-B2yUr31k.js → TeamsPage-CQarfcYr.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-CfYiANhU.js → TemplatesPage-CPi1R8Xj.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-CMXoexLM.js → TerminalOutput-WiGhWdYD.js} +1 -1
- package/web-app/dist/assets/{activity-B0dzoZtY.js → activity-DOa7ejQA.js} +1 -1
- package/web-app/dist/assets/{bell-DFOTaSoo.js → bell-CX-VAlYN.js} +1 -1
- package/web-app/dist/assets/{bot-Dz-WSQyP.js → bot-EDXaZmHc.js} +1 -1
- package/web-app/dist/assets/{check-L1QIRueJ.js → check-CnBdKk3L.js} +1 -1
- package/web-app/dist/assets/{chevron-left-1TrnIEZS.js → chevron-left-BJ_ydRQW.js} +1 -1
- package/web-app/dist/assets/{circle-alert-8Bf6xEDx.js → circle-alert-tz8ufhc_.js} +1 -1
- package/web-app/dist/assets/{clock-DB8NE6P5.js → clock-DFBZPSsH.js} +1 -1
- package/web-app/dist/assets/{cloud-DUSL3wZS.js → cloud-uy3p_5Q7.js} +1 -1
- package/web-app/dist/assets/{code-xml-DbjryWNl.js → code-xml-3CbYD9YL.js} +1 -1
- package/web-app/dist/assets/{database-DrezrMSs.js → database-YiNBXc9c.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-CGbaLMzR.js → dollar-sign-D0wBa4Cv.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-Dfypa-r4.js → file-code-corner-DDdZVZ5v.js} +1 -1
- package/web-app/dist/assets/{file-plus-CzV5L2Gd.js → file-plus-D09LTBqt.js} +1 -1
- package/web-app/dist/assets/{globe-BoYjTwPQ.js → globe-BirTPwff.js} +1 -1
- package/web-app/dist/assets/{hammer-WO-HuiyJ.js → hammer-DaE5qgnd.js} +1 -1
- package/web-app/dist/assets/{index-CjIOUjnd.js → index-D8t7KnQf.js} +3 -3
- package/web-app/dist/assets/{layers-Bn6jtKNk.js → layers-DVVrckRY.js} +1 -1
- package/web-app/dist/assets/{loader-circle-gERWRnmW.js → loader-circle-2-nMGmcb.js} +1 -1
- package/web-app/dist/assets/{lock-BIiMLJq8.js → lock-D0Xj43pC.js} +1 -1
- package/web-app/dist/assets/{package-dLvDSVys.js → package-BB7S3egm.js} +1 -1
- package/web-app/dist/assets/{plus-BHM3ZLhZ.js → plus-BLhoMrn3.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-BAp_alv6.js → refresh-cw-DMc2uPLw.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-CBhwq_MG.js → rotate-ccw-DM3IP9x7.js} +1 -1
- package/web-app/dist/assets/{scroll-text-DoS-6dfU.js → scroll-text-lH2uW7rB.js} +1 -1
- package/web-app/dist/assets/{server-5JgdDWQO.js → server-BkerZcdR.js} +1 -1
- package/web-app/dist/assets/{shield-alert-DjmlLT34.js → shield-alert-B8ohcUfu.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-BtiEXl3p.js → thumbs-up-CTIxLzPD.js} +1 -1
- package/web-app/dist/assets/{trash-2-ClsWU_Vg.js → trash-2-C3zIpsH5.js} +1 -1
- package/web-app/dist/assets/{trending-down-DPS4M7_X.js → trending-down-BTT7wV4Y.js} +1 -1
- package/web-app/dist/assets/{trending-up-D6OJ38-V.js → trending-up-BzHdCDvF.js} +1 -1
- package/web-app/dist/assets/{upload-DOCzLUpF.js → upload-HxR34-A8.js} +1 -1
- package/web-app/dist/assets/{usePolling-CljQTWj_.js → usePolling-DDj6Fupy.js} +1 -1
- package/web-app/dist/assets/{user-C0G82Weu.js → user-qH5y9yNy.js} +1 -1
- package/web-app/dist/index.html +1 -1
- package/dashboard/static/assets/mermaid.min.js +0 -2030
- package/dashboard/static/cost.html +0 -344
- package/dashboard/static/favicon.svg +0 -5
- package/dashboard/static/index.html +0 -15577
- package/dashboard/static/proofs.html +0 -194
- package/dashboard/static/start.html +0 -131
- package/dashboard/static/trust.html +0 -325
- package/packages/control-plane/src/server/actions.ts +0 -60
- package/packages/control-plane/ui/dist/assets/index-DHDI_dHM.css +0 -1
- package/packages/control-plane/ui/dist/assets/index-DaQr0MPS.js +0 -129
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// CPE24-P3: memory, context, focus and tasks, ported from dashboard/server.py (list_tasks, set_focus, list_episodes, list_patterns, list_skills,
|
|
2
|
+
// get_token_economics, get_context). Same .loki/ flat-file sources; the legacy SQLite memory backend and the SQL task store have no CP equivalent, so
|
|
3
|
+
// those paths are not ported (see docs/v10/CP-ENTERPRISE-UI.md). Unmeasured values are null, never a fabricated zero.
|
|
4
|
+
// Every path read is derived from the focused project dir plus a fixed suffix; a request value is only ever compared to an id, never joined into a path.
|
|
5
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
6
|
+
import type { Dirent } from "node:fs";
|
|
7
|
+
import type { Context } from "hono";
|
|
8
|
+
import { join, sep } from "node:path";
|
|
9
|
+
import { isLoopbackHost, peerIsLoopback } from "../auth.ts";
|
|
10
|
+
import { probeAllowed, type RouteCtx } from "./index.ts";
|
|
11
|
+
import { lokiDirOf, readJson } from "./cost_ledger.ts";
|
|
12
|
+
import { registryFile } from "./fleet.ts";
|
|
13
|
+
|
|
14
|
+
type Rec = Record<string, unknown>;
|
|
15
|
+
const isObj = (v: unknown): v is Rec => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
16
|
+
const nonEmpty = (v: unknown) => !(v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0) || (isObj(v) && Object.keys(v).length === 0));
|
|
17
|
+
|
|
18
|
+
/** The focused project dir (POST /v1/focus); null means the server's own checkout. Process-wide, like the legacy _active_project_dir. */
|
|
19
|
+
export const focus: { dir: string | null } = { dir: null };
|
|
20
|
+
export const lokiFor = (repoDir: string) => lokiDirOf(focus.dir ?? repoDir);
|
|
21
|
+
|
|
22
|
+
/** State-changing guard: loopback peer and Host, JSON content type, and a browser Origin (when sent) equal to the Host. */
|
|
23
|
+
export function mutationAllowed(c: Context): boolean {
|
|
24
|
+
if (!peerIsLoopback(c) || !isLoopbackHost(c.req.header("host"))) return false;
|
|
25
|
+
if (!(c.req.header("content-type") ?? "").toLowerCase().startsWith("application/json")) return false;
|
|
26
|
+
const origin = c.req.header("origin");
|
|
27
|
+
if (origin === undefined) return true;
|
|
28
|
+
try { return new URL(origin).host.toLowerCase() === (c.req.header("host") ?? "").toLowerCase(); } catch { return false; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Every .json file under dir (sorted), never following symlinks, so nothing resolves outside dir. */
|
|
32
|
+
function jsonFiles(dir: string, cap = Infinity): string[] {
|
|
33
|
+
const out: string[] = [];
|
|
34
|
+
const walk = (d: string) => {
|
|
35
|
+
let ents: Dirent[] = [];
|
|
36
|
+
try { ents = readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
37
|
+
for (const e of ents.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
38
|
+
if (out.length >= cap) return;
|
|
39
|
+
const p = join(d, e.name);
|
|
40
|
+
if (e.isDirectory()) walk(p); else if (e.isFile() && e.name.endsWith(".json")) out.push(p);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
walk(dir);
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function focusInfo(repoDir: string) { return { project_dir: focus.dir, loki_dir: lokiFor(repoDir) }; }
|
|
48
|
+
|
|
49
|
+
/** A focus target must be this checkout or a project in the machine registry, and hold a .loki dir; `..` and relative paths are refused. */
|
|
50
|
+
export function setFocus(repoDir: string, raw: unknown, registry = registryFile()): { ok: true } | { ok: false; error: string } {
|
|
51
|
+
const s = typeof raw === "string" ? raw.trim() : "";
|
|
52
|
+
if (!s) return { ok: false, error: "project_dir must not be empty" };
|
|
53
|
+
if (!s.startsWith("/") || s.split(/[\\/]/).includes("..")) return { ok: false, error: "project_dir must be an absolute path without .. segments" };
|
|
54
|
+
let real = "";
|
|
55
|
+
try { real = realpathSync(s); if (!statSync(real).isDirectory()) throw new Error("not a dir"); } catch { return { ok: false, error: `Directory does not exist: ${s}` }; }
|
|
56
|
+
if (!existsSync(join(real, ".loki")) || !statSync(join(real, ".loki")).isDirectory()) return { ok: false, error: `Directory does not contain a .loki/ subdirectory: ${s}` };
|
|
57
|
+
const allowed = new Set<string>();
|
|
58
|
+
try { allowed.add(realpathSync(repoDir)); } catch { /* unreadable */ }
|
|
59
|
+
const reg = readJson(registry);
|
|
60
|
+
if (isObj(reg) && isObj(reg.projects)) for (const p of Object.values(reg.projects)) {
|
|
61
|
+
if (isObj(p) && typeof p.path === "string") { try { allowed.add(realpathSync(p.path)); } catch { /* gone */ } }
|
|
62
|
+
}
|
|
63
|
+
if (!allowed.has(real)) return { ok: false, error: "project_dir is not this checkout or a registered project" };
|
|
64
|
+
focus.dir = real;
|
|
65
|
+
return { ok: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---- tasks (dashboard-state.json groups plus .loki/queue/*.json), deduped with the legacy terminal-wins rule ----
|
|
69
|
+
const GROUPS: [string, string][] = [["pending", "pending"], ["inProgress", "in_progress"], ["review", "review"], ["completed", "done"], ["failed", "done"]];
|
|
70
|
+
const QUEUES: [string, string][] = [["pending.json", "pending"], ["in-progress.json", "in_progress"], ["completed.json", "done"], ["failed.json", "done"], ["dead-letter.json", "done"]];
|
|
71
|
+
const RANK: Record<string, number> = { done: 3, in_progress: 2, review: 1, pending: 0 };
|
|
72
|
+
|
|
73
|
+
export function tasksList(loki: string, status?: string): Rec[] {
|
|
74
|
+
const all: Rec[] = [];
|
|
75
|
+
const st = readJson(join(loki, "dashboard-state.json"));
|
|
76
|
+
const groups = isObj(st) && isObj(st.tasks) ? st.tasks : {};
|
|
77
|
+
for (const [key, mapped] of GROUPS) {
|
|
78
|
+
const g = groups[key];
|
|
79
|
+
if (!Array.isArray(g)) continue;
|
|
80
|
+
g.forEach((t, i) => {
|
|
81
|
+
if (!isObj(t)) return;
|
|
82
|
+
const payload = isObj(t.payload) ? t.payload : {};
|
|
83
|
+
const e: Rec = { id: t.id ?? `${key}-${i}`, title: t.title ?? payload.action ?? t.type ?? "Task", description: t.description ?? payload.description ?? "", status: mapped, priority: t.priority ?? payload.priority ?? "medium", type: t.type ?? "task", position: i };
|
|
84
|
+
for (const f of ["acceptance_criteria", "notes", "logs", "user_story", "project", "source", "specification", "provider", "startedAt", "full_content", "completedAt", "phase", "exitCode"]) if (nonEmpty(t[f])) e[f] = t[f];
|
|
85
|
+
if (t.exitCode === 0) e.exitCode = 0;
|
|
86
|
+
if (key === "completed" || key === "failed") e._terminal_outcome = key;
|
|
87
|
+
all.push(e);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
for (const [file, qs] of QUEUES) {
|
|
91
|
+
const raw = readJson(join(loki, "queue", file));
|
|
92
|
+
const items = isObj(raw) ? raw.tasks : raw;
|
|
93
|
+
if (!Array.isArray(items)) continue;
|
|
94
|
+
const term = file === "completed.json" ? "completed" : file === "failed.json" || file === "dead-letter.json" ? "failed" : null;
|
|
95
|
+
items.forEach((it, i) => {
|
|
96
|
+
if (!isObj(it)) return;
|
|
97
|
+
const id = it.id ?? `q-${qs}-${i}`;
|
|
98
|
+
if (term === null && all.some((t) => t.id === id)) return;
|
|
99
|
+
const e: Rec = { id, title: it.title ?? it.action ?? "Task", description: it.description ?? "", status: qs, priority: it.priority ?? "medium", type: it.type ?? "task", position: i };
|
|
100
|
+
for (const f of ["acceptance_criteria", "user_story", "project", "source", "provider", "startedAt", "completedAt", "phase"]) if (nonEmpty(it[f])) e[f] = it[f];
|
|
101
|
+
for (const f of ["notes", "logs"]) if (Array.isArray(it[f])) e[f] = it[f];
|
|
102
|
+
if (Number.isInteger(it.exitCode)) e.exitCode = it.exitCode;
|
|
103
|
+
if (term) e._terminal_outcome = term;
|
|
104
|
+
all.push(e);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
const auth = (t: Rec) => [RANK[t.status as string] ?? -1, t._terminal_outcome === "failed" ? 1 : 0] as const;
|
|
108
|
+
const better = (a: Rec, b: Rec) => { const x = auth(a), y = auth(b); return x[0] !== y[0] ? x[0] > y[0] : x[1] > y[1]; };
|
|
109
|
+
const byId = new Map<unknown, Rec>(), order: (Rec | { ref: unknown })[] = [];
|
|
110
|
+
for (const t of all) {
|
|
111
|
+
if (t.id === undefined || t.id === null) { order.push(t); continue; }
|
|
112
|
+
const prev = byId.get(t.id);
|
|
113
|
+
if (!prev) { byId.set(t.id, t); order.push({ ref: t.id }); } else if (better(t, prev)) byId.set(t.id, t);
|
|
114
|
+
}
|
|
115
|
+
const out = order.map((o) => ("ref" in o ? (byId.get(o.ref) as Rec) : o));
|
|
116
|
+
for (const t of out) {
|
|
117
|
+
if (t.type === "iteration" && !t.description) {
|
|
118
|
+
const ec = t.exitCode, failed = t._terminal_outcome === "failed";
|
|
119
|
+
const o = Number.isInteger(ec) && ec === 0 && !failed ? "completed cleanly (exit 0)" : Number.isInteger(ec) && ec !== 0 ? `failed (exit ${ec})` : failed ? "failed"
|
|
120
|
+
: t._terminal_outcome === "completed" ? "completed" : t.status === "done" ? "finished" : null;
|
|
121
|
+
if (o) t.description = `Iteration ${o}${t.provider ? `, built by ${t.provider}` : ""}.`;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return status ? out.filter((t) => t.status === status) : out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- memory (.loki/memory JSON store) ----
|
|
128
|
+
const memDir = (loki: string, sub: string) => join(loki, "memory", sub);
|
|
129
|
+
|
|
130
|
+
export function memoryEpisodes(loki: string, limit = 50): Rec[] {
|
|
131
|
+
const eps = jsonFiles(memDir(loki, "episodic")).map(readJson).filter(isObj);
|
|
132
|
+
eps.sort((a, b) => String(b.timestamp ?? "").localeCompare(String(a.timestamp ?? "")));
|
|
133
|
+
return eps.slice(0, limit);
|
|
134
|
+
}
|
|
135
|
+
export function memoryEpisode(loki: string, id: string): Rec | null {
|
|
136
|
+
for (const f of jsonFiles(memDir(loki, "episodic"))) {
|
|
137
|
+
const d = readJson(f);
|
|
138
|
+
if (isObj(d) && (d.id === id || f.split(sep).pop() === `${id}.json`)) return d;
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
export function memoryPatterns(loki: string): Rec[] {
|
|
143
|
+
const d = readJson(join(memDir(loki, "semantic"), "patterns.json"));
|
|
144
|
+
const list = Array.isArray(d) ? d : isObj(d) && Array.isArray(d.patterns) ? d.patterns : [];
|
|
145
|
+
return list.filter(isObj);
|
|
146
|
+
}
|
|
147
|
+
export const memorySkills = (loki: string): Rec[] => jsonFiles(memDir(loki, "skills")).map(readJson).filter(isObj);
|
|
148
|
+
|
|
149
|
+
export function memoryEconomics(loki: string) {
|
|
150
|
+
const rawV = readJson(join(loki, "memory", "token_economics.json"));
|
|
151
|
+
const raw: Rec = isObj(rawV) ? rawV : {};
|
|
152
|
+
const m = isObj(raw.metrics) ? raw.metrics : {};
|
|
153
|
+
const n = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? Math.trunc(v) : 0);
|
|
154
|
+
const hits = n(m.cache_hits), misses = n(m.cache_misses), total = hits + misses;
|
|
155
|
+
const recorded = Object.keys(m).length > 0;
|
|
156
|
+
const disc = recorded ? n(m.discovery_tokens) : null, read = recorded ? n(m.read_tokens) : null;
|
|
157
|
+
const cands: Rec[] = [];
|
|
158
|
+
for (const kind of ["episodic", "semantic"]) {
|
|
159
|
+
for (const f of jsonFiles(memDir(loki, kind), 300)) {
|
|
160
|
+
const d = readJson(f);
|
|
161
|
+
if (!isObj(d)) continue;
|
|
162
|
+
const ctx = isObj(d.context) ? d.context : {};
|
|
163
|
+
cands.push({ id: d.id ?? "", kind, access_count: n(d.access_count), importance: Number(d.importance) || 0, summary: String(d.summary || d.pattern || ctx.goal || "").slice(0, 160) });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
cands.sort((a, b) => (b.access_count as number) - (a.access_count as number) || (b.importance as number) - (a.importance as number));
|
|
167
|
+
return {
|
|
168
|
+
session_id: raw.session_id ?? null, discovery_tokens: disc, read_tokens: read, total_tokens: recorded ? (disc as number) + (read as number) : null,
|
|
169
|
+
cache_hits: hits, cache_misses: misses, hit_rate: total > 0 ? Math.round((hits / total) * 1e4) / 1e4 : null, ratio: raw.ratio ?? null,
|
|
170
|
+
savings_percent: raw.savings_percent ?? null, top_patterns: cands.slice(0, 10).filter((p) => (p.access_count as number) > 0),
|
|
171
|
+
discoveryTokens: disc, readTokens: read, savingsPercent: raw.savings_percent ?? null, raw,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** .loki/context/tracking.json; absent means the tracker never ran, so every reading is null. A file that exists but is unreadable is an error, not a zero. */
|
|
176
|
+
export function contextTracking(loki: string): { status: 200 | 500; body: unknown } {
|
|
177
|
+
const f = join(loki, "context", "tracking.json");
|
|
178
|
+
if (!existsSync(f)) {
|
|
179
|
+
return { status: 200, body: {
|
|
180
|
+
session_id: null, updated_at: null,
|
|
181
|
+
current: { input_tokens: null, output_tokens: null, cache_read_tokens: null, cache_creation_tokens: null, total_tokens: null, context_window_pct: null, estimated_cost_usd: null },
|
|
182
|
+
compactions: [], per_iteration: [],
|
|
183
|
+
totals: { total_input: null, total_output: null, total_cost_usd: null, compaction_count: null, iterations_tracked: null },
|
|
184
|
+
} };
|
|
185
|
+
}
|
|
186
|
+
try { return { status: 200, body: JSON.parse(readFileSync(f, "utf8")) }; } catch { return { status: 500, body: { error: "Failed to read context tracking data" } }; }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function mount(ctx: RouteCtx): void {
|
|
190
|
+
const deny = { error: "loopback only without a token" };
|
|
191
|
+
const read = (path: string, fn: (c: Context, loki: string) => Response) =>
|
|
192
|
+
ctx.app.get(path, (c) => (probeAllowed(ctx, c) ? fn(c, lokiFor(ctx.repoDir)) : c.json(deny, 403)));
|
|
193
|
+
read("/v1/tasks", (c, l) => {
|
|
194
|
+
if (c.req.query("project_id") !== undefined) return c.json({ error: "project_id filter is not supported by the Control Plane" }, 400);
|
|
195
|
+
return c.json(tasksList(l, c.req.query("status")));
|
|
196
|
+
});
|
|
197
|
+
read("/v1/memory/episodes", (c, l) => {
|
|
198
|
+
const lim = c.req.query("limit") === undefined ? 50 : Number(c.req.query("limit"));
|
|
199
|
+
return Number.isInteger(lim) && lim >= 1 && lim <= 1000 ? c.json(memoryEpisodes(l, lim)) : c.json({ error: "limit must be 1..1000" }, 422);
|
|
200
|
+
});
|
|
201
|
+
read("/v1/memory/episodes/:id", (c, l) => { const e = memoryEpisode(l, c.req.param("id")); return e ? c.json(e) : c.json({ error: "Episode not found" }, 404); });
|
|
202
|
+
read("/v1/memory/patterns", (c, l) => c.json(memoryPatterns(l)));
|
|
203
|
+
read("/v1/memory/patterns/:id", (c, l) => { const p = memoryPatterns(l).find((x) => x.id === c.req.param("id")); return p ? c.json(p) : c.json({ error: "Pattern not found" }, 404); });
|
|
204
|
+
read("/v1/memory/skills", (c, l) => c.json(memorySkills(l)));
|
|
205
|
+
read("/v1/memory/economics", (c, l) => c.json(memoryEconomics(l)));
|
|
206
|
+
read("/v1/context", (c, l) => { const r = contextTracking(l); return c.json(r.body, r.status); });
|
|
207
|
+
read("/v1/focus", (c) => c.json(focusInfo(ctx.repoDir)));
|
|
208
|
+
ctx.act.post("/v1/focus", async (c) => {
|
|
209
|
+
if (!mutationAllowed(c)) return c.json({ error: "loopback JSON requests only" }, 403);
|
|
210
|
+
const body = await c.req.json().catch(() => null);
|
|
211
|
+
const r = setFocus(ctx.repoDir, isObj(body) ? body.project_dir : undefined);
|
|
212
|
+
return r.ok ? c.json(focusInfo(ctx.repoDir)) : c.json({ error: r.error }, 400);
|
|
213
|
+
});
|
|
214
|
+
ctx.act.delete("/v1/focus", (c) => {
|
|
215
|
+
if (!mutationAllowed(c)) return c.json({ error: "loopback JSON requests only" }, 403);
|
|
216
|
+
focus.dir = null;
|
|
217
|
+
return c.json(focusInfo(ctx.repoDir));
|
|
218
|
+
});
|
|
219
|
+
}
|
|
@@ -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
|
+
}
|