loki-mode 10.9.1 → 10.10.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/.github/actions/issue-to-pr/action.yml +2 -2
- package/README.md +9 -3
- package/SKILL.md +12 -2
- package/VERSION +1 -1
- package/autonomy/lib/proof-generator.py +1 -0
- package/autonomy/loki +39 -9
- package/autonomy/receipt_jwt.py +46 -1
- package/autonomy/run.sh +1 -1
- package/autonomy/telemetry.sh +1 -1
- package/autonomy/trigger-server.py +1 -0
- package/bin/loki +12 -1
- package/bin/loki-postinstall.js +12 -0
- package/dashboard/__init__.py +1 -1
- package/docs/CLI-REFERENCE.md +139 -0
- package/docs/INSTALLATION.md +1 -1
- package/docs/LOKI-10-BUILD-PROMPT.md +2 -2
- package/docs/PLANS-INDEX.md +2 -3
- package/docs/SLO.md +3 -3
- package/docs/alternative-installations.md +1 -1
- package/docs/bug-fixes/agent-04-memory-fixes.md +1 -1
- package/docs/dev/release-checklist.md +1 -1
- package/{.github/workflows/loki-issue-to-pr.yml → docs/examples/loki-issue-to-pr.yml.example} +2 -0
- package/docs/{ONE-RUN-AUDIT.md → history/one-run-audit.md} +6 -0
- package/docs/{BUG-AUDIT-v6.61.0.md → history/v6.61.0-audit.md} +6 -0
- package/docs/{V8-AGENT-SDK-PLAN.md → history/v8-agent-sdk-plan.md} +6 -0
- package/docs/{CONFIG-FILE-PLAN.md → history/v8-config-plan.md} +6 -0
- package/docs/test-scenarios/enterprise-scenarios.md +1 -1
- package/docs/v10/BOARD.md +3 -0
- package/docs/v10/CLI-MODERN.md +190 -0
- package/docs/v10/DEPS.md +148 -169
- package/docs/v10/FAILURE-CLASSES.md +18 -0
- package/docs/v10/LEGACY-REMOVAL.md +334 -25
- package/docs/v10/PROGRESS.md +15 -0
- package/loki-ts/dist/loki.js +1570 -1790
- package/mcp/__init__.py +1 -1
- package/package.json +3 -2
- package/packages/control-plane/dist/server.js +424 -327
- package/packages/control-plane/src/db/fixture-cleanup.ts +47 -0
- package/packages/control-plane/src/server/app.ts +2 -0
- package/packages/control-plane/src/server/integrity.ts +48 -9
- package/packages/control-plane/src/server/routes/stats.ts +16 -5
- package/packages/control-plane/src/server/runs.ts +16 -4
- package/packages/control-plane/ui/dist/assets/index-NAMkcA9N.js +165 -0
- package/packages/control-plane/ui/dist/index.html +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/schemas/status-result.schema.json +13 -22
- package/skills/release-cadence.md +5 -5
- package/web-app/dist/assets/{AdminPage-DqMnfZrH.js → AdminPage-B8RjG3sy.js} +1 -1
- package/web-app/dist/assets/{Avatar-BEof29du.js → Avatar-Dttmvqc-.js} +1 -1
- package/web-app/dist/assets/{Badge-WD53OL5m.js → Badge-Bvyp8VrD.js} +1 -1
- package/web-app/dist/assets/{Button-Rzk5flhU.js → Button-DtY5ZrZw.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-Bqz3PCMh.js → CockpitPage-B2gbOwRp.js} +1 -1
- package/web-app/dist/assets/{ComparePage-Byo2NzAZ.js → ComparePage-CHi5nZTU.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-BuE0kH3W.js → ErrorBoundary-BUuTj-OQ.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-CKFHOPBx.js → EvidenceReceiptPanel-C5aOn4ES.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-Df_kTMqI.js → GitHubIssuesPanel-isVxxHXb.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-CouvcKrZ.js → GitHubPRsPanel-DtDpxyQl.js} +1 -1
- package/web-app/dist/assets/{HomePage-dEpLzj0x.js → HomePage-Casf-LUB.js} +2 -2
- package/web-app/dist/assets/{LoginPage-6DVjUND5.js → LoginPage-DvB78691.js} +1 -1
- package/web-app/dist/assets/{MagicPage-BDQ9MOfY.js → MagicPage-DU6hmbnC.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-CXiJ04G_.js → MetricsPage-DcPwzoS8.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-BaXhZvyj.js → NotFoundPage-Bs6WxAZ8.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-BzE4V7Ti.js → ProjectPage-RBIqC-DJ.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-ipf6TLMX.js → ProjectsPage-CogXD7jM.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-BDxkUKN6.js → SettingsPage-pAyJAfPt.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-YQXD72SM.js → ShowcasePage-D6bT2bDd.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-Dou7zHS0.js → SystemSettingsPage-CE8xiJRh.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-CQarfcYr.js → TeamsPage-wMS55TDT.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-CPi1R8Xj.js → TemplatesPage-COJNVQqu.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-WiGhWdYD.js → TerminalOutput-Cc2XASxd.js} +1 -1
- package/web-app/dist/assets/{activity-DOa7ejQA.js → activity-C6eHFDjL.js} +1 -1
- package/web-app/dist/assets/{bell-CX-VAlYN.js → bell-BJpVYj4n.js} +1 -1
- package/web-app/dist/assets/{bot-EDXaZmHc.js → bot-BwRVVUQL.js} +1 -1
- package/web-app/dist/assets/{check-CnBdKk3L.js → check-CGDwoKy8.js} +1 -1
- package/web-app/dist/assets/{chevron-left-BJ_ydRQW.js → chevron-left-CF2Nilj6.js} +1 -1
- package/web-app/dist/assets/{circle-alert-tz8ufhc_.js → circle-alert-D0RyRec1.js} +1 -1
- package/web-app/dist/assets/{clock-DFBZPSsH.js → clock-xeF3gW0W.js} +1 -1
- package/web-app/dist/assets/{cloud-uy3p_5Q7.js → cloud-D5Sy69ZZ.js} +1 -1
- package/web-app/dist/assets/{code-xml-3CbYD9YL.js → code-xml-C6-5Ql9I.js} +1 -1
- package/web-app/dist/assets/{database-YiNBXc9c.js → database-BSjG1_z0.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-D0wBa4Cv.js → dollar-sign-FnDR2QUu.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-DDdZVZ5v.js → file-code-corner-BiOK9vR6.js} +1 -1
- package/web-app/dist/assets/{file-plus-D09LTBqt.js → file-plus-3WALQDEE.js} +1 -1
- package/web-app/dist/assets/{globe-BirTPwff.js → globe-vUdUrQqp.js} +1 -1
- package/web-app/dist/assets/{hammer-DaE5qgnd.js → hammer-0VwHUEpa.js} +1 -1
- package/web-app/dist/assets/{index-D8t7KnQf.js → index-BMKIwteZ.js} +3 -3
- package/web-app/dist/assets/{layers-DVVrckRY.js → layers-C6q8kzST.js} +1 -1
- package/web-app/dist/assets/{loader-circle-2-nMGmcb.js → loader-circle-B4Tz724d.js} +1 -1
- package/web-app/dist/assets/{lock-D0Xj43pC.js → lock-yxvEA65n.js} +1 -1
- package/web-app/dist/assets/{package-BB7S3egm.js → package-BpKD0Ot0.js} +1 -1
- package/web-app/dist/assets/{plus-BLhoMrn3.js → plus-Ibqx_Vo9.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-DMc2uPLw.js → refresh-cw-C9auyvi-.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-DM3IP9x7.js → rotate-ccw-2cEiXm6O.js} +1 -1
- package/web-app/dist/assets/{scroll-text-lH2uW7rB.js → scroll-text-BPAWML-w.js} +1 -1
- package/web-app/dist/assets/{server-BkerZcdR.js → server-CZh6B1zx.js} +1 -1
- package/web-app/dist/assets/{shield-alert-B8ohcUfu.js → shield-alert-CivolUIx.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-CTIxLzPD.js → thumbs-up-BP9covEH.js} +1 -1
- package/web-app/dist/assets/{trash-2-C3zIpsH5.js → trash-2-BCDfTcNx.js} +1 -1
- package/web-app/dist/assets/{trending-down-BTT7wV4Y.js → trending-down-5LypivXh.js} +1 -1
- package/web-app/dist/assets/{trending-up-BzHdCDvF.js → trending-up-DNt4SFdk.js} +1 -1
- package/web-app/dist/assets/{upload-HxR34-A8.js → upload-DBmHbY6y.js} +1 -1
- package/web-app/dist/assets/{usePolling-DDj6Fupy.js → usePolling-DOPZcbUL.js} +1 -1
- package/web-app/dist/assets/{user-qH5y9yNy.js → user-BQTJJDCG.js} +1 -1
- package/web-app/dist/index.html +1 -1
- package/docs/BRANCH-LIFECYCLE-PLAN.md +0 -354
- package/docs/EXECUTION-COCKPIT-PLAN.md +0 -289
- package/packages/control-plane/ui/dist/assets/index-Dbhu244h.js +0 -155
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// One-time cleanup of fixture runs that earlier test suites leaked into real control DBs (FC-07b).
|
|
2
|
+
// Runs at CP start. A marker audit row makes it run once per DB; every removal is audited first, in the same transaction.
|
|
3
|
+
import { Database } from "bun:sqlite";
|
|
4
|
+
import { realpathSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { sep } from "node:path";
|
|
7
|
+
import { audit } from "./prune.ts";
|
|
8
|
+
|
|
9
|
+
export const FIXTURE_REPO = "acme/widget";
|
|
10
|
+
const DONE = "fixture.cleanup.done";
|
|
11
|
+
|
|
12
|
+
const tempRoots = (): string[] => {
|
|
13
|
+
const roots = new Set<string>(["/tmp", "/private/tmp", "/var/tmp", tmpdir()]);
|
|
14
|
+
for (const r of [...roots]) { try { roots.add(realpathSync(r)); } catch { /* absent */ } }
|
|
15
|
+
return [...roots].map((r) => r.replace(/[\\/]+$/, "")).filter((r) => r.length > 1);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const underTemp = (p: string, roots: string[]): boolean => roots.some((r) => p === r || p.startsWith(r + sep) || p.startsWith(r + "/"));
|
|
19
|
+
|
|
20
|
+
/** Removes leaked fixture runs once per DB. Returns the number of runs removed (0 on every later start). */
|
|
21
|
+
export function cleanupLeakedFixtures(sqlite: Database): number {
|
|
22
|
+
let removed = 0;
|
|
23
|
+
sqlite.transaction(() => {
|
|
24
|
+
if (sqlite.query("select 1 x from audit where action = ?").get(DONE)) return;
|
|
25
|
+
const roots = tempRoots();
|
|
26
|
+
const tempSources = new Set(
|
|
27
|
+
(sqlite.query("select source_id, realpath from local_repos").all() as { source_id: string; realpath: string }[])
|
|
28
|
+
.filter((r) => underTemp(r.realpath, roots)).map((r) => r.source_id),
|
|
29
|
+
);
|
|
30
|
+
const all = sqlite.query("select source_id, run_id, origin_repo from runs").all() as { source_id: string; run_id: string; origin_repo: string | null }[];
|
|
31
|
+
const doomed = all.filter((r) => r.origin_repo === FIXTURE_REPO || tempSources.has(r.source_id));
|
|
32
|
+
const touched = new Set<string>();
|
|
33
|
+
for (const k of doomed) {
|
|
34
|
+
const ev = (sqlite.query("select count(*) n from events where source_id = ? and run_id = ?").get(k.source_id, k.run_id) as { n: number }).n;
|
|
35
|
+
audit(sqlite, "fixture.cleanup", "startup", { source_id: k.source_id, run_id: k.run_id, origin_repo: k.origin_repo, events: ev });
|
|
36
|
+
sqlite.query("delete from events where source_id = ? and run_id = ?").run(k.source_id, k.run_id);
|
|
37
|
+
sqlite.query("delete from runs where source_id = ? and run_id = ?").run(k.source_id, k.run_id);
|
|
38
|
+
touched.add(k.source_id);
|
|
39
|
+
removed++;
|
|
40
|
+
}
|
|
41
|
+
for (const id of touched) {
|
|
42
|
+
sqlite.query("delete from sources where id = ? and not exists (select 1 from runs where source_id = ?) and not exists (select 1 from events where source_id = ?)").run(id, id, id);
|
|
43
|
+
}
|
|
44
|
+
audit(sqlite, DONE, "startup", { removed });
|
|
45
|
+
}).immediate();
|
|
46
|
+
return removed;
|
|
47
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, statSync } from "node:fs";
|
|
2
2
|
import { join, resolve, sep } from "node:path";
|
|
3
3
|
import { Hono, type Context } from "hono";
|
|
4
|
+
import { cleanupLeakedFixtures } from "../db/fixture-cleanup.ts";
|
|
4
5
|
import { openDb } from "../db/migrate.ts";
|
|
5
6
|
import { ingest } from "./ingest.ts";
|
|
6
7
|
import { defaultAnswerDir, writeAnswer } from "./answer.ts";
|
|
@@ -22,6 +23,7 @@ const defaultUiDir = () => [join(import.meta.dir, "../../ui/dist"), join(import.
|
|
|
22
23
|
export function createApp(opts: { dbPath: string; uiDir?: string; answerDir?: string; token?: string; loopbackOnly?: boolean; repoDir?: string; startBin?: string; spawnImpl?: typeof spawnStart }) {
|
|
23
24
|
const uiDir = opts.uiDir ?? defaultUiDir();
|
|
24
25
|
const { db, sqlite } = openDb(opts.dbPath);
|
|
26
|
+
cleanupLeakedFixtures(sqlite);
|
|
25
27
|
recomputeLegacy(db);
|
|
26
28
|
let ready = true;
|
|
27
29
|
const answerDir = opts.answerDir ?? defaultAnswerDir();
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// TAMPERED means positive evidence of forgery. A log the CP cannot check (redacted before ingest) is UNVERIFIED, never TAMPERED and never VERIFIED.
|
|
6
6
|
import { createHash, createPublicKey, verify, type KeyObject } from "node:crypto";
|
|
7
7
|
import { readFileSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
8
10
|
import type { EventEnvelope } from "../../../../loki-ts/src/engine10/types.ts";
|
|
9
11
|
|
|
10
12
|
export interface RunIntegrity {
|
|
@@ -16,10 +18,10 @@ export interface RunIntegrity {
|
|
|
16
18
|
sig_checked: boolean;
|
|
17
19
|
reasons: string[];
|
|
18
20
|
}
|
|
19
|
-
export type KeyResolver = ((kid: string) => KeyObject | undefined) & { configured?: boolean; fingerprint?: string };
|
|
21
|
+
export type KeyResolver = ((kid: string) => KeyObject | undefined) & { configured?: boolean; strict?: boolean; reason?: string; fingerprint?: string };
|
|
20
22
|
export interface IntegrityOpts {
|
|
21
23
|
pubkeyFor?: KeyResolver;
|
|
22
|
-
/**
|
|
24
|
+
/** Strict key set (LOKI_CP_RECEIPT_PUBKEYS declared): an unknown kid is TAMPERED, as in the engine's checkLogSeal. Defaults to pubkeyFor.strict, else pubkeyFor.configured. */
|
|
23
25
|
keysConfigured?: boolean;
|
|
24
26
|
}
|
|
25
27
|
|
|
@@ -36,14 +38,51 @@ const normVerdict = (v: string | null | undefined): string | null => (typeof v =
|
|
|
36
38
|
/** Same derivation as loki-ts/src/engine10/stages/seal.ts kidOf (parity-tested). */
|
|
37
39
|
export const kidOf = (pub: KeyObject): string => createHash("sha256").update(`{"crv":"Ed25519","kty":"OKP","x":"${pub.export({ format: "jwk" }).x}"}`).digest("base64url");
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
type Env = Record<string, string | undefined>;
|
|
42
|
+
const isEd25519 = (k: KeyObject): boolean => k.asymmetricKeyType === "ed25519";
|
|
43
|
+
|
|
44
|
+
/** The local signer's PUBLIC half, found in the order seal.ts loadSigningKey uses: inline LOKI_RECEIPT_SIGNING_KEY, then LOKI_RECEIPT_SIGNING_KEY_FILE,
|
|
45
|
+
* then $HOME/.loki/keys/receipt-ed25519.pem. There is no separate public file on disk, so the public KeyObject is derived in memory (createPublicKey)
|
|
46
|
+
* and only that derived object is kept. Unlike loadSigningKey this never chmods, creates or generates anything, and never retains, logs or returns
|
|
47
|
+
* private material. An absent default-path key means no key and no reason; one that is named but unreadable or not Ed25519 is reported in `reason`. */
|
|
48
|
+
export function localKeyInfo(env: Env = process.env): { key?: KeyObject; reason?: string } {
|
|
49
|
+
const inline = env["LOKI_RECEIPT_SIGNING_KEY"]?.trim();
|
|
50
|
+
const given = env["LOKI_RECEIPT_SIGNING_KEY_FILE"]?.trim();
|
|
51
|
+
try {
|
|
52
|
+
let pub: KeyObject;
|
|
53
|
+
if (inline) pub = createPublicKey(inline);
|
|
54
|
+
else {
|
|
55
|
+
const file = given || join(env["HOME"] || homedir(), ".loki", "keys", "receipt-ed25519.pem");
|
|
56
|
+
try { pub = createPublicKey(readFileSync(file)); } catch (e) {
|
|
57
|
+
if (!given && (e as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
58
|
+
throw e;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return isEd25519(pub) ? { key: pub } : { reason: "local signing key is not an Ed25519 key" };
|
|
62
|
+
} catch (e) {
|
|
63
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
64
|
+
return { reason: `local signing key ${inline ? "(LOKI_RECEIPT_SIGNING_KEY)" : given ? "file" : "default file"} could not be used: ${code ?? "malformed PEM"}` };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export const localPublicKey = (env: Env = process.env): KeyObject | undefined => localKeyInfo(env).key;
|
|
68
|
+
|
|
69
|
+
const pemList = (raw: string | undefined): KeyObject[] => (raw ?? "").split(":").map((f) => f.trim()).filter(Boolean).flatMap((f) => {
|
|
70
|
+
try { const k = createPublicKey(readFileSync(f)); return isEd25519(k) ? [k] : []; } catch { return []; }
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** Verification keys by kid: the local signer's public half (automatic), LOKI_RECEIPT_RETIRED_PUBKEYS (trusted, as `loki verify` honours them) and
|
|
74
|
+
* LOKI_CP_RECEIPT_PUBKEYS (colon-separated Ed25519 public PEM files for remote/team keys). Unreadable or non-Ed25519 files are skipped.
|
|
75
|
+
* `strict` is true only when LOKI_CP_RECEIPT_PUBKEYS names at least one usable key: then an unknown kid is TAMPERED (the operator declared the key set).
|
|
76
|
+
* With only implicit keys an unknown kid (CI, another machine, a rotation) is NOT CHECKED, as `loki verify` treats it. */
|
|
77
|
+
export function pubkeysFromEnv(env: Env = process.env): KeyResolver {
|
|
78
|
+
const info = localKeyInfo(env);
|
|
79
|
+
const explicit = pemList(env["LOKI_CP_RECEIPT_PUBKEYS"]);
|
|
80
|
+
const keys = [...(info.key ? [info.key] : []), ...pemList(env["LOKI_RECEIPT_RETIRED_PUBKEYS"]), ...explicit];
|
|
44
81
|
const fn: KeyResolver = (kid) => keys.find((k) => kidOf(k) === kid);
|
|
45
82
|
fn.configured = keys.length > 0;
|
|
46
|
-
fn.
|
|
83
|
+
fn.strict = explicit.length > 0;
|
|
84
|
+
if (info.reason) fn.reason = info.reason;
|
|
85
|
+
fn.fingerprint = createHash("sha256").update(keys.map(kidOf).sort().join(",") + (fn.strict ? "|strict" : "")).digest("hex").slice(0, 16); // key set and mode a row was judged under
|
|
47
86
|
return fn;
|
|
48
87
|
}
|
|
49
88
|
|
|
@@ -63,7 +102,7 @@ export function verifyRunIntegrity(evs: readonly EventEnvelope[], opts: Integrit
|
|
|
63
102
|
const sealedAt = evs.map((e, i) => (e.type === "receipt.sealed" ? i : -1)).filter((i) => i >= 0);
|
|
64
103
|
const finalised = doneAt >= 0 || sealedAt.length > 0;
|
|
65
104
|
const lines = evs.map(line);
|
|
66
|
-
const keysConfigured = opts.keysConfigured ?? opts.pubkeyFor?.configured ?? false;
|
|
105
|
+
const keysConfigured = opts.keysConfigured ?? opts.pubkeyFor?.strict ?? opts.pubkeyFor?.configured ?? false;
|
|
67
106
|
let sigChecked = false;
|
|
68
107
|
|
|
69
108
|
if (finalised) { // a live run may still be missing a batch; once it claims an end, every seq must be present
|
|
@@ -13,12 +13,16 @@ export interface Stats {
|
|
|
13
13
|
runs_running: number;
|
|
14
14
|
by_verdict: Record<string, number>;
|
|
15
15
|
blocked_waiting: number;
|
|
16
|
-
/** Plain VERIFIED (attested, signature-checked) over finished
|
|
16
|
+
/** Plain VERIFIED (attested, signature-checked) over the VERIFIABLE finished outcomes (verified, signature-unchecked verified, failed); ALREADY_SATISFIED and other outcomes are not in the denominator. Null when there are none. */
|
|
17
17
|
verified_rate: number | null;
|
|
18
|
+
/** Finished runs by outcome class: verified (checked), failed (FAILED, PARTIAL, TAMPERED), already_satisfied (either signature state), other (SPEC_CONFLICT, UNVERIFIED, unattested, anything else). Unchecked verified runs are in verified_unchecked. */
|
|
19
|
+
breakdown: { verified: number; failed: number; already_satisfied: number; other: number };
|
|
18
20
|
/** Finished successes whose signature could not be checked (VERIFIED (signature not checked)); a rate of 0 with these is "not measured", not "failing". */
|
|
19
21
|
verified_unchecked: number;
|
|
20
|
-
/** At least one receipt verification key is configured (LOKI_CP_RECEIPT_PUBKEYS). */
|
|
22
|
+
/** At least one receipt verification key is configured (the local signer key or LOKI_CP_RECEIPT_PUBKEYS). */
|
|
21
23
|
keys_configured: boolean;
|
|
24
|
+
/** Present when a named local signing key could not be used (unreadable, malformed, not Ed25519); keys_configured then reflects the remaining keys. */
|
|
25
|
+
keys_reason?: string;
|
|
22
26
|
cost: {
|
|
23
27
|
/** Sum over runs whose cost is fully measured; null when no run in the window is. */
|
|
24
28
|
measured_usd: number | null;
|
|
@@ -34,7 +38,7 @@ export interface Stats {
|
|
|
34
38
|
export function computeStats(db: Db, since: string | null): Stats {
|
|
35
39
|
const all = db.select().from(runs).all().filter((r) => !since || (r.startedAt !== null && r.startedAt >= since));
|
|
36
40
|
const by: Record<string, number> = {};
|
|
37
|
-
let finished = 0, running = 0, blocked = 0,
|
|
41
|
+
let finished = 0, running = 0, blocked = 0, verified = 0, unchecked = 0, failed = 0, satisfied = 0, other = 0;
|
|
38
42
|
let mUsd = 0, mRuns = 0, pUsd = 0, pRuns = 0;
|
|
39
43
|
for (const r of all) {
|
|
40
44
|
if (r.endedAt) finished++; else running++;
|
|
@@ -42,7 +46,13 @@ export function computeStats(db: Db, since: string | null): Stats {
|
|
|
42
46
|
const verdict = r.verdict ? effectiveVerdict({ verdict: r.verdict, tampered: r.tampered === 1, attested: r.attested === 1, sig_checked: r.sigChecked === 1 }) : null;
|
|
43
47
|
if (verdict) {
|
|
44
48
|
by[verdict] = (by[verdict] ?? 0) + 1;
|
|
45
|
-
if (r.endedAt) {
|
|
49
|
+
if (r.endedAt) {
|
|
50
|
+
if (verdict === "VERIFIED") verified++;
|
|
51
|
+
else if (verdict === "VERIFIED (signature not checked)") unchecked++;
|
|
52
|
+
else if (verdict === "ALREADY_SATISFIED" || verdict === "ALREADY_SATISFIED (signature not checked)") satisfied++;
|
|
53
|
+
else if (verdict === "FAILED" || verdict === "PARTIAL" || verdict === "TAMPERED") failed++;
|
|
54
|
+
else other++;
|
|
55
|
+
}
|
|
46
56
|
}
|
|
47
57
|
if (r.verdict === "SPEC_CONFLICT") blocked++;
|
|
48
58
|
if (r.costUsd !== null) { mUsd += r.costUsd; mRuns++; }
|
|
@@ -58,7 +68,8 @@ export function computeStats(db: Db, since: string | null): Stats {
|
|
|
58
68
|
const r6 = (n: number): number => Math.round(n * 1e6) / 1e6;
|
|
59
69
|
return {
|
|
60
70
|
since, runs_total: all.length, runs_finished: finished, runs_running: running, by_verdict: by, blocked_waiting: blocked,
|
|
61
|
-
verified_rate:
|
|
71
|
+
verified_rate: verified + unchecked + failed ? verified / (verified + unchecked + failed) : null,
|
|
72
|
+
breakdown: { verified, failed, already_satisfied: satisfied, other }, verified_unchecked: unchecked, keys_configured: pubkeysFromEnv().configured === true, ...(pubkeysFromEnv().reason ? { keys_reason: pubkeysFromEnv().reason } : {}),
|
|
62
73
|
cost: {
|
|
63
74
|
measured_usd: mRuns ? r6(mUsd) : null, measured_runs: mRuns, partial_usd: pRuns ? r6(pUsd) : null, partial_runs: pRuns,
|
|
64
75
|
label: pRuns ? "partial" : mRuns ? "measured" : "not measured",
|
|
@@ -55,9 +55,16 @@ const parseRun = (r: typeof runs.$inferSelect) => ({
|
|
|
55
55
|
effective_verdict: effectiveVerdict({ verdict: r.verdict, tampered: r.tampered === 1, attested: r.attested === 1, sig_checked: r.sigChecked === 1 }),
|
|
56
56
|
status: r.endedAt ? "completed" : "running", elapsed_s: elapsedS(r),
|
|
57
57
|
});
|
|
58
|
+
/** The task title the intake stage recorded (its `title`, else the first line of `task`), for sidebar rows of runs that have no issue ref. Null when intake has not completed. */
|
|
59
|
+
export function taskTitle(db: Db, sourceId: string, runId: string): string | null {
|
|
60
|
+
const d = db.select({ data: events.data }).from(events).where(and(eq(events.sourceId, sourceId), eq(events.runId, runId), eq(events.type, "stage.completed"), eq(events.stage, "intake"))).orderBy(asc(events.seq)).limit(1).get()?.data as Record<string, unknown> | undefined;
|
|
61
|
+
const raw = str(d?.["title"]) ?? str(d?.["task"]);
|
|
62
|
+
const t = raw?.split("\n")[0]?.trim();
|
|
63
|
+
return t ? (t.length > 140 ? `${t.slice(0, 139)}...` : t) : null;
|
|
64
|
+
}
|
|
58
65
|
// A run with no run.completed yet is running: add its live fields (stage, files) from the stored events.
|
|
59
66
|
const withLive = (db: Db, r: typeof runs.$inferSelect) => {
|
|
60
|
-
const p = parseRun(r);
|
|
67
|
+
const p = { ...parseRun(r), title: taskTitle(db, r.sourceId, r.runId) };
|
|
61
68
|
return r.endedAt ? { ...p, current_stage: null, files_touched: [] as string[] } : { ...p, ...liveInfo(loadEvents(db, r.sourceId, r.runId)) };
|
|
62
69
|
};
|
|
63
70
|
|
|
@@ -139,13 +146,18 @@ export function runDetail(db: Db, sourceId: string, runId: string) {
|
|
|
139
146
|
const r = db.select().from(runs).where(and(eq(runs.sourceId, sourceId), eq(runs.runId, runId))).get();
|
|
140
147
|
if (!r) return null;
|
|
141
148
|
const evs = loadEvents(db, sourceId, runId);
|
|
142
|
-
const stages: { stage: string; started_at: string | null; ended_at: string | null; status: string }[] = [];
|
|
149
|
+
const stages: { stage: string; started_at: string | null; ended_at: string | null; status: string; reason: string | null }[] = [];
|
|
143
150
|
for (const e of evs) {
|
|
144
151
|
if (e.stage === null || !e.type.startsWith("stage.")) continue;
|
|
145
152
|
let s = stages.find((x) => x.stage === e.stage);
|
|
146
|
-
if (!s) stages.push((s = { stage: e.stage, started_at: null, ended_at: null, status: "started" }));
|
|
153
|
+
if (!s) stages.push((s = { stage: e.stage, started_at: null, ended_at: null, status: "started", reason: null }));
|
|
147
154
|
if (e.type === "stage.started") s.started_at = e.ts;
|
|
148
|
-
else {
|
|
155
|
+
else {
|
|
156
|
+
s.ended_at = e.ts; s.status = e.type.slice("stage.".length);
|
|
157
|
+
// The engine records why a stage was skipped or failed (stage.skipped data.reason); nothing is invented when it did not.
|
|
158
|
+
const why = (e.data as Record<string, unknown> | null)?.reason;
|
|
159
|
+
s.reason = typeof why === "string" && why ? why : null;
|
|
160
|
+
}
|
|
149
161
|
}
|
|
150
162
|
const f = fold(evs);
|
|
151
163
|
const done = f.run.completed?.data;
|