loki-mode 10.11.0 → 11.0.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/.github/actions/issue-to-pr/action.yml +17 -0
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/lib/backlog.py +1 -1
- package/autonomy/lib/workspace.py +1 -1
- package/autonomy/loki +5 -8
- package/bin/loki +21 -18
- package/dashboard/__init__.py +1 -1
- package/docs/AGENT-CHANGE-RECEIPT.md +47 -0
- package/docs/CLI-REFERENCE.md +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/docs/v10/BOARD.md +3 -3
- package/docs/v10/DEPS.md +3 -0
- package/docs/v10/FAILURE-CLASSES.md +25 -0
- package/docs/v10/GUIDE.md +17 -22
- package/docs/v10/PROGRESS.md +13 -0
- package/docs/v10/RELEASE-11.md +75 -0
- package/docs/v11/C1-multi-user-sso.md +30 -0
- package/docs/v11/C10-the-8090-teardown.md +28 -0
- package/docs/v11/C11-mcp-modern.md +16 -0
- package/docs/v11/C2-hosted-runner.md +30 -0
- package/docs/v11/C3-reproducible-environments.md +30 -0
- package/docs/v11/C4-bitbucket-azure.md +29 -0
- package/docs/v11/C5-open-local-models.md +29 -0
- package/docs/v11/C6-secrets-handling.md +31 -0
- package/docs/v11/C7-windows.md +30 -0
- package/docs/v11/C8-public-benchmark.md +30 -0
- package/docs/v11/C9-opt-in-telemetry.md +33 -0
- package/docs/v11/README.md +32 -0
- package/loki-ts/dist/loki.js +691 -676
- package/mcp/__init__.py +1 -1
- package/package.json +3 -2
- package/packages/control-plane/dist/ask-tools-server.js +41169 -0
- package/packages/control-plane/dist/server.js +2343 -1238
- package/packages/control-plane/package.json +4 -2
- package/packages/control-plane/src/ask/invoke.ts +42 -0
- package/packages/control-plane/src/ask/policy.ts +65 -0
- package/packages/control-plane/src/ask/prompt.ts +38 -0
- package/packages/control-plane/src/ask/store.ts +88 -0
- package/packages/control-plane/src/ask/tools.ts +111 -0
- package/packages/control-plane/src/ask/tools_server.ts +46 -0
- package/packages/control-plane/src/ask/worker.ts +136 -0
- package/packages/control-plane/src/db/fixture-cleanup.ts +24 -10
- package/packages/control-plane/src/server/app.ts +13 -6
- package/packages/control-plane/src/server/ingest.ts +6 -1
- package/packages/control-plane/src/server/reconcile.ts +33 -0
- package/packages/control-plane/src/server/repos.ts +37 -3
- package/packages/control-plane/src/server/routes/artifacts.ts +3 -3
- package/packages/control-plane/src/server/routes/ask.ts +139 -0
- package/packages/control-plane/src/server/routes/cost.ts +19 -0
- package/packages/control-plane/src/server/routes/doctor.ts +67 -0
- package/packages/control-plane/src/server/routes/index.ts +7 -3
- package/packages/control-plane/src/server/routes/stream.ts +22 -11
- package/packages/control-plane/src/server/runs.ts +27 -0
- package/packages/control-plane/src/server/spawn.ts +19 -6
- package/packages/control-plane/ui/dist/assets/index-CxZWgwie.css +1 -0
- package/packages/control-plane/ui/dist/assets/index-DcBJXjCJ.js +190 -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-qTfCHm93.js → AdminPage-Bmmmm2To.js} +1 -1
- package/web-app/dist/assets/{Avatar-V5Wfzf34.js → Avatar-63_TXRKt.js} +1 -1
- package/web-app/dist/assets/{Badge-B7fNko8Z.js → Badge-x_TAt-df.js} +1 -1
- package/web-app/dist/assets/{Button-C_3eInAe.js → Button-AXhA9K5O.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-Ir2wag2q.js → CockpitPage-DTLCv1h8.js} +1 -1
- package/web-app/dist/assets/{ComparePage-qbC63b0U.js → ComparePage-BYcilVjG.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-BTEqFTCi.js → ErrorBoundary-BLqm3BId.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-CcICm1gj.js → EvidenceReceiptPanel-BceXzz60.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-CXCWYQqX.js → GitHubIssuesPanel-D8c4JUx5.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-DmzhZYpS.js → GitHubPRsPanel-DUrmma6t.js} +1 -1
- package/web-app/dist/assets/{HomePage-BRPspBy7.js → HomePage-DxmEUjq_.js} +2 -2
- package/web-app/dist/assets/{LoginPage-C1Nb7Vf9.js → LoginPage-Bgct3T-6.js} +1 -1
- package/web-app/dist/assets/{MagicPage-CeXc60VN.js → MagicPage-Bh7ejcHn.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-MuDDdqNu.js → MetricsPage-BqPEIiec.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-8iOaj85A.js → NotFoundPage-B2DSNOjE.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-DOKcPhxr.js → ProjectPage-Bl0_uWYD.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-CNKaqvuO.js → ProjectsPage-D6ZrFHCy.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-uZT6dOT1.js → SettingsPage-DWE92j7N.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-CHzrwMZ3.js → ShowcasePage-Bi1m8Y6y.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-DMHj5Xub.js → SystemSettingsPage-DDCPZqns.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-IGY1oZ2o.js → TeamsPage-C2PmN2Ok.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-Byxc8XyZ.js → TemplatesPage-CpTFFqSz.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-CRamR4sR.js → TerminalOutput-CNFCZjx4.js} +1 -1
- package/web-app/dist/assets/{activity-DQfL1H8b.js → activity-5uiPn7vG.js} +1 -1
- package/web-app/dist/assets/{bell-CVksERF2.js → bell-C-xlbVax.js} +1 -1
- package/web-app/dist/assets/{bot-jSx_C-35.js → bot-8hOixr10.js} +1 -1
- package/web-app/dist/assets/{check-o3bOhMvE.js → check-B59pXlrD.js} +1 -1
- package/web-app/dist/assets/{chevron-left-DBhM8Y2E.js → chevron-left-DfvDsieV.js} +1 -1
- package/web-app/dist/assets/{circle-alert-vCzO_mB8.js → circle-alert-DMPidduh.js} +1 -1
- package/web-app/dist/assets/{clock-Bj125ZaJ.js → clock-XdPeED5-.js} +1 -1
- package/web-app/dist/assets/{cloud-DpLr6uQJ.js → cloud-CEM1vME-.js} +1 -1
- package/web-app/dist/assets/{code-xml-VuYrHYvT.js → code-xml-BcQSgNZ8.js} +1 -1
- package/web-app/dist/assets/{database-DBmPbwaG.js → database-P9maBI33.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-DEajFwH1.js → dollar-sign-BrvK3fRq.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-BidTyjV_.js → file-code-corner-DAO2Qi8-.js} +1 -1
- package/web-app/dist/assets/{file-plus-DJ4dSvxw.js → file-plus-DBAP9mx-.js} +1 -1
- package/web-app/dist/assets/{globe-BnGEwsOq.js → globe-DxSEu4Ui.js} +1 -1
- package/web-app/dist/assets/{hammer-KCPviXRE.js → hammer-CY7jlobV.js} +1 -1
- package/web-app/dist/assets/{index-CdK6NQG3.js → index-Dhcimr1u.js} +3 -3
- package/web-app/dist/assets/{layers-7G4hsaDk.js → layers-CsOybYaX.js} +1 -1
- package/web-app/dist/assets/{loader-circle-CWVxvRmJ.js → loader-circle-CP-uZ3fl.js} +1 -1
- package/web-app/dist/assets/{lock-BEH3dNM0.js → lock-C80-6kWZ.js} +1 -1
- package/web-app/dist/assets/{package-DvijfmPC.js → package-B2Opl41-.js} +1 -1
- package/web-app/dist/assets/{plus-CEwJzXGx.js → plus-CUNzK4gi.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-Ct7rMsNS.js → refresh-cw-C4rz1BZ5.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-CoTyya_B.js → rotate-ccw-VZpqP-O-.js} +1 -1
- package/web-app/dist/assets/{scroll-text-CbsOnRXr.js → scroll-text-CpyY8-2b.js} +1 -1
- package/web-app/dist/assets/{server-MUPVBbag.js → server-VlQUsZyS.js} +1 -1
- package/web-app/dist/assets/{shield-alert-CWwdqRUv.js → shield-alert-CiBUSY3R.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-CPVKKyLS.js → thumbs-up-Cir_mvr6.js} +1 -1
- package/web-app/dist/assets/{trash-2-BqzCzljk.js → trash-2-D5zmzpia.js} +1 -1
- package/web-app/dist/assets/{trending-down-DAo5VuPc.js → trending-down-DUNUrvDV.js} +1 -1
- package/web-app/dist/assets/{trending-up-BCUggli9.js → trending-up-BPbDp6kc.js} +1 -1
- package/web-app/dist/assets/{upload-iQijlCY2.js → upload-DRshJXd5.js} +1 -1
- package/web-app/dist/assets/{usePolling-B6kojZR_.js → usePolling-Bo4k0szY.js} +1 -1
- package/web-app/dist/assets/{user-DR3XkyHZ.js → user-nh6_54pL.js} +1 -1
- package/web-app/dist/index.html +1 -1
- package/autonomy/lib/engine10-legacy-notice.txt +0 -3
- package/packages/control-plane/ui/dist/assets/index-BinL6h3i.css +0 -1
- package/packages/control-plane/ui/dist/assets/index-D27veKDf.js +0 -152
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, statSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { join, resolve, sep } from "node:path";
|
|
3
3
|
import { Hono, type Context } from "hono";
|
|
4
4
|
import { cleanupLeakedFixtures } from "../db/fixture-cleanup.ts";
|
|
@@ -10,7 +10,8 @@ import { hostGuard, isLoopbackHost, peerIsLoopback, tokenGuard } from "./auth.ts
|
|
|
10
10
|
import { backfill } from "../shipper/backfill.ts";
|
|
11
11
|
import { removeRun } from "../db/prune.ts";
|
|
12
12
|
import type { spawnStart } from "./spawn.ts";
|
|
13
|
-
import {
|
|
13
|
+
import { reconcileDeadRuns } from "./reconcile.ts";
|
|
14
|
+
import { syncLocalRepos, type GhRunner } from "./repos.ts";
|
|
14
15
|
import { registerRoutes } from "./routes/index.ts";
|
|
15
16
|
import { legacyShim } from "./legacy/shim.ts";
|
|
16
17
|
|
|
@@ -20,11 +21,14 @@ const MAX_BODY = 1_000_000;
|
|
|
20
21
|
const defaultUiDir = () => [join(import.meta.dir, "../../ui/dist"), join(import.meta.dir, "../ui/dist")].find((d) => existsSync(join(d, "index.html")));
|
|
21
22
|
|
|
22
23
|
/** dbPath ":memory:" for tests. Migrations run here, so /ready is true as soon as this returns. uiDir overrides the built-UI location. */
|
|
23
|
-
export function createApp(opts: { dbPath: string; uiDir?: string; answerDir?: string; token?: string; loopbackOnly?: boolean; repoDir?: string; startBin?: string; spawnImpl?: typeof spawnStart }) {
|
|
24
|
+
export function createApp(opts: { dbPath: string; uiDir?: string; answerDir?: string; token?: string; loopbackOnly?: boolean; repoDir?: string; startBin?: string; spawnImpl?: typeof spawnStart; ghImpl?: GhRunner }) {
|
|
24
25
|
const uiDir = opts.uiDir ?? defaultUiDir();
|
|
25
26
|
const { db, sqlite } = openDb(opts.dbPath);
|
|
26
27
|
cleanupLeakedFixtures(sqlite);
|
|
27
28
|
recomputeLegacy(db);
|
|
29
|
+
reconcileDeadRuns(db);
|
|
30
|
+
const reconciler = setInterval(() => { try { reconcileDeadRuns(db); } catch { /* best effort */ } }, 60_000);
|
|
31
|
+
reconciler.unref();
|
|
28
32
|
let ready = true;
|
|
29
33
|
const answerDir = opts.answerDir ?? defaultAnswerDir();
|
|
30
34
|
const app = new Hono();
|
|
@@ -37,7 +41,10 @@ export function createApp(opts: { dbPath: string; uiDir?: string; answerDir?: st
|
|
|
37
41
|
await next();
|
|
38
42
|
});
|
|
39
43
|
|
|
40
|
-
|
|
44
|
+
// version = what this process started as; installed_version = what is on disk now. They differ after an upgrade while the CP kept running.
|
|
45
|
+
const readInstalled = (): string => { for (const f of [process.env.LOKI_VERSION_FILE, join(import.meta.dir, "../../../../VERSION")]) { try { if (f) return readFileSync(f, "utf8").trim() || "unknown"; } catch { /* next */ } } return "unknown"; };
|
|
46
|
+
const startVersion = process.env.LOKI_CONTROL_VERSION || readInstalled();
|
|
47
|
+
app.get("/health", (c) => c.json({ service: "loki-control", pid: process.pid, install_path: import.meta.dir, version: startVersion, installed_version: readInstalled() }));
|
|
41
48
|
app.get("/ready", (c) => {
|
|
42
49
|
try { sqlite.query("select 1").get(); } catch { ready = false; }
|
|
43
50
|
return ready ? c.json({ ready: true }) : c.json({ ready: false }, 503);
|
|
@@ -108,7 +115,7 @@ export function createApp(opts: { dbPath: string; uiDir?: string; answerDir?: st
|
|
|
108
115
|
});
|
|
109
116
|
// Local discovery fills local_repos (never /v1/ingest). GET /v1/repos (names only, loopback guard) is mounted by routes/index.ts.
|
|
110
117
|
syncLocalRepos(db, repoDir);
|
|
111
|
-
registerRoutes({ app, act, db, repoDir, token: opts.token, peerIsLoopback, local, startBin: opts.startBin, spawnImpl: opts.spawnImpl, answerDir });
|
|
118
|
+
registerRoutes({ app, act, db, repoDir, token: opts.token, peerIsLoopback, local, startBin: opts.startBin, spawnImpl: opts.spawnImpl, ghImpl: opts.ghImpl, answerDir });
|
|
112
119
|
// :id is `source:run` (run ids never contain a colon)
|
|
113
120
|
app.get("/v1/runs/:id", (c) => {
|
|
114
121
|
const id = c.req.param("id");
|
|
@@ -131,5 +138,5 @@ export function createApp(opts: { dbPath: string; uiDir?: string; answerDir?: st
|
|
|
131
138
|
return new Response(Bun.file(join(root, "index.html")));
|
|
132
139
|
});
|
|
133
140
|
|
|
134
|
-
return { app, db, close: () => sqlite.close() };
|
|
141
|
+
return { app, db, close: () => { clearInterval(reconciler); sqlite.close(); } };
|
|
135
142
|
}
|
|
@@ -4,7 +4,8 @@ import { validateEnvelope } from "../../../../loki-ts/src/engine10/events.ts";
|
|
|
4
4
|
import type { EventEnvelope } from "../../../../loki-ts/src/engine10/types.ts";
|
|
5
5
|
import { redactSecrets } from "../../../../loki-ts/src/util/redact.ts";
|
|
6
6
|
import type { Db } from "../db/migrate.ts";
|
|
7
|
-
import { events, runs, sources } from "../db/schema.ts";
|
|
7
|
+
import { events, localRepos, runs, sources } from "../db/schema.ts";
|
|
8
|
+
import { TEST_RUN_ID, tempRoots, underTemp } from "../db/fixture-cleanup.ts";
|
|
8
9
|
import { rebuildRun } from "./runs.ts";
|
|
9
10
|
|
|
10
11
|
export const MAX_EVENTS = 500;
|
|
@@ -20,6 +21,10 @@ export function ingest(db: Db, body: unknown): IngestResult {
|
|
|
20
21
|
const sourceId = b?.source ?? b?.source_id;
|
|
21
22
|
if (typeof sourceId !== "string" || !sourceId || typeof b?.run_id !== "string" || !b.run_id) return { status: 400, body: { error: "source and run_id required" } };
|
|
22
23
|
const runId = b.run_id;
|
|
24
|
+
// A3b: test suites once leaked fixture runs into a real control.db. A source rooted under a temp dir, or a test run-id prefix, is never real history.
|
|
25
|
+
if (TEST_RUN_ID.test(runId)) return { status: 400, body: { error: `run_id ${runId} looks like a test fixture (e37-, e10-sig, e10-sg); refusing to ingest it` } };
|
|
26
|
+
const repoPath = db.select({ p: localRepos.realpath }).from(localRepos).where(eq(localRepos.sourceId, sourceId)).get()?.p;
|
|
27
|
+
if (repoPath && process.env.LOKI_CONTROL_ALLOW_TEMP_SOURCES !== "1" && underTemp(repoPath, tempRoots())) return { status: 400, body: { error: "source is a repo under a temp directory (a test fixture); refusing to ingest it" } };
|
|
23
28
|
if (!Array.isArray(b.events) || b.events.length > MAX_EVENTS) return { status: 400, body: { error: `events must be an array of at most ${MAX_EVENTS}` } };
|
|
24
29
|
|
|
25
30
|
const rows: { e: EventEnvelope; json: string; sha: string }[] = [];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// A3e (L6): a local run with no live worker PID, no terminal event and no new event for a grace period is shown as stopped, not "Running" forever.
|
|
2
|
+
// The worker PID is the one the supervisor recorded in <run dir>/run.pid (verified by start time, never matched by name). A run whose repo is not on
|
|
3
|
+
// this machine cannot be checked and is left alone. The runs row is a projection: a later event for the run rebuilds it, so a revived run recovers.
|
|
4
|
+
import { realpathSync } from "node:fs";
|
|
5
|
+
import { join, resolve, sep } from "node:path";
|
|
6
|
+
import { and, eq, isNull } from "drizzle-orm";
|
|
7
|
+
import { verifyRunPid } from "../../../../loki-ts/src/util/run_pid.ts";
|
|
8
|
+
import type { Db } from "../db/migrate.ts";
|
|
9
|
+
import { localRepos, runs } from "../db/schema.ts";
|
|
10
|
+
|
|
11
|
+
export const STOPPED_VERDICT = "STOPPED";
|
|
12
|
+
export const STOPPED_REASON = "stopped (no longer running)";
|
|
13
|
+
export const STALE_GRACE_MS = 120_000;
|
|
14
|
+
|
|
15
|
+
export function reconcileDeadRuns(db: Db, o: { now?: number; graceMs?: number; verify?: typeof verifyRunPid } = {}): number {
|
|
16
|
+
const now = o.now ?? Date.now(), grace = o.graceMs ?? STALE_GRACE_MS, verify = o.verify ?? verifyRunPid;
|
|
17
|
+
let n = 0;
|
|
18
|
+
for (const r of db.select().from(runs).where(isNull(runs.endedAt)).all()) {
|
|
19
|
+
const last = Date.parse(r.lastEventAt ?? r.startedAt ?? "");
|
|
20
|
+
if (Number.isNaN(last) || now - last < grace) continue;
|
|
21
|
+
const repoRow = db.select().from(localRepos).where(eq(localRepos.sourceId, r.sourceId)).get();
|
|
22
|
+
if (!repoRow) continue;
|
|
23
|
+
let repo: string;
|
|
24
|
+
try { repo = realpathSync(repoRow.realpath); } catch { continue; }
|
|
25
|
+
const root = join(repo, ".loki", "runs"), dir = resolve(root, r.runId);
|
|
26
|
+
if (!dir.startsWith(root + sep)) continue;
|
|
27
|
+
if (verify(dir, r.runId).ok) continue;
|
|
28
|
+
db.update(runs).set({ endedAt: r.lastEventAt ?? new Date(last).toISOString(), verdict: r.verdict ?? STOPPED_VERDICT })
|
|
29
|
+
.where(and(eq(runs.sourceId, r.sourceId), eq(runs.runId, r.runId), isNull(runs.endedAt))).run();
|
|
30
|
+
n++;
|
|
31
|
+
}
|
|
32
|
+
return n;
|
|
33
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
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
2
|
// the path never leaves the server. The API exposes display names only.
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
3
4
|
import { basename, resolve } from "node:path";
|
|
4
|
-
import
|
|
5
|
+
import { eq } from "drizzle-orm";
|
|
6
|
+
import type { Context, Hono } from "hono";
|
|
5
7
|
import type { Db } from "../db/migrate.ts";
|
|
6
|
-
import { localRepos } from "../db/schema.ts";
|
|
8
|
+
import { localRepos, runs } from "../db/schema.ts";
|
|
7
9
|
import { discoverLocalRepos } from "../shipper/discover.ts";
|
|
8
10
|
|
|
9
11
|
/** Upsert every locally discovered repo. Returns the number of repos known. */
|
|
@@ -23,7 +25,39 @@ export function repoNames(db: Db): string[] {
|
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
/** GET /v1/repos. `act` is the loopback-only router; `peerIsLoopback` checks the real socket address. */
|
|
26
|
-
export
|
|
28
|
+
export type GhRunner = (argv: string[]) => Promise<{ code: number; stdout: string; stderr: string }>;
|
|
29
|
+
const defaultGh: GhRunner = (argv) => new Promise((done) => {
|
|
30
|
+
execFile("gh", argv, { timeout: 15000, maxBuffer: 2_000_000, shell: false }, (err, stdout, stderr) => {
|
|
31
|
+
done({ code: err ? (typeof (err as { code?: unknown }).code === "number" ? (err as { code: number }).code : 1) : 0, stdout: String(stdout), stderr: String(stderr || (err?.message ?? "")) });
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
const OWNER_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\/[A-Za-z0-9._-]{1,100}$/;
|
|
35
|
+
|
|
36
|
+
/** owner/name of a registered repo (by display name), from the origin_repo its runs recorded. Null when the name is not registered or has no known origin. */
|
|
37
|
+
export function registeredOwnerName(db: Db, name: string): string | null {
|
|
38
|
+
for (const r of db.select().from(localRepos).where(eq(localRepos.name, name)).all()) {
|
|
39
|
+
const o = db.select({ o: runs.originRepo }).from(runs).where(eq(runs.sourceId, r.sourceId)).all().map((x) => x.o).find((x): x is string => !!x && OWNER_NAME.test(x));
|
|
40
|
+
if (o) return o;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function mountRepos(act: Hono, db: Db, peerIsLoopback: (c: Context) => boolean, repoDir?: string, gh: GhRunner = defaultGh): void {
|
|
46
|
+
act.get("/v1/repos/issues", async (c) => {
|
|
47
|
+
if (!peerIsLoopback(c)) return c.json({ error: "loopback only" }, 403);
|
|
48
|
+
const repo = c.req.query("repo") ?? "";
|
|
49
|
+
const ownerName = repo ? registeredOwnerName(db, repo) : null;
|
|
50
|
+
if (!ownerName) return c.json({ error: "repo is not a registered repo with a known owner/name" }, 400);
|
|
51
|
+
const r = await gh(["issue", "list", "--repo", ownerName, "--state", "open", "--json", "number,title,url", "--limit", "50"]);
|
|
52
|
+
if (r.code !== 0) return c.json({ error: `gh issue list failed: ${r.stderr.trim().slice(0, 300) || `exit ${r.code}`}` }, 502);
|
|
53
|
+
try {
|
|
54
|
+
const j = JSON.parse(r.stdout) as unknown;
|
|
55
|
+
if (!Array.isArray(j)) throw new Error("not an array");
|
|
56
|
+
const issues = j.filter((i): i is { number: number; title: string; url: string } => !!i && Number.isInteger((i as { number?: unknown }).number) && typeof (i as { title?: unknown }).title === "string" && typeof (i as { url?: unknown }).url === "string")
|
|
57
|
+
.map((i) => ({ number: i.number, title: i.title, url: i.url }));
|
|
58
|
+
return c.json({ issues });
|
|
59
|
+
} catch (e) { return c.json({ error: `gh returned unreadable output: ${(e as Error).message}` }, 502); }
|
|
60
|
+
});
|
|
27
61
|
// default_repo is the folder name of the directory the service was launched from (what a run with no repo chip uses); the path itself is never sent.
|
|
28
62
|
const defaultRepo = repoDir ? basename(resolve(repoDir)) || null : null;
|
|
29
63
|
act.get("/v1/repos", (c) => peerIsLoopback(c) ? c.json({ repos: repoNames(db), default_repo: defaultRepo }) : c.json({ error: "loopback only" }, 403));
|
|
@@ -14,7 +14,7 @@ export const EVENTS_DEFAULT = 500;
|
|
|
14
14
|
export const EVENTS_MAX = 1000;
|
|
15
15
|
|
|
16
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)$/;
|
|
17
|
+
export 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
18
|
|
|
19
19
|
const TYPES: Record<string, string> = {
|
|
20
20
|
json: "application/json; charset=utf-8",
|
|
@@ -23,10 +23,10 @@ const TYPES: Record<string, string> = {
|
|
|
23
23
|
png: "image/png",
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
-
const safeId = (s: string) => ID.test(s) && !s.includes("..");
|
|
26
|
+
export const safeId = (s: string) => ID.test(s) && !s.includes("..");
|
|
27
27
|
|
|
28
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 } {
|
|
29
|
+
export function readCapped(path: string): { status: 200; body: Buffer } | { status: 404 | 413 } {
|
|
30
30
|
let fd: number;
|
|
31
31
|
try { fd = openSync(path, "r"); } catch { return { status: 404 }; }
|
|
32
32
|
try {
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// CP-ASK slice 10: Ask Loki HTTP surface, behind LOKI_CP_ASK=1 (flag off: every /v1/ask* route is 404).
|
|
2
|
+
// Free text goes ONLY to the read-only Ask worker; it never reaches the run-start path (this file has no import from the run-start module).
|
|
3
|
+
// Gate: a bearer token (enforced by tokenGuard on /v1/*) or, without one, a loopback peer and Host. POST needs JSON, a same-host Origin and questions up to 4000 chars.
|
|
4
|
+
import { basename } from "node:path";
|
|
5
|
+
import type { Context } from "hono";
|
|
6
|
+
import { localRepos } from "../../db/schema.ts";
|
|
7
|
+
import { buildInvocation, MODEL_RE } from "../../ask/invoke.ts";
|
|
8
|
+
import { activeCount, addTurn, citations, createThread, getMessage, getThread, listEvents, listMessages, listThreads, markInterrupted, pidAlive, TERMINAL, threadBusy } from "../../ask/store.ts";
|
|
9
|
+
import { cancelJob, runAskJob } from "../../ask/worker.ts";
|
|
10
|
+
import { isLoopbackHost } from "../auth.ts";
|
|
11
|
+
import { audit } from "../audit.ts";
|
|
12
|
+
import type { RouteCtx } from "./index.ts";
|
|
13
|
+
import { createSse } from "./stream.ts";
|
|
14
|
+
|
|
15
|
+
export const MAX_QUESTION = 4000;
|
|
16
|
+
const PROVIDERS = new Set(["claude", "codex", "cline", "aider", "opencode"]); // opencode and cline and aider are refused by buildInvocation
|
|
17
|
+
const num = (v: string | undefined, d: number) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : d; };
|
|
18
|
+
export const askEnabled = (): boolean => process.env.LOKI_CP_ASK === "1";
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
export function mount(ctx: RouteCtx): void {
|
|
22
|
+
const { app, db } = ctx;
|
|
23
|
+
// A restart leaves running rows with dead worker pids; flip them so the UI offers a retry.
|
|
24
|
+
markInterrupted(db, pidAlive);
|
|
25
|
+
const sse = createSse({ pollMs: num(process.env.LOKI_ASK_STREAM_POLL_MS, 300), heartbeatMs: num(process.env.LOKI_CONTROL_STREAM_HEARTBEAT_MS, 15000), maxStreams: num(process.env.LOKI_CONTROL_STREAM_MAX, 32) });
|
|
26
|
+
const off = (c: Context) => c.json({ error: "not found" }, 404);
|
|
27
|
+
const allowed = (c: Context) => !!ctx.token || (ctx.peerIsLoopback(c) && isLoopbackHost(c.req.header("host")));
|
|
28
|
+
const sameOrigin = (c: Context): boolean => {
|
|
29
|
+
const origin = c.req.header("origin");
|
|
30
|
+
if (origin === undefined) return true;
|
|
31
|
+
try { return new URL(origin).host.toLowerCase() === (c.req.header("host") ?? "").toLowerCase(); } catch { return false; }
|
|
32
|
+
};
|
|
33
|
+
const dbPath = (): string => {
|
|
34
|
+
const f = (db as unknown as { $client?: { filename?: string } }).$client?.filename;
|
|
35
|
+
return f && f !== ":memory:" ? f : process.env.LOKI_CONTROL_DB ?? "";
|
|
36
|
+
};
|
|
37
|
+
const knownRepo = (name: string): boolean =>
|
|
38
|
+
name === basename(ctx.repoDir) || db.select({ n: localRepos.name }).from(localRepos).all().some((r) => r.n === name);
|
|
39
|
+
|
|
40
|
+
// Every route: flag first (404), then the access gate (403).
|
|
41
|
+
const guard = (c: Context): Response | null => (!askEnabled() ? off(c) : !allowed(c) ? c.json({ error: "loopback or bearer token required" }, 403) : null);
|
|
42
|
+
|
|
43
|
+
app.post("/v1/ask", async (c) => {
|
|
44
|
+
const g = guard(c);
|
|
45
|
+
if (g) return g;
|
|
46
|
+
if (!(c.req.header("content-type") ?? "").toLowerCase().startsWith("application/json")) return c.json({ error: "content-type must be application/json" }, 415);
|
|
47
|
+
if (!sameOrigin(c)) return c.json({ error: "origin not allowed" }, 403);
|
|
48
|
+
const text = await c.req.text();
|
|
49
|
+
if (text.length > 20_000) return c.json({ error: "body too large" }, 413);
|
|
50
|
+
let body: Record<string, unknown>;
|
|
51
|
+
try { const j = JSON.parse(text); if (!j || typeof j !== "object" || Array.isArray(j)) throw new Error(); body = j as Record<string, unknown>; } catch { return c.json({ error: "invalid JSON" }, 400); }
|
|
52
|
+
const question = typeof body.question === "string" ? body.question.trim() : "";
|
|
53
|
+
if (!question) return c.json({ error: "question must be a non-empty string" }, 400);
|
|
54
|
+
if (question.length > MAX_QUESTION) return c.json({ error: `question must be at most ${MAX_QUESTION} characters` }, 400);
|
|
55
|
+
|
|
56
|
+
let threadId: string;
|
|
57
|
+
let provider: string, model: string | null, repo: string | null;
|
|
58
|
+
if (body.thread_id !== undefined && body.thread_id !== null && body.thread_id !== "") {
|
|
59
|
+
const t = typeof body.thread_id === "string" ? getThread(db, body.thread_id) : undefined;
|
|
60
|
+
if (!t) return c.json({ error: "thread not found" }, 404);
|
|
61
|
+
if (threadBusy(db, t.id)) return c.json({ error: "this thread is still answering" }, 409);
|
|
62
|
+
threadId = t.id; provider = t.provider; model = t.model; repo = t.repo;
|
|
63
|
+
} else {
|
|
64
|
+
provider = typeof body.provider === "string" && body.provider ? body.provider : process.env.LOKI_ASK_PROVIDER || "claude";
|
|
65
|
+
if (!PROVIDERS.has(provider)) return c.json({ error: "provider must be one of: claude, codex" }, 400);
|
|
66
|
+
model = typeof body.model === "string" && body.model ? body.model : null;
|
|
67
|
+
if (model !== null && !MODEL_RE.test(model)) return c.json({ error: "model must match [A-Za-z0-9][A-Za-z0-9._/-]{0,79}" }, 400);
|
|
68
|
+
repo = typeof body.repo === "string" && body.repo ? body.repo : null;
|
|
69
|
+
if (repo !== null && !knownRepo(repo)) return c.json({ error: "repo is not a known project" }, 400);
|
|
70
|
+
threadId = "";
|
|
71
|
+
}
|
|
72
|
+
const dry = buildInvocation({ provider, model, mcpConfigPath: "", jobDir: "", maxUsd: 1 });
|
|
73
|
+
if (!dry.ok) return c.json({ error: dry.error }, 400);
|
|
74
|
+
if (activeCount(db) >= num(process.env.LOKI_ASK_MAX_JOBS, 2)) return c.json({ error: "too many Ask jobs running; try again shortly" }, 429, { "retry-after": "10" });
|
|
75
|
+
|
|
76
|
+
if (!threadId) threadId = createThread(db, { provider, model, repo });
|
|
77
|
+
const { assistantId } = addTurn(db, threadId, question);
|
|
78
|
+
audit(db, { kind: "ask.start", target: threadId, result: "queued", detail: `provider ${provider}; ${question.length} chars` });
|
|
79
|
+
void runAskJob(db, assistantId, {
|
|
80
|
+
toolsServer: process.env.LOKI_ASK_TOOLS_SERVER || undefined, dbPath: dbPath(), bin: process.env.LOKI_ASK_BIN || undefined,
|
|
81
|
+
timeoutMs: num(process.env.LOKI_ASK_TIMEOUT_S, 600) * 1000, maxUsd: num(process.env.LOKI_ASK_MAX_USD, 1),
|
|
82
|
+
}).catch(() => { /* runAskJob records its own failures on the row */ });
|
|
83
|
+
return c.json({ thread_id: threadId, message_id: assistantId }, 202);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
app.get("/v1/ask/threads", (c) => {
|
|
87
|
+
const g = guard(c);
|
|
88
|
+
if (g) return g;
|
|
89
|
+
return c.json({ threads: listThreads(db).map((t) => ({ id: t.id, title: t.title, updated_at: t.updatedAt })) });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
app.get("/v1/ask/threads/:id", (c) => {
|
|
93
|
+
const g = guard(c);
|
|
94
|
+
if (g) return g;
|
|
95
|
+
const t = getThread(db, c.req.param("id"));
|
|
96
|
+
if (!t) return c.json({ error: "thread not found" }, 404);
|
|
97
|
+
const messages = listMessages(db, t.id).map((m) => ({
|
|
98
|
+
id: m.id, seq: m.seq, role: m.role, text: m.text, status: m.status, error: m.error, cost_usd: m.costUsd,
|
|
99
|
+
...(m.role === "assistant" ? { citations: citations(db, m.id) } : {}),
|
|
100
|
+
}));
|
|
101
|
+
return c.json({ thread: { id: t.id, title: t.title, repo: t.repo }, messages });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
app.get("/v1/ask/messages/:id/stream", (c) => {
|
|
105
|
+
const g = guard(c);
|
|
106
|
+
if (g) return g;
|
|
107
|
+
const id = c.req.param("id");
|
|
108
|
+
if (!getMessage(db, id)) return c.json({ error: "message not found" }, 404);
|
|
109
|
+
const given = c.req.header("last-event-id") ?? c.req.query("after");
|
|
110
|
+
const parsed = given === undefined || given === "" ? NaN : Number(given);
|
|
111
|
+
let cursor = Number.isInteger(parsed) ? parsed : -1; // a fresh client replays the whole answer
|
|
112
|
+
const tick = (): string[] | { frames: string[]; end: true } => {
|
|
113
|
+
const m = getMessage(db, id)!;
|
|
114
|
+
const terminal = TERMINAL.has(m.status); // read status BEFORE events so no event is missed
|
|
115
|
+
const rows = listEvents(db, id, cursor);
|
|
116
|
+
if (rows.length) cursor = rows[rows.length - 1]!.seq;
|
|
117
|
+
const frames = rows.map((e) => `id: ${e.seq}\nevent: ${e.kind}\ndata: ${e.payload}\n\n`);
|
|
118
|
+
if (!terminal) return frames;
|
|
119
|
+
return { frames: [...frames, `event: done\ndata: ${JSON.stringify({ status: m.status, error: m.error })}\n\n`], end: true };
|
|
120
|
+
};
|
|
121
|
+
const first = tick();
|
|
122
|
+
if (!Array.isArray(first)) {
|
|
123
|
+
return new Response(`retry: 3000\n: connected\n\n${first.frames.join("")}`, { headers: { "content-type": "text/event-stream", "cache-control": "no-cache, no-transform", "x-accel-buffering": "no" } });
|
|
124
|
+
}
|
|
125
|
+
return sse(c.req.raw.signal, `retry: 3000\n: connected\n\n${first.join("")}`, tick);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
app.post("/v1/ask/messages/:id/cancel", async (c) => {
|
|
129
|
+
const g = guard(c);
|
|
130
|
+
if (g) return g;
|
|
131
|
+
if (!(c.req.header("content-type") ?? "").toLowerCase().startsWith("application/json")) return c.json({ error: "content-type must be application/json" }, 415);
|
|
132
|
+
if (!sameOrigin(c)) return c.json({ error: "origin not allowed" }, 403);
|
|
133
|
+
const id = c.req.param("id");
|
|
134
|
+
if (!getMessage(db, id)) return c.json({ error: "message not found" }, 404);
|
|
135
|
+
if (!cancelJob(db, id)) return c.json({ error: "message is not running" }, 409);
|
|
136
|
+
audit(db, { kind: "ask.cancel", target: id, result: "cancelled", detail: "" });
|
|
137
|
+
return c.json({ ok: true });
|
|
138
|
+
});
|
|
139
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Unmeasured = no priced session (subscription or local provider): never summed as 0.
|
|
4
4
|
import { gte } from "drizzle-orm";
|
|
5
5
|
import { runs } from "../../db/schema.ts";
|
|
6
|
+
import { effectiveVerdict, SUCCESS_VERDICTS } from "../integrity.ts";
|
|
6
7
|
import type { RouteCtx } from "./index.ts";
|
|
7
8
|
|
|
8
9
|
export const COST_DIMS = ["day", "model", "repo", "provider"] as const;
|
|
@@ -29,6 +30,24 @@ const round = (n: number) => Math.round(n * 1e6) / 1e6;
|
|
|
29
30
|
const out = (a: Acc) => ({ ...a.key, runs: a.runs, measured_runs: a.measured_runs, partial_runs: a.partial_runs, unmeasured_runs: a.unmeasured_runs, measured_usd: round(a.measured_usd), partial_usd: round(a.partial_usd), input_tokens: a.input_tokens, output_tokens: a.output_tokens });
|
|
30
31
|
|
|
31
32
|
export function mount(ctx: RouteCtx): void {
|
|
33
|
+
// B8: per-repo breakdown. total_usd sums measured runs only (never invents a zero); avg_usd is over measured runs; verified = plain attested success display verdict.
|
|
34
|
+
ctx.app.get("/v1/cost/repos", (c) => {
|
|
35
|
+
const g = new Map<string, { runs: number; priced: number; usd: number; verified: number }>();
|
|
36
|
+
for (const r of ctx.db.select().from(runs).all()) {
|
|
37
|
+
const k = r.originRepo ?? "(unknown)";
|
|
38
|
+
const a = g.get(k) ?? { runs: 0, priced: 0, usd: 0, verified: 0 };
|
|
39
|
+
a.runs++;
|
|
40
|
+
if (r.costUsd !== null) { a.priced++; a.usd += r.costUsd; }
|
|
41
|
+
const ev = effectiveVerdict({ verdict: r.verdict, tampered: r.tampered === 1, attested: r.attested === 1, sig_checked: r.sigChecked === 1 });
|
|
42
|
+
if (ev !== null && SUCCESS_VERDICTS.has(ev)) a.verified++;
|
|
43
|
+
g.set(k, a);
|
|
44
|
+
}
|
|
45
|
+
const rows = [...g.entries()].sort((x, y) => x[0].localeCompare(y[0])).map(([repo, a]) => ({
|
|
46
|
+
repo, runs: a.runs, total_usd: round(a.usd), avg_usd: a.priced ? round(a.usd / a.priced) : null,
|
|
47
|
+
verified_runs: a.verified, usd_per_verified: a.verified ? round(a.usd / a.verified) : null,
|
|
48
|
+
}));
|
|
49
|
+
return c.json({ rows });
|
|
50
|
+
});
|
|
32
51
|
ctx.app.get("/v1/stats/cost", (c) => {
|
|
33
52
|
const dims = (c.req.query("group") ?? "day").split(",").map((s) => s.trim()).filter(Boolean) as CostDim[];
|
|
34
53
|
if (dims.length === 0 || dims.some((d) => !COST_DIMS.includes(d))) {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// GET /v1/doctor (A5): the real `loki doctor --json` checks, mapped to {name,status,detail}. Fixed argv, no shell, 25s timeout,
|
|
2
|
+
// the same scrubbed child env as spawned runs, cached for 60s. Unusable output is an error (502), never an invented pass.
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { childEnv } from "../spawn.ts";
|
|
5
|
+
import { probeAllowed, ttlCache, type RouteCtx } from "./index.ts";
|
|
6
|
+
|
|
7
|
+
export const DOCTOR_TIMEOUT_MS = 25_000;
|
|
8
|
+
const MAX_OUT = 1_000_000;
|
|
9
|
+
|
|
10
|
+
export type DoctorStatus = "pass" | "warn" | "fail";
|
|
11
|
+
export interface DoctorCheck { name: string; status: DoctorStatus; detail: string }
|
|
12
|
+
|
|
13
|
+
const isStatus = (s: unknown): s is DoctorStatus => s === "pass" || s === "warn" || s === "fail";
|
|
14
|
+
|
|
15
|
+
/** Run `<bin> doctor --json` and return stdout. doctor exits 1 when a check fails, so the exit code is not the signal; the JSON is. */
|
|
16
|
+
export function runDoctor(bin: string, timeoutMs = DOCTOR_TIMEOUT_MS): Promise<string> {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
let out = "";
|
|
19
|
+
let done = false;
|
|
20
|
+
let child: ReturnType<typeof spawn>;
|
|
21
|
+
const finish = (f: () => void): void => { if (!done) { done = true; clearTimeout(timer); f(); } };
|
|
22
|
+
try {
|
|
23
|
+
child = spawn(bin, ["doctor", "--json"], { stdio: ["ignore", "pipe", "ignore"], shell: false, env: childEnv() });
|
|
24
|
+
} catch (e) { return reject(new Error(`could not run loki doctor: ${(e as Error).message}`)); }
|
|
25
|
+
const timer = setTimeout(() => {
|
|
26
|
+
try { child.kill("SIGKILL"); } catch { /* already gone */ }
|
|
27
|
+
finish(() => reject(new Error(`loki doctor timed out after ${Math.round(timeoutMs / 1000)}s`)));
|
|
28
|
+
}, timeoutMs);
|
|
29
|
+
child.stdout?.on("data", (d: Buffer) => { if (out.length < MAX_OUT) out += d.toString("utf8"); });
|
|
30
|
+
child.on("error", (e) => finish(() => reject(new Error(`could not run loki doctor: ${e.message}`))));
|
|
31
|
+
child.on("close", () => finish(() => resolve(out)));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Map the doctor JSON (tool checks plus disk and AI provider) to the flat checklist shape. */
|
|
36
|
+
export function mapDoctor(raw: string): DoctorCheck[] {
|
|
37
|
+
let j: { checks?: unknown; disk?: unknown; ai_provider?: unknown };
|
|
38
|
+
try { j = JSON.parse(raw); } catch { throw new Error("loki doctor did not return JSON"); }
|
|
39
|
+
if (!j || !Array.isArray(j.checks)) throw new Error("loki doctor JSON has no checks");
|
|
40
|
+
const out: DoctorCheck[] = [];
|
|
41
|
+
for (const c of j.checks as Record<string, unknown>[]) {
|
|
42
|
+
if (typeof c?.name !== "string" || !isStatus(c.status)) continue;
|
|
43
|
+
const sev = typeof c.required === "string" ? c.required : "";
|
|
44
|
+
const detail = c.found === false
|
|
45
|
+
? `not found${sev ? ` (${sev})` : ""}`
|
|
46
|
+
: `${typeof c.version === "string" ? c.version : "found"}${c.status === "warn" && typeof c.min_version === "string" ? `, needs ${c.min_version} or newer` : ""}`;
|
|
47
|
+
out.push({ name: c.name, status: c.status, detail });
|
|
48
|
+
}
|
|
49
|
+
const d = j.disk as { available_gb?: unknown; status?: unknown } | undefined;
|
|
50
|
+
if (d && isStatus(d.status)) out.push({ name: "Disk space", status: d.status, detail: typeof d.available_gb === "number" ? `${d.available_gb} GB available` : "unknown" });
|
|
51
|
+
const a = j.ai_provider as { found?: unknown; status?: unknown; detail?: unknown } | undefined;
|
|
52
|
+
if (a && isStatus(a.status)) out.push({ name: "AI provider", status: a.status, detail: typeof a.detail === "string" && a.detail ? a.detail : a.found ? "found" : "not found" });
|
|
53
|
+
if (out.length === 0) throw new Error("loki doctor returned no usable checks");
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function mount(ctx: RouteCtx): void {
|
|
58
|
+
const cached = ttlCache<{ checks: DoctorCheck[] }>();
|
|
59
|
+
ctx.app.get("/v1/doctor", async (c) => {
|
|
60
|
+
if (!probeAllowed(ctx, c)) return c.json({ error: "loopback only without a token" }, 403);
|
|
61
|
+
try {
|
|
62
|
+
return c.json(await cached("doctor", async () => ({ checks: mapDoctor(await runDoctor(ctx.startBin ?? "loki")) })));
|
|
63
|
+
} catch (e) {
|
|
64
|
+
return c.json({ error: (e as Error).message }, 502);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -4,14 +4,16 @@ import type { Context, Hono } from "hono";
|
|
|
4
4
|
import type { Db } from "../../db/migrate.ts";
|
|
5
5
|
import type { spawnStart } from "../spawn.ts";
|
|
6
6
|
import { isLoopbackHost } from "../auth.ts";
|
|
7
|
-
import { mountRepos } from "../repos.ts";
|
|
7
|
+
import { mountRepos, type GhRunner } from "../repos.ts";
|
|
8
8
|
import { mount as artifacts } from "./artifacts.ts";
|
|
9
|
+
import { mount as ask } from "./ask.ts";
|
|
9
10
|
import { mount as audit } from "./audit.ts";
|
|
10
11
|
import { mount as checkpoints } from "./checkpoints.ts";
|
|
11
12
|
import { mount as config } from "./config.ts";
|
|
12
13
|
import { mount as control } from "./control.ts";
|
|
13
14
|
import { mount as cost } from "./cost.ts";
|
|
14
15
|
import { mount as costLedger } from "./cost_ledger.ts";
|
|
16
|
+
import { mount as doctor } from "./doctor.ts";
|
|
15
17
|
import { mount as fleet } from "./fleet.ts";
|
|
16
18
|
import { mount as memory } from "./memory.ts";
|
|
17
19
|
import { mount as metrics } from "./metrics.ts";
|
|
@@ -41,12 +43,14 @@ export interface RouteCtx {
|
|
|
41
43
|
spawnImpl?: typeof spawnStart;
|
|
42
44
|
/** Where BLOCKED answers are written (createApp answerDir). */
|
|
43
45
|
answerDir?: string;
|
|
46
|
+
/** gh seam for GET /v1/repos/issues (tests stub it). */
|
|
47
|
+
ghImpl?: GhRunner;
|
|
44
48
|
}
|
|
45
49
|
|
|
46
|
-
export const routeModules: ReadonlyArray<(ctx: RouteCtx) => void> = [artifacts, stream, start, control, stats, cost, costLedger, fleet, metrics, checkpoints, memory, sessionControl, config, providers, verify, integrations, notify, audit, mergeRisk];
|
|
50
|
+
export const routeModules: ReadonlyArray<(ctx: RouteCtx) => void> = [artifacts, ask, stream, start, control, stats, cost, costLedger, doctor, fleet, metrics, checkpoints, memory, sessionControl, config, providers, verify, integrations, notify, audit, mergeRisk];
|
|
47
51
|
|
|
48
52
|
export function registerRoutes(ctx: RouteCtx): void {
|
|
49
|
-
mountRepos(ctx.act, ctx.db, ctx.peerIsLoopback, ctx.repoDir);
|
|
53
|
+
mountRepos(ctx.act, ctx.db, ctx.peerIsLoopback, ctx.repoDir, ctx.ghImpl);
|
|
50
54
|
for (const m of routeModules) m(ctx);
|
|
51
55
|
}
|
|
52
56
|
|
|
@@ -7,19 +7,17 @@ import type { RouteCtx } from "./index.ts";
|
|
|
7
7
|
|
|
8
8
|
const num = (v: string | undefined, d: number) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : d; };
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
/** A tick returns frames to send, or frames plus end:true to send them and close the stream. */
|
|
11
|
+
export type SseTick = () => string[] | { frames: string[]; end: true };
|
|
12
|
+
|
|
13
|
+
/** Bounded SSE responder (shared by the run streams and the Ask stream). Each instance has its own slot count; a slot is freed exactly once on abort, cancel, end or error. */
|
|
14
|
+
export function createSse(opts: { pollMs: number; heartbeatMs: number; maxStreams: number }) {
|
|
15
15
|
const enc = new TextEncoder();
|
|
16
16
|
let open = 0;
|
|
17
|
-
|
|
18
17
|
const headers = { "content-type": "text/event-stream", "cache-control": "no-cache, no-transform", connection: "keep-alive", "x-accel-buffering": "no" };
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (open >= maxStreams) return Response.json({ error: "too many streams" }, { status: 429, headers: { "retry-after": "5" } });
|
|
19
|
+
return (signal: AbortSignal, hello: string, tick: SseTick): Response => {
|
|
20
|
+
if (open >= opts.maxStreams) return Response.json({ error: "too many streams" }, { status: 429, headers: { "retry-after": "5" } });
|
|
23
21
|
open++;
|
|
24
22
|
let freed = false;
|
|
25
23
|
let poll: ReturnType<typeof setInterval> | undefined;
|
|
@@ -30,14 +28,27 @@ export function mount(ctx: RouteCtx): void {
|
|
|
30
28
|
const send = (s: string) => { try { ctrl.enqueue(enc.encode(s)); } catch { free(); } };
|
|
31
29
|
const close = () => { free(); try { ctrl.close(); } catch { /* already closed */ } };
|
|
32
30
|
send(hello);
|
|
33
|
-
poll = setInterval(() => {
|
|
34
|
-
|
|
31
|
+
poll = setInterval(() => {
|
|
32
|
+
try {
|
|
33
|
+
const r = tick();
|
|
34
|
+
if (Array.isArray(r)) { for (const f of r) send(f); } else { for (const f of r.frames) send(f); close(); }
|
|
35
|
+
} catch { close(); }
|
|
36
|
+
}, opts.pollMs);
|
|
37
|
+
beat = setInterval(() => send(": heartbeat\n\n"), opts.heartbeatMs);
|
|
35
38
|
if (signal.aborted) close(); else signal.addEventListener("abort", close, { once: true });
|
|
36
39
|
},
|
|
37
40
|
cancel: free,
|
|
38
41
|
});
|
|
39
42
|
return new Response(body, { headers });
|
|
40
43
|
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function mount(ctx: RouteCtx): void {
|
|
47
|
+
const pollMs = num(process.env.LOKI_CONTROL_STREAM_POLL_MS, 1000);
|
|
48
|
+
const heartbeatMs = num(process.env.LOKI_CONTROL_STREAM_HEARTBEAT_MS, 15000);
|
|
49
|
+
const maxStreams = num(process.env.LOKI_CONTROL_STREAM_MAX, 32);
|
|
50
|
+
const { db } = ctx;
|
|
51
|
+
const sse = createSse({ pollMs, heartbeatMs, maxStreams });
|
|
41
52
|
|
|
42
53
|
const maxSeq = (source: string, run: string): number =>
|
|
43
54
|
db.select({ m: sql<number | null>`max(${events.seq})` }).from(events).where(and(eq(events.sourceId, source), eq(events.runId, run))).get()?.m ?? -1;
|
|
@@ -143,6 +143,30 @@ export function listRuns(db: Db, q: ListQuery) {
|
|
|
143
143
|
return { runs: rows.map((r) => withLive(db, r)), total, next_cursor: offset + rows.length < total ? String(offset + rows.length) : null };
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[@-Z\\-_]|\[\d{1,3}(?:;\d{1,3})*m/g;
|
|
147
|
+
const oneLine = (s: string, max: number) => s.replace(ANSI, "").replace(/[\x00-\x1f\x7f\s]+/g, " ").trim().slice(0, max);
|
|
148
|
+
// FC-19: Loki's own stage rules talked the model into a false spec conflict. The engine records no version or classifier, so this reads the model's own words.
|
|
149
|
+
const OWN_RULES = /\bstage rules\b|\bstage-rules\b/i;
|
|
150
|
+
|
|
151
|
+
/** The terminal stop reason as one ANSI-free sentence, from the run's own end (never from verify output); null when the run did not stop short. */
|
|
152
|
+
export function stopReason(evs: EventEnvelope[], verdict: string | null): { reason: string | null; ownRules: boolean } {
|
|
153
|
+
if (verdict === "SPEC_CONFLICT") {
|
|
154
|
+
const why = evs.find((e) => e.type === "stage.completed" && e.stage === "implement")?.data.spec_conflict_reason;
|
|
155
|
+
const text = typeof why === "string" ? oneLine(why, 4000) : "";
|
|
156
|
+
const first = text.split(/(?<=[.!?])\s+(?=[A-Z])/)[0] ?? "";
|
|
157
|
+
const reason = `The run stopped on a spec conflict: ${first ? first.slice(0, 240) : "the task and the repository disagree"}`;
|
|
158
|
+
return { reason: /[.!?]$/.test(reason) ? reason : `${reason}.`, ownRules: OWN_RULES.test(text) };
|
|
159
|
+
}
|
|
160
|
+
if (verdict === "FAILED") {
|
|
161
|
+
const failed = [...evs].reverse().find((e) => e.type === "stage.failed");
|
|
162
|
+
const why = typeof failed?.data?.reason === "string" ? oneLine(failed.data.reason as string, 240) : "";
|
|
163
|
+
if (failed && failed.stage) return { reason: `The run failed in the ${failed.stage} stage${why ? `: ${why}` : ""}.`, ownRules: false };
|
|
164
|
+
const bad = evs.filter((e) => e.type === "test.result" && (e.data as Record<string, unknown>).result === "fail").length;
|
|
165
|
+
return { reason: bad ? `The run failed: ${bad} check${bad === 1 ? "" : "s"} did not pass.` : "The run failed and did not record a reason.", ownRules: false };
|
|
166
|
+
}
|
|
167
|
+
return { reason: null, ownRules: false };
|
|
168
|
+
}
|
|
169
|
+
|
|
146
170
|
/** Summary row plus the folded detail: stage timeline, receipt, not_proven. Null when the run is unknown. */
|
|
147
171
|
export function runDetail(db: Db, sourceId: string, runId: string) {
|
|
148
172
|
const r = db.select().from(runs).where(and(eq(runs.sourceId, sourceId), eq(runs.runId, runId))).get();
|
|
@@ -164,9 +188,12 @@ export function runDetail(db: Db, sourceId: string, runId: string) {
|
|
|
164
188
|
const f = fold(evs);
|
|
165
189
|
const done = f.run.completed?.data;
|
|
166
190
|
const sealed = evs.find((e) => e.type === "receipt.sealed")?.data;
|
|
191
|
+
const stop = stopReason(evs, r.verdict);
|
|
167
192
|
return {
|
|
168
193
|
...withLive(db, r),
|
|
169
194
|
blocked_question: blockedQuestion(evs, r.verdict),
|
|
195
|
+
stop_reason: stop.reason,
|
|
196
|
+
own_rules_block: stop.ownRules,
|
|
170
197
|
stages,
|
|
171
198
|
stages_completed: f.completed,
|
|
172
199
|
receipt: sealed ? { sha256: str(sealed.receipt_sha256), signed: sealed.signed === true, verdict: str(sealed.verdict), path: str(sealed.path) } : null,
|