loki-mode 10.7.0 → 10.7.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 +1 -0
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/docs/v10/CONTROL-PLANE.md +7 -0
- package/docs/v10/DECISIONS.md +41 -0
- package/docs/v10/ENGINE-LAWS.md +155 -0
- package/docs/v10/FAILURE-CLASSES.md +145 -0
- package/docs/v10/GUIDE.md +4 -2
- package/docs/v10/L0-WAVE1.md +81 -0
- package/docs/v10/MORNING-BRIEF-1003.md +11 -25
- package/docs/v10/OPERATING-MODEL.md +1 -0
- package/docs/v10/PROGRESS.md +28 -0
- package/loki-ts/dist/loki.js +802 -781
- package/loki-ts/package.json +1 -0
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/packages/control-plane/dist/server.js +293 -19
- package/packages/control-plane/drizzle/0002_tiresome_fixer.sql +7 -0
- package/packages/control-plane/drizzle/0003_integrity.sql +4 -0
- package/packages/control-plane/drizzle/meta/0002_snapshot.json +409 -0
- package/packages/control-plane/drizzle/meta/0003_snapshot.json +437 -0
- package/packages/control-plane/drizzle/meta/_journal.json +14 -0
- package/packages/control-plane/src/db/migrate.ts +6 -2
- package/packages/control-plane/src/db/prune.ts +91 -0
- package/packages/control-plane/src/db/schema.ts +13 -0
- package/packages/control-plane/src/server/app.ts +20 -1
- package/packages/control-plane/src/server/integrity.ts +147 -0
- package/packages/control-plane/src/server/runs.ts +46 -4
- package/packages/control-plane/src/shipper/discover.ts +32 -3
- package/packages/control-plane/src/shipper/ship.ts +15 -0
- package/packages/control-plane/src/shipper/watch.ts +3 -1
- package/packages/control-plane/ui/dist/assets/index-DHDI_dHM.css +1 -0
- package/packages/control-plane/ui/dist/assets/index-DaQr0MPS.js +129 -0
- package/packages/control-plane/ui/dist/index.html +2 -2
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/web-app/dist/assets/{AdminPage-BQPb5rc5.js → AdminPage-BJSU5hY1.js} +1 -1
- package/web-app/dist/assets/{Avatar-Cttco6FC.js → Avatar-CSOSpw9c.js} +1 -1
- package/web-app/dist/assets/{Badge-CPb6kpbH.js → Badge-COr7vj0D.js} +1 -1
- package/web-app/dist/assets/{Button-caTL28a4.js → Button-DGoWFERp.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-bEbLx6ph.js → CockpitPage-CBpWkNXY.js} +1 -1
- package/web-app/dist/assets/{ComparePage-DNtJSij-.js → ComparePage-BwT8vqrL.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-BrgC3K44.js → ErrorBoundary-D7SicDDA.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-Q-mSrUUQ.js → EvidenceReceiptPanel-bx-5liG2.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-DbmYl_Er.js → GitHubIssuesPanel-BAeeWLqY.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-C6PCHM4B.js → GitHubPRsPanel-CssD8lGI.js} +1 -1
- package/web-app/dist/assets/{HomePage-7zsL_k_e.js → HomePage-SL5PT3m5.js} +2 -2
- package/web-app/dist/assets/{LoginPage-Cl4ON8RQ.js → LoginPage-C8hFOZOM.js} +1 -1
- package/web-app/dist/assets/{MagicPage-BNEAhTqH.js → MagicPage-xHbHkrPf.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-DyBH-88q.js → MetricsPage-B_2CKFcC.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-CIVr21yL.js → NotFoundPage-CXwXMq35.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-BMeVYocF.js → ProjectPage-AaisMQI-.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-D-H2WRVU.js → ProjectsPage-Cej5ResX.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-o0kR9RAt.js → SettingsPage-DKv4mn9H.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-DtfMDMjp.js → ShowcasePage-Dkiyzne4.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-CkXdpZ_T.js → SystemSettingsPage-DaBLQ5dU.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-Cu5H9cQ0.js → TeamsPage-B2yUr31k.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-3wdbVwil.js → TemplatesPage-CfYiANhU.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-CWn301xh.js → TerminalOutput-CMXoexLM.js} +1 -1
- package/web-app/dist/assets/{activity-YGaAt4zJ.js → activity-B0dzoZtY.js} +1 -1
- package/web-app/dist/assets/{bell-C28q0GO7.js → bell-DFOTaSoo.js} +1 -1
- package/web-app/dist/assets/{bot-DLcQGI92.js → bot-Dz-WSQyP.js} +1 -1
- package/web-app/dist/assets/{check-w0sBNlcm.js → check-L1QIRueJ.js} +1 -1
- package/web-app/dist/assets/{chevron-left-W7O0vC_m.js → chevron-left-1TrnIEZS.js} +1 -1
- package/web-app/dist/assets/{circle-alert-Cxjq9fxB.js → circle-alert-8Bf6xEDx.js} +1 -1
- package/web-app/dist/assets/{clock-C8EqzmNl.js → clock-DB8NE6P5.js} +1 -1
- package/web-app/dist/assets/{cloud-xNgp4g3Y.js → cloud-DUSL3wZS.js} +1 -1
- package/web-app/dist/assets/{code-xml-B-lyNDLJ.js → code-xml-DbjryWNl.js} +1 -1
- package/web-app/dist/assets/{database-DoW5lmWc.js → database-DrezrMSs.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-BcARA5sC.js → dollar-sign-CGbaLMzR.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-CehcII0J.js → file-code-corner-Dfypa-r4.js} +1 -1
- package/web-app/dist/assets/{file-plus-DI9Zo5hP.js → file-plus-CzV5L2Gd.js} +1 -1
- package/web-app/dist/assets/{globe-CiZWBk3H.js → globe-BoYjTwPQ.js} +1 -1
- package/web-app/dist/assets/{hammer-3qv_T2N-.js → hammer-WO-HuiyJ.js} +1 -1
- package/web-app/dist/assets/{index-gF9grAz-.js → index-CjIOUjnd.js} +3 -3
- package/web-app/dist/assets/{layers-CN7d0By9.js → layers-Bn6jtKNk.js} +1 -1
- package/web-app/dist/assets/{loader-circle-DywcM-im.js → loader-circle-gERWRnmW.js} +1 -1
- package/web-app/dist/assets/{lock-_hdSmkU4.js → lock-BIiMLJq8.js} +1 -1
- package/web-app/dist/assets/{package-BV5zgqxJ.js → package-dLvDSVys.js} +1 -1
- package/web-app/dist/assets/{plus-CU_Hh4-x.js → plus-BHM3ZLhZ.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-DMK-fNl-.js → refresh-cw-BAp_alv6.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-CEB3_v5_.js → rotate-ccw-CBhwq_MG.js} +1 -1
- package/web-app/dist/assets/{scroll-text-BSdXxcRN.js → scroll-text-DoS-6dfU.js} +1 -1
- package/web-app/dist/assets/{server-CAMlP1yJ.js → server-5JgdDWQO.js} +1 -1
- package/web-app/dist/assets/{shield-alert-B-r1Dt3h.js → shield-alert-DjmlLT34.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-CYfxw9o4.js → thumbs-up-BtiEXl3p.js} +1 -1
- package/web-app/dist/assets/{trash-2-DX3nGPS8.js → trash-2-ClsWU_Vg.js} +1 -1
- package/web-app/dist/assets/{trending-down-Bt-hSntd.js → trending-down-DPS4M7_X.js} +1 -1
- package/web-app/dist/assets/{trending-up-DVSlvQp8.js → trending-up-D6OJ38-V.js} +1 -1
- package/web-app/dist/assets/{upload-D45RRHAP.js → upload-DOCzLUpF.js} +1 -1
- package/web-app/dist/assets/{usePolling-fq_Xj8iw.js → usePolling-CljQTWj_.js} +1 -1
- package/web-app/dist/assets/{user-BWcJkLlr.js → user-C0G82Weu.js} +1 -1
- package/web-app/dist/index.html +1 -1
- package/packages/control-plane/ui/dist/assets/index-CUQ65vJY.css +0 -1
- package/packages/control-plane/ui/dist/assets/index-CwwGfp6E.js +0 -129
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// EL-FC08b (D86, FC-08, L2): integrity of an INGESTED event log, run on every rebuild of a run's projection.
|
|
2
|
+
// Pure: no db. The same checks `loki verify` makes on events.jsonl (loki-ts/src/engine10/verify_cmd.ts: checkEventLog, checkLogSeal),
|
|
3
|
+
// restated over stored envelopes. Option taken for sharing: a CP-local module plus test/server/integrity-parity.test.ts, which runs both
|
|
4
|
+
// implementations over the same fixtures, including signed ones (unknown kid, mismatched kid, appended run.completed), so they cannot drift.
|
|
5
|
+
// TAMPERED means positive evidence of forgery. A log the CP cannot check (redacted before ingest) is UNVERIFIED, never TAMPERED and never VERIFIED.
|
|
6
|
+
import { createHash, createPublicKey, verify, type KeyObject } from "node:crypto";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import type { EventEnvelope } from "../../../../loki-ts/src/engine10/types.ts";
|
|
9
|
+
|
|
10
|
+
export interface RunIntegrity {
|
|
11
|
+
/** Positive evidence of forgery, loss or edit. Always shown as TAMPERED. */
|
|
12
|
+
tampered: boolean;
|
|
13
|
+
/** A VERIFIED claim backed by a signed receipt.sealed and a sealing line, with a log that could be checked. False means unattested (shown UNVERIFIED). */
|
|
14
|
+
attested: boolean;
|
|
15
|
+
/** The sealing line's signature was verified against a configured public key. False: shape and hash chain only. */
|
|
16
|
+
sig_checked: boolean;
|
|
17
|
+
reasons: string[];
|
|
18
|
+
}
|
|
19
|
+
export type KeyResolver = ((kid: string) => KeyObject | undefined) & { configured?: boolean; fingerprint?: string };
|
|
20
|
+
export interface IntegrityOpts {
|
|
21
|
+
pubkeyFor?: KeyResolver;
|
|
22
|
+
/** At least one key is configured: an unknown kid is then TAMPERED, as in the engine's checkLogSeal. Defaults to pubkeyFor.configured. */
|
|
23
|
+
keysConfigured?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SHA = /^[0-9a-f]{64}$/;
|
|
27
|
+
const SEAL_LINE = "log.sealed";
|
|
28
|
+
const REDACTED = /\[REDACTED(?::[A-Z_]+)?\]/;
|
|
29
|
+
export const REDACTED_NOTE = "log redacted before ingest; seal not checkable";
|
|
30
|
+
export const UNCHECKED_SIG = "VERIFIED (signature not checked)";
|
|
31
|
+
export const UNATTESTED_SUFFIX = " (unattested)";
|
|
32
|
+
/** Outcomes that claim success: each needs a signed seal, exactly like VERIFIED. */
|
|
33
|
+
export const SUCCESS_VERDICTS: ReadonlySet<string> = new Set(["VERIFIED", "ALREADY_SATISFIED"]);
|
|
34
|
+
const normVerdict = (v: string | null | undefined): string | null => (typeof v === "string" ? v.trim().toUpperCase() : null);
|
|
35
|
+
|
|
36
|
+
/** Same derivation as loki-ts/src/engine10/stages/seal.ts kidOf (parity-tested). */
|
|
37
|
+
export const kidOf = (pub: KeyObject): string => createHash("sha256").update(`{"crv":"Ed25519","kty":"OKP","x":"${pub.export({ format: "jwk" }).x}"}`).digest("base64url");
|
|
38
|
+
|
|
39
|
+
/** Public keys from LOKI_CP_RECEIPT_PUBKEYS (colon-separated PEM files, like `loki verify` retired keys), by kid. Unreadable files are skipped. */
|
|
40
|
+
export function pubkeysFromEnv(env: Record<string, string | undefined> = process.env): KeyResolver {
|
|
41
|
+
const keys = (env["LOKI_CP_RECEIPT_PUBKEYS"] ?? "").split(":").map((f) => f.trim()).filter(Boolean).flatMap((f) => {
|
|
42
|
+
try { return [createPublicKey(readFileSync(f))]; } catch { return []; }
|
|
43
|
+
});
|
|
44
|
+
const fn: KeyResolver = (kid) => keys.find((k) => kidOf(k) === kid);
|
|
45
|
+
fn.configured = keys.length > 0;
|
|
46
|
+
fn.fingerprint = createHash("sha256").update(keys.map(kidOf).sort().join(",")).digest("hex").slice(0, 16); // identifies the key set a row was judged under
|
|
47
|
+
return fn;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The engine writes JSON.stringify(envelope) in this key order, so a stored envelope re-serialises to the bytes it was hashed as.
|
|
51
|
+
const line = (e: EventEnvelope): string => JSON.stringify({ v: e.v, seq: e.seq, ts: e.ts, run: e.run, type: e.type, stage: e.stage, data: e.data });
|
|
52
|
+
const verdictOf = (e: EventEnvelope | undefined): string | null => (typeof e?.data["verdict"] === "string" ? e.data["verdict"].trim().toUpperCase() : null);
|
|
53
|
+
|
|
54
|
+
/** Events up to and including the sealing line (the authenticated prefix); everything when the run has none. Lines after it are unauthenticated. */
|
|
55
|
+
export function sealedPrefix<T extends { type: string }>(evs: readonly T[]): T[] {
|
|
56
|
+
const i = evs.findIndex((e) => e.type === SEAL_LINE);
|
|
57
|
+
return i < 0 ? [...evs] : evs.slice(0, i + 1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function verifyRunIntegrity(evs: readonly EventEnvelope[], opts: IntegrityOpts = {}): RunIntegrity {
|
|
61
|
+
const bad: string[] = [], notes: string[] = [];
|
|
62
|
+
const doneAt = evs.findIndex((e) => e.type === "run.completed");
|
|
63
|
+
const sealedAt = evs.map((e, i) => (e.type === "receipt.sealed" ? i : -1)).filter((i) => i >= 0);
|
|
64
|
+
const finalised = doneAt >= 0 || sealedAt.length > 0;
|
|
65
|
+
const lines = evs.map(line);
|
|
66
|
+
const keysConfigured = opts.keysConfigured ?? opts.pubkeyFor?.configured ?? false;
|
|
67
|
+
let sigChecked = false;
|
|
68
|
+
|
|
69
|
+
if (finalised) { // a live run may still be missing a batch; once it claims an end, every seq must be present
|
|
70
|
+
const gap = evs.findIndex((e, i) => e.seq !== i);
|
|
71
|
+
if (gap >= 0) bad.push(`event log has a seq gap, duplicate or missing start at position ${gap} (seq ${evs[gap]!.seq})`);
|
|
72
|
+
}
|
|
73
|
+
if (evs.some((e) => e.type === "tamper.detected")) bad.push("the supervisor detected events.jsonl being modified during the run");
|
|
74
|
+
const firstSeal = evs.findIndex((e) => e.type === SEAL_LINE);
|
|
75
|
+
if (firstSeal >= 0 && evs.slice(firstSeal + 1).some((e) => e.type === "run.completed" || e.type === "receipt.sealed" || typeof e.data["verdict"] === "string")) bad.push("a verdict-bearing event after log.sealed is unauthenticated");
|
|
76
|
+
if (evs.filter((e) => e.type === "run.completed").length > 1) bad.push("more than one run.completed (a verdict appended after the seal is unauthenticated)");
|
|
77
|
+
|
|
78
|
+
// Hash problems are tamper evidence unless a redaction placeholder explains the changed bytes.
|
|
79
|
+
const hashBad: string[] = [];
|
|
80
|
+
const redacted = REDACTED.test(JSON.stringify(doneAt >= 0 ? evs.slice(0, doneAt + 2).map((e) => e.data) : evs.map((e) => e.data)));
|
|
81
|
+
|
|
82
|
+
for (const i of sealedAt) {
|
|
83
|
+
const d = evs[i]!.data;
|
|
84
|
+
if (typeof d["receipt_sha256"] !== "string" || !SHA.test(d["receipt_sha256"])) hashBad.push(`receipt.sealed at seq ${evs[i]!.seq} has no valid receipt_sha256`);
|
|
85
|
+
const bound = d["events_sha256"]; // optional on the event: the engine keeps it in receipt.json, which the CP never sees
|
|
86
|
+
if (typeof bound === "string") {
|
|
87
|
+
const h = createHash("sha256");
|
|
88
|
+
let ok = false;
|
|
89
|
+
for (const l of lines) { if (h.copy().digest("hex") === bound) { ok = true; break; } h.update(l + "\n"); }
|
|
90
|
+
if (!ok && h.digest("hex") === bound) ok = true;
|
|
91
|
+
if (!ok) hashBad.push("receipt.sealed events_sha256 does not match any prefix of the ingested log");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const sealLines = evs.map((e, i) => (e.type === SEAL_LINE ? i : -1)).filter((i) => i >= 0);
|
|
96
|
+
if (sealLines.some((i) => i !== doneAt + 1)) bad.push(`${SEAL_LINE} is not the line right after run.completed`);
|
|
97
|
+
const ls = doneAt >= 0 && evs[doneAt + 1]?.type === SEAL_LINE ? evs[doneAt + 1]!.data : null;
|
|
98
|
+
const signedSeals = sealedAt.map((i) => evs[i]!.data).filter((d) => d["signed"] === true);
|
|
99
|
+
const signedReceipt = signedSeals.length > 0;
|
|
100
|
+
if (ls) {
|
|
101
|
+
const sha = createHash("sha256").update(lines.slice(0, doneAt + 1).join("\n") + "\n").digest("hex");
|
|
102
|
+
const sig = typeof ls["sig"] === "string" ? Buffer.from(ls["sig"], "base64url") : null;
|
|
103
|
+
const kid = ls["kid"];
|
|
104
|
+
if (ls["tampered"] !== false) bad.push(`${SEAL_LINE} line records a tamper or is malformed`);
|
|
105
|
+
else if (typeof kid !== "string" || !sig || sig.length !== 64) hashBad.push(`${SEAL_LINE} line is malformed or records a tamper`);
|
|
106
|
+
else {
|
|
107
|
+
if (signedSeals.some((d) => typeof d["kid"] === "string" && d["kid"] !== kid)) hashBad.push(`${SEAL_LINE} kid differs from the receipt kid`);
|
|
108
|
+
if (ls["events_sha256"] !== sha) hashBad.push(`${SEAL_LINE} events_sha256 does not match the ingested log`);
|
|
109
|
+
else {
|
|
110
|
+
const pub = opts.pubkeyFor?.(kid);
|
|
111
|
+
if (pub) { sigChecked = true; if (!verify(null, Buffer.from(`${sha}:false`), pub, sig)) bad.push(`${SEAL_LINE} signature does not verify`); }
|
|
112
|
+
else if (keysConfigured) bad.push(`${SEAL_LINE} kid ${kid} has no configured public key`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} else if (signedReceipt && doneAt >= 0) bad.push(`signed receipt but no ${SEAL_LINE} line after run.completed`);
|
|
116
|
+
|
|
117
|
+
let unverifiable = false;
|
|
118
|
+
if (hashBad.length > 0) {
|
|
119
|
+
if (redacted) { unverifiable = true; sigChecked = false; notes.push(REDACTED_NOTE); } else bad.push(...hashBad);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// A VERIFIED claim (first run.completed only) needs a receipt sealed before it, for the same verdict.
|
|
123
|
+
let claimsVerified = false;
|
|
124
|
+
const claimed = doneAt >= 0 ? verdictOf(evs[doneAt]) : null;
|
|
125
|
+
if (claimed !== null && SUCCESS_VERDICTS.has(claimed)) {
|
|
126
|
+
claimsVerified = true;
|
|
127
|
+
const before = sealedAt.filter((i) => i < doneAt);
|
|
128
|
+
if (before.length === 0) bad.push(`run.completed claims ${claimed} but no receipt.sealed precedes it`);
|
|
129
|
+
else if (!before.some((i) => verdictOf(evs[i]) === claimed)) bad.push(`run.completed claims ${claimed} but the sealed receipt does not`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const tampered = bad.length > 0;
|
|
133
|
+
const attested = !tampered && !unverifiable && (!claimsVerified || (signedReceipt && ls !== null));
|
|
134
|
+
return { tampered, attested, sig_checked: sigChecked && !tampered, reasons: [...bad, ...notes] };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The one display verdict (FC-08). TAMPERED when the log failed integrity. When it is not attested, a success claim shows UNVERIFIED and any other verdict carries an
|
|
138
|
+
* "(unattested)" marker. An attested success whose seal no key checked shows "<verdict> (signature not checked)"; plain VERIFIED only for a signature-checked run.
|
|
139
|
+
* sig_checked undefined (older captures) reads as checked. */
|
|
140
|
+
export function effectiveVerdict(r: { verdict: string | null; tampered: boolean; attested?: boolean; sig_checked?: boolean }): string | null {
|
|
141
|
+
if (r.tampered) return "TAMPERED";
|
|
142
|
+
const v = normVerdict(r.verdict);
|
|
143
|
+
if (v === null) return r.verdict;
|
|
144
|
+
if (r.attested === false) return SUCCESS_VERDICTS.has(v) ? "UNVERIFIED" : `${r.verdict}${UNATTESTED_SUFFIX}`;
|
|
145
|
+
if (SUCCESS_VERDICTS.has(v)) return r.sig_checked === false ? `${v} (signature not checked)` : v;
|
|
146
|
+
return r.verdict;
|
|
147
|
+
}
|
|
@@ -4,6 +4,7 @@ import type { EventEnvelope } from "../../../../loki-ts/src/engine10/types.ts";
|
|
|
4
4
|
import type { Db } from "../db/migrate.ts";
|
|
5
5
|
import { events, runs } from "../db/schema.ts";
|
|
6
6
|
import { blockedQuestion } from "./answer.ts";
|
|
7
|
+
import { effectiveVerdict, pubkeysFromEnv, sealedPrefix, SUCCESS_VERDICTS, verifyRunIntegrity } from "./integrity.ts";
|
|
7
8
|
|
|
8
9
|
const str = (x: unknown): string | null => (typeof x === "string" ? x : null);
|
|
9
10
|
|
|
@@ -16,8 +17,11 @@ export function loadEvents(db: Db, sourceId: string, runId: string): EventEnvelo
|
|
|
16
17
|
export function rebuildRun(db: Db, sourceId: string, runId: string): void {
|
|
17
18
|
const evs = loadEvents(db, sourceId, runId);
|
|
18
19
|
if (evs.length === 0) return;
|
|
19
|
-
const f = fold(evs);
|
|
20
|
-
const
|
|
20
|
+
const f = fold(sealedPrefix(evs)); // the verdict comes from the authenticated prefix; lines after the sealing line are unauthenticated
|
|
21
|
+
const keys = pubkeysFromEnv();
|
|
22
|
+
const integ = verifyRunIntegrity(evs, { pubkeyFor: keys }); // EL-FC08b: tampered is the supervisor flag OR any ingest-side integrity failure
|
|
23
|
+
const tampered = f.run.tampered || integ.tampered;
|
|
24
|
+
const pc = partialCost(evs, tampered);
|
|
21
25
|
const sd = f.run.started?.data ?? {};
|
|
22
26
|
const done = f.run.completed?.data ?? {};
|
|
23
27
|
const pr = evs.find((e) => e.type === "pr.opened")?.data;
|
|
@@ -31,12 +35,15 @@ export function rebuildRun(db: Db, sourceId: string, runId: string): void {
|
|
|
31
35
|
costUsd: f.cost.usd, partialUsd: pc.usd, measuredSessions: pc.measured, totalSessions: pc.total,
|
|
32
36
|
inputTokens: f.cost.inputTokens, outputTokens: f.cost.outputTokens,
|
|
33
37
|
wallS: typeof done.wall_s === "number" ? done.wall_s : null,
|
|
34
|
-
lastSeq: f.lastSeq, lastEventAt: evs[evs.length - 1]?.ts ?? null, tampered: Number(
|
|
38
|
+
lastSeq: f.lastSeq, lastEventAt: evs[evs.length - 1]?.ts ?? null, tampered: Number(tampered),
|
|
39
|
+
attested: Number(integ.attested), sigChecked: Number(integ.sig_checked), integrityKeyFp: keys.fingerprint ?? "", integrityReasons: JSON.stringify(integ.reasons),
|
|
35
40
|
};
|
|
36
41
|
// conflict is owned by ingest, so it is left out of the update set and survives a rebuild
|
|
37
42
|
db.insert(runs).values(row).onConflictDoUpdate({ target: [runs.sourceId, runs.runId], set: row }).run();
|
|
38
43
|
}
|
|
39
44
|
|
|
45
|
+
const reasonsOf = (j: string | null): string[] => { try { const x = JSON.parse(j ?? "[]"); return Array.isArray(x) ? x.filter((s): s is string => typeof s === "string") : []; } catch { return []; } };
|
|
46
|
+
|
|
40
47
|
const parseRun = (r: typeof runs.$inferSelect) => ({
|
|
41
48
|
source_id: r.sourceId, run_id: r.runId, origin_repo: r.originRepo, issue_ref: r.issueRef, task_source: r.taskSource,
|
|
42
49
|
group_id: r.groupId, unit_id: r.unitId, provider: r.provider, model: r.model, started_at: r.startedAt, ended_at: r.endedAt, verdict: r.verdict,
|
|
@@ -44,6 +51,8 @@ const parseRun = (r: typeof runs.$inferSelect) => ({
|
|
|
44
51
|
cost_usd: r.costUsd, partial_usd: r.partialUsd, measured_sessions: r.measuredSessions, total_sessions: r.totalSessions,
|
|
45
52
|
input_tokens: r.inputTokens, output_tokens: r.outputTokens, wall_s: r.wallS, last_seq: r.lastSeq,
|
|
46
53
|
last_event_at: r.lastEventAt, tampered: r.tampered === 1, conflict: r.conflict === 1,
|
|
54
|
+
attested: r.attested === 1, sig_checked: r.sigChecked === 1, integrity_reasons: reasonsOf(r.integrityReasons),
|
|
55
|
+
effective_verdict: effectiveVerdict({ verdict: r.verdict, tampered: r.tampered === 1, attested: r.attested === 1, sig_checked: r.sigChecked === 1 }),
|
|
47
56
|
status: r.endedAt ? "completed" : "running", elapsed_s: elapsedS(r),
|
|
48
57
|
});
|
|
49
58
|
// A run with no run.completed yet is running: add its live fields (stage, files) from the stored events.
|
|
@@ -75,13 +84,46 @@ const elapsedS = (r: typeof runs.$inferSelect): number | null => {
|
|
|
75
84
|
return Math.max(0, (end - Date.parse(r.startedAt)) / 1000);
|
|
76
85
|
};
|
|
77
86
|
|
|
87
|
+
/** FC-08: filters match the DISPLAY verdict. Plain VERIFIED (and ALREADY_SATISFIED) lists only attested, signature-checked runs; "<verdict> (signature not checked)" the attested
|
|
88
|
+
* rest; UNVERIFIED every non-tampered run that is not attested. */
|
|
89
|
+
const SIG_SUFFIX = " (signature not checked)";
|
|
90
|
+
const verdictFilter = (v: string | undefined) => {
|
|
91
|
+
if (!v) return undefined;
|
|
92
|
+
if (v === "TAMPERED") return eq(runs.tampered, 1);
|
|
93
|
+
if (v === "UNVERIFIED") return and(eq(runs.tampered, 0), sql`${runs.verdict} is not null`, sql`coalesce(${runs.attested}, 0) != 1`);
|
|
94
|
+
if (SUCCESS_VERDICTS.has(v)) return and(eq(runs.verdict, v), eq(runs.tampered, 0), eq(runs.attested, 1), eq(runs.sigChecked, 1));
|
|
95
|
+
if (v.endsWith(SIG_SUFFIX) && SUCCESS_VERDICTS.has(v.slice(0, -SIG_SUFFIX.length))) return and(eq(runs.verdict, v.slice(0, -SIG_SUFFIX.length)), eq(runs.tampered, 0), eq(runs.attested, 1), sql`coalesce(${runs.sigChecked}, 0) != 1`);
|
|
96
|
+
return and(eq(runs.verdict, v), eq(runs.tampered, 0), eq(runs.attested, 1));
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const RECOMPUTE_CHUNK = 200;
|
|
100
|
+
/** Boot-time recompute. Rows are re-judged when their integrity was never evaluated (attested IS NULL) or was evaluated under a different key set (stored fingerprint differs).
|
|
101
|
+
* Chunked, one transaction per chunk. A row whose events are gone is marked unattested with a reason and the current fingerprint, so it is not retried on every boot. */
|
|
102
|
+
export function recomputeLegacy(db: Db): number {
|
|
103
|
+
const fp = pubkeysFromEnv().fingerprint ?? "";
|
|
104
|
+
let n = 0;
|
|
105
|
+
for (;;) {
|
|
106
|
+
const rows = db.select({ s: runs.sourceId, r: runs.runId }).from(runs).where(sql`${runs.attested} is null or ${runs.integrityKeyFp} is not ${fp}`).limit(RECOMPUTE_CHUNK).all();
|
|
107
|
+
if (rows.length === 0) return n;
|
|
108
|
+
db.transaction((tx) => {
|
|
109
|
+
const t = tx as unknown as Db;
|
|
110
|
+
for (const x of rows) {
|
|
111
|
+
if (loadEvents(t, x.s, x.r).length === 0) {
|
|
112
|
+
t.update(runs).set({ attested: 0, sigChecked: 0, integrityKeyFp: fp, integrityReasons: JSON.stringify(["stored events are missing; integrity cannot be evaluated"]) }).where(and(eq(runs.sourceId, x.s), eq(runs.runId, x.r))).run();
|
|
113
|
+
} else rebuildRun(t, x.s, x.r);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
n += rows.length;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
78
120
|
export interface ListQuery { verdict?: string; repo?: string; since?: string; until?: string; group_id?: string; limit?: number; cursor?: string }
|
|
79
121
|
|
|
80
122
|
export function listRuns(db: Db, q: ListQuery) {
|
|
81
123
|
const limit = Math.min(Math.max(q.limit ?? 50, 1), 200);
|
|
82
124
|
const offset = Math.max(Number.parseInt(q.cursor ?? "0", 10) || 0, 0); // ponytail: cursor is an opaque offset, keyset when write rate makes pages shift
|
|
83
125
|
const where = and(
|
|
84
|
-
|
|
126
|
+
verdictFilter(q.verdict),
|
|
85
127
|
q.repo ? eq(runs.originRepo, q.repo) : undefined,
|
|
86
128
|
q.group_id ? eq(runs.groupId, q.group_id) : undefined,
|
|
87
129
|
q.since ? gte(runs.startedAt, q.since) : undefined,
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// C2 CP-DEFAULT: find a running local Control Plane (docs/v10/CONTROL-PLANE.md section 6). Reads
|
|
2
2
|
// ~/.loki/control/instance.json; returns its url only when the pid is alive and /health answers service=loki-control
|
|
3
3
|
// within 300 ms. Never starts a server, never throws. LOKI_CONTROL=0 disables discovery.
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
6
|
+
import { homedir, tmpdir } from "node:os";
|
|
7
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
7
8
|
|
|
8
9
|
export const instancePath = (env: NodeJS.ProcessEnv): string => join(env.HOME || homedir(), ".loki", "control", "instance.json");
|
|
9
10
|
|
|
@@ -27,3 +28,31 @@ export async function discoverControlUrl(env: NodeJS.ProcessEnv, o: DiscoverOpts
|
|
|
27
28
|
return h.service === "loki-control" ? url : null;
|
|
28
29
|
} catch { return null; }
|
|
29
30
|
}
|
|
31
|
+
|
|
32
|
+
// P0 guard: auto-discovery must never ship a throwaway or fixture repo to a developer's live Control Plane. An explicit
|
|
33
|
+
// LOKI_CONTROL_URL is an operator decision and is never filtered. LOKI_CONTROL_ALLOW_TMP=1 lets a sandboxed harness
|
|
34
|
+
// (its own HOME and its own server, repos under the temp dir) exercise discovery on purpose.
|
|
35
|
+
const FIXTURE_REPO = /(^|[/:])acme\/widget(\.git)?\/?$/i;
|
|
36
|
+
const FIXED_TMP_ROOTS = ["/tmp", "/private/tmp", "/var/folders", "/private/var/folders"];
|
|
37
|
+
|
|
38
|
+
const real = (p: string): string => { try { return realpathSync(p); } catch { return resolve(p); } };
|
|
39
|
+
const under = (p: string, root: string): boolean => p === root || p.startsWith(root.endsWith("/") ? root : `${root}/`);
|
|
40
|
+
|
|
41
|
+
export function isThrowawayPath(repoDir: string, tmpRoots: string[] = [real(tmpdir()), ...FIXED_TMP_ROOTS]): boolean {
|
|
42
|
+
const p = real(repoDir), raw = resolve(repoDir);
|
|
43
|
+
return tmpRoots.some((r) => under(p, r) || under(raw, r) || under(p, real(r)));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A reason when a discovered (non-explicit) Control Plane must not receive this repo's events, else null. */
|
|
47
|
+
export function discoveryRefusal(repoDir: string, originUrl: string | null, env: NodeJS.ProcessEnv = process.env, tmpRoots?: string[]): string | null {
|
|
48
|
+
if (env.LOKI_CONTROL_ALLOW_TMP === "1") return null;
|
|
49
|
+
if (isThrowawayPath(repoDir, tmpRoots)) return "repo is under the OS temp dir";
|
|
50
|
+
if (originUrl && FIXTURE_REPO.test(originUrl.trim())) return "origin is the fixture repo acme/widget";
|
|
51
|
+
if (FIXTURE_REPO.test(`${basename(dirname(repoDir))}/${basename(repoDir)}`)) return "repo name is the fixture acme/widget";
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The repo's origin url (env is threaded so the spawn never inherits ambient state), or null. */
|
|
56
|
+
export function gitOrigin(repoDir: string, env: NodeJS.ProcessEnv): string | null {
|
|
57
|
+
try { return execFileSync("git", ["-C", repoDir, "config", "--get", "remote.origin.url"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env }).trim() || null; } catch { return null; }
|
|
58
|
+
}
|
|
@@ -93,3 +93,18 @@ export async function shipRun(o: ShipOpts): Promise<ShipResult> {
|
|
|
93
93
|
|
|
94
94
|
export const shipEnabled = (env: NodeJS.ProcessEnv): string | null => env.LOKI_CONTROL_URL || null;
|
|
95
95
|
export const hasSpool = (runDir: string): boolean => existsSync(join(runDir, "events.jsonl"));
|
|
96
|
+
|
|
97
|
+
/** Fire-and-forget ship loop: an unref'd timer with backoff; never throws. */
|
|
98
|
+
export function startShipLoop(o: Omit<ShipOpts, "timeoutMs">): void {
|
|
99
|
+
let failures = 0, nextAt = 0, busy = false;
|
|
100
|
+
const tick = async (): Promise<void> => {
|
|
101
|
+
if (busy || Date.now() < nextAt) return;
|
|
102
|
+
busy = true;
|
|
103
|
+
try {
|
|
104
|
+
const r = await shipRun({ ...o, timeoutMs: 5000 });
|
|
105
|
+
failures = r.ok ? 0 : failures + 1;
|
|
106
|
+
nextAt = r.ok ? 0 : Date.now() + backoffMs(failures);
|
|
107
|
+
} catch { failures++; } finally { busy = false; }
|
|
108
|
+
};
|
|
109
|
+
setInterval(() => void tick(), 300).unref();
|
|
110
|
+
}
|
|
@@ -5,6 +5,7 @@ import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { backfill } from "./backfill.ts";
|
|
8
|
+
import { discoveryRefusal } from "./discover.ts";
|
|
8
9
|
import { shipRun, sourceId } from "./ship.ts";
|
|
9
10
|
|
|
10
11
|
/** Repo dirs from the dashboard project registry; never throws, a missing or corrupt registry means none. */
|
|
@@ -29,7 +30,8 @@ const sig = (f: string): string => { try { const s = statSync(f); return `${s.si
|
|
|
29
30
|
/** Backfill every repo, then watch. Resolves once the backfill is done; the watcher keeps polling until stop(). */
|
|
30
31
|
export async function ingestAndWatch(o: WatchOpts): Promise<Watcher> {
|
|
31
32
|
const env: NodeJS.ProcessEnv = { ...(o.env ?? process.env), LOKI_CONTROL_URL: o.url };
|
|
32
|
-
|
|
33
|
+
// P0: registry entries are third-party-registered paths; skip temp and fixture repos (the explicit repoDir is always kept).
|
|
34
|
+
const repos = [...new Set([o.repoDir, ...registryRepos(env).filter((r) => discoveryRefusal(r, null, env) === null)])];
|
|
33
35
|
const seen = new Map<string, string>(); // events.jsonl path -> size:mtime last shipped OK
|
|
34
36
|
for (const repoDir of repos) {
|
|
35
37
|
try { await backfill({ repoDir, env, attempts: 1, fetchImpl: o.fetchImpl }); } catch { /* one bad repo must not block the rest */ }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.ml-2{margin-left:.5rem}.ml-auto{margin-left:auto}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.min-h-11{min-height:2.75rem}.min-h-screen{min-height:100vh}.w-full{width:100%}.min-w-0{min-width:0px}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[auto_1fr\]{grid-template-columns:auto 1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-1{row-gap:.25rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.overflow-x-auto{overflow-x:auto}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-red-500\/50{border-color:#ef444480}.border-slate-200{--tw-border-opacity: 1;border-color:rgb(226 232 240 / var(--tw-border-opacity, 1))}.border-slate-300{--tw-border-opacity: 1;border-color:rgb(203 213 225 / var(--tw-border-opacity, 1))}.bg-amber-500\/15{background-color:#f59e0b26}.bg-emerald-500\/15{background-color:#10b98126}.bg-red-500\/15{background-color:#ef444426}.bg-red-600{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity, 1))}.bg-red-600\/20{background-color:#dc262633}.bg-sky-500\/15{background-color:#0ea5e926}.bg-sky-600{--tw-bg-opacity: 1;background-color:rgb(2 132 199 / var(--tw-bg-opacity, 1))}.bg-slate-100{--tw-bg-opacity: 1;background-color:rgb(241 245 249 / var(--tw-bg-opacity, 1))}.bg-slate-200{--tw-bg-opacity: 1;background-color:rgb(226 232 240 / var(--tw-bg-opacity, 1))}.bg-slate-500\/15{background-color:#64748b26}.bg-violet-500\/15{background-color:#8b5cf626}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-8{padding:2rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.pl-5{padding-left:1.25rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.text-amber-600{--tw-text-opacity: 1;color:rgb(217 119 6 / var(--tw-text-opacity, 1))}.text-amber-700{--tw-text-opacity: 1;color:rgb(180 83 9 / var(--tw-text-opacity, 1))}.text-emerald-600{--tw-text-opacity: 1;color:rgb(5 150 105 / var(--tw-text-opacity, 1))}.text-emerald-700{--tw-text-opacity: 1;color:rgb(4 120 87 / var(--tw-text-opacity, 1))}.text-red-600{--tw-text-opacity: 1;color:rgb(220 38 38 / var(--tw-text-opacity, 1))}.text-red-700{--tw-text-opacity: 1;color:rgb(185 28 28 / var(--tw-text-opacity, 1))}.text-sky-600{--tw-text-opacity: 1;color:rgb(2 132 199 / var(--tw-text-opacity, 1))}.text-sky-700{--tw-text-opacity: 1;color:rgb(3 105 161 / var(--tw-text-opacity, 1))}.text-slate-400{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.text-slate-500{--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity, 1))}.text-slate-600{--tw-text-opacity: 1;color:rgb(71 85 105 / var(--tw-text-opacity, 1))}.text-slate-900{--tw-text-opacity: 1;color:rgb(15 23 42 / var(--tw-text-opacity, 1))}.text-violet-700{--tw-text-opacity: 1;color:rgb(109 40 217 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.ring-1{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-amber-500\/30{--tw-ring-color: rgb(245 158 11 / .3)}.ring-emerald-500\/30{--tw-ring-color: rgb(16 185 129 / .3)}.ring-red-500\/30{--tw-ring-color: rgb(239 68 68 / .3)}.ring-red-600\/50{--tw-ring-color: rgb(220 38 38 / .5)}.ring-sky-500\/30{--tw-ring-color: rgb(14 165 233 / .3)}.ring-slate-500\/30{--tw-ring-color: rgb(100 116 139 / .3)}.ring-violet-500\/30{--tw-ring-color: rgb(139 92 246 / .3)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.hover\:underline:hover{text-decoration-line:underline}.disabled\:opacity-50:disabled{opacity:.5}.dark\:border-slate-700:is(.dark *){--tw-border-opacity: 1;border-color:rgb(51 65 85 / var(--tw-border-opacity, 1))}.dark\:border-slate-800:is(.dark *){--tw-border-opacity: 1;border-color:rgb(30 41 59 / var(--tw-border-opacity, 1))}.dark\:bg-slate-800:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity, 1))}.dark\:bg-slate-900:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(15 23 42 / var(--tw-bg-opacity, 1))}.dark\:bg-slate-950:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(2 6 23 / var(--tw-bg-opacity, 1))}.dark\:text-amber-300:is(.dark *){--tw-text-opacity: 1;color:rgb(252 211 77 / var(--tw-text-opacity, 1))}.dark\:text-emerald-300:is(.dark *){--tw-text-opacity: 1;color:rgb(110 231 183 / var(--tw-text-opacity, 1))}.dark\:text-emerald-400:is(.dark *){--tw-text-opacity: 1;color:rgb(52 211 153 / var(--tw-text-opacity, 1))}.dark\:text-red-300:is(.dark *){--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}.dark\:text-sky-300:is(.dark *){--tw-text-opacity: 1;color:rgb(125 211 252 / var(--tw-text-opacity, 1))}.dark\:text-sky-400:is(.dark *){--tw-text-opacity: 1;color:rgb(56 189 248 / var(--tw-text-opacity, 1))}.dark\:text-slate-100:is(.dark *){--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity, 1))}.dark\:text-slate-300:is(.dark *){--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.dark\:text-slate-400:is(.dark *){--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.dark\:text-violet-300:is(.dark *){--tw-text-opacity: 1;color:rgb(196 181 253 / var(--tw-text-opacity, 1))}@media(min-width:640px){.sm\:flex{display:flex}.sm\:flex-row{flex-direction:row}.sm\:flex-wrap{flex-wrap:wrap}.sm\:items-end{align-items:flex-end}}@media(min-width:768px){.md\:mb-6{margin-bottom:1.5rem}.md\:ml-0{margin-left:0}.md\:mt-6{margin-top:1.5rem}.md\:block{display:block}.md\:table-cell{display:table-cell}.md\:min-h-0{min-height:0px}.md\:w-48{width:12rem}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:border-b-0{border-bottom-width:0px}.md\:border-r{border-right-width:1px}.md\:p-4{padding:1rem}.md\:p-6{padding:1.5rem}.md\:px-2{padding-left:.5rem;padding-right:.5rem}.md\:px-3{padding-left:.75rem;padding-right:.75rem}.md\:py-0{padding-top:0;padding-bottom:0}.md\:py-1{padding-top:.25rem;padding-bottom:.25rem}}
|