loki-mode 10.7.0 → 10.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/loki +4 -1
- package/autonomy/run.sh +9 -5
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +6 -8
- package/docs/INSTALLATION.md +1 -1
- package/docs/v10/CONTROL-PLANE.md +7 -0
- package/docs/v10/CP-ENTERPRISE-UI.md +263 -2
- package/docs/v10/DECISIONS.md +41 -0
- package/docs/v10/ENGINE-LAWS.md +155 -0
- package/docs/v10/FAILURE-CLASSES.md +166 -0
- package/docs/v10/GUIDE.md +4 -2
- package/docs/v10/L0-WAVE1.md +81 -0
- package/docs/v10/METRICS.md +6 -0
- package/docs/v10/MORNING-BRIEF-1003.md +11 -25
- package/docs/v10/OPERATING-MODEL.md +1 -0
- package/docs/v10/PROGRESS.md +28 -0
- package/loki-ts/data/model-pricing.json +6 -6
- package/loki-ts/dist/loki.js +851 -837
- package/loki-ts/package.json +2 -1
- package/mcp/__init__.py +1 -1
- package/package.json +2 -2
- package/packages/control-plane/dist/server.js +17103 -5411
- package/packages/control-plane/drizzle/0002_tiresome_fixer.sql +7 -0
- package/packages/control-plane/drizzle/0003_integrity.sql +4 -0
- package/packages/control-plane/drizzle/0004_cpe03_local_repos_actions.sql +17 -0
- package/packages/control-plane/drizzle/meta/0002_snapshot.json +409 -0
- package/packages/control-plane/drizzle/meta/0003_snapshot.json +437 -0
- package/packages/control-plane/drizzle/meta/0004_snapshot.json +542 -0
- package/packages/control-plane/drizzle/meta/_journal.json +22 -1
- package/packages/control-plane/package.json +4 -1
- package/packages/control-plane/src/db/migrate.ts +6 -2
- package/packages/control-plane/src/db/prune.ts +91 -0
- package/packages/control-plane/src/db/schema.ts +32 -0
- package/packages/control-plane/src/server/app.ts +30 -22
- package/packages/control-plane/src/server/audit.ts +15 -0
- package/packages/control-plane/src/server/auth.ts +7 -1
- package/packages/control-plane/src/server/integrity.ts +147 -0
- package/packages/control-plane/src/server/legacy/routes-metrics.ts +18 -0
- package/packages/control-plane/src/server/legacy/routes.ts +237 -0
- package/packages/control-plane/src/server/legacy/shim.ts +195 -0
- package/packages/control-plane/src/server/repos.ts +27 -0
- package/packages/control-plane/src/server/routes/artifacts.ts +104 -0
- package/packages/control-plane/src/server/routes/audit.ts +17 -0
- package/packages/control-plane/src/server/routes/config.ts +319 -0
- package/packages/control-plane/src/server/routes/control.ts +75 -0
- package/packages/control-plane/src/server/routes/cost.ts +70 -0
- package/packages/control-plane/src/server/routes/cost_ledger.ts +210 -0
- package/packages/control-plane/src/server/routes/fleet.ts +101 -0
- package/packages/control-plane/src/server/routes/index.ts +68 -0
- package/packages/control-plane/src/server/routes/integrations.ts +123 -0
- package/packages/control-plane/src/server/routes/merge_risk.ts +209 -0
- package/packages/control-plane/src/server/routes/metrics.ts +93 -0
- package/packages/control-plane/src/server/routes/notify.ts +48 -0
- package/packages/control-plane/src/server/routes/providers.ts +111 -0
- package/packages/control-plane/src/server/routes/start.ts +39 -0
- package/packages/control-plane/src/server/routes/stats.ts +76 -0
- package/packages/control-plane/src/server/routes/stream.ts +85 -0
- package/packages/control-plane/src/server/routes/verify.ts +66 -0
- package/packages/control-plane/src/server/runs.ts +46 -4
- package/packages/control-plane/src/server/spawn.ts +79 -0
- package/packages/control-plane/src/shipper/discover.ts +50 -3
- package/packages/control-plane/src/shipper/ship.ts +15 -0
- package/packages/control-plane/src/shipper/watch.ts +3 -1
- package/packages/control-plane/ui/dist/assets/index-BQbFv65M.css +1 -0
- package/packages/control-plane/ui/dist/assets/index-Dbhu244h.js +155 -0
- package/packages/control-plane/ui/dist/index.html +2 -2
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/web-app/dist/assets/{AdminPage-BQPb5rc5.js → AdminPage-DDDvUz0N.js} +1 -1
- package/web-app/dist/assets/{Avatar-Cttco6FC.js → Avatar-Dcdz46i6.js} +1 -1
- package/web-app/dist/assets/{Badge-CPb6kpbH.js → Badge-BB24cCpK.js} +1 -1
- package/web-app/dist/assets/{Button-caTL28a4.js → Button-D0-DmPZL.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-bEbLx6ph.js → CockpitPage-CdjpU2JW.js} +1 -1
- package/web-app/dist/assets/{ComparePage-DNtJSij-.js → ComparePage-DT5U0y0d.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-BrgC3K44.js → ErrorBoundary-lEHcl0EO.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-Q-mSrUUQ.js → EvidenceReceiptPanel-GpnWvdCr.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-DbmYl_Er.js → GitHubIssuesPanel-C7VOh1B7.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-C6PCHM4B.js → GitHubPRsPanel-G_kOuINv.js} +1 -1
- package/web-app/dist/assets/{HomePage-7zsL_k_e.js → HomePage-PqRisOny.js} +2 -2
- package/web-app/dist/assets/{LoginPage-Cl4ON8RQ.js → LoginPage-DU7pFAkf.js} +1 -1
- package/web-app/dist/assets/{MagicPage-BNEAhTqH.js → MagicPage-C5JWIWMk.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-DyBH-88q.js → MetricsPage-CbIGS_qo.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-CIVr21yL.js → NotFoundPage-vU1pvdSt.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-BMeVYocF.js → ProjectPage-SsixIPPv.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-D-H2WRVU.js → ProjectsPage-BfQKSR9k.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-o0kR9RAt.js → SettingsPage-B8YfXAom.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-DtfMDMjp.js → ShowcasePage-Ba407f2-.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-CkXdpZ_T.js → SystemSettingsPage-DzNQhhBS.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-Cu5H9cQ0.js → TeamsPage-tqUnpZ15.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-3wdbVwil.js → TemplatesPage-ClB5EpI3.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-CWn301xh.js → TerminalOutput-CBvpYfEu.js} +1 -1
- package/web-app/dist/assets/{activity-YGaAt4zJ.js → activity-C1s0L_X9.js} +1 -1
- package/web-app/dist/assets/{bell-C28q0GO7.js → bell-DMEiXr73.js} +1 -1
- package/web-app/dist/assets/{bot-DLcQGI92.js → bot-BF_xIOxu.js} +1 -1
- package/web-app/dist/assets/{check-w0sBNlcm.js → check-Dv1Ynvrs.js} +1 -1
- package/web-app/dist/assets/{chevron-left-W7O0vC_m.js → chevron-left-Ddhsod7S.js} +1 -1
- package/web-app/dist/assets/{circle-alert-Cxjq9fxB.js → circle-alert-OATHUkBZ.js} +1 -1
- package/web-app/dist/assets/{clock-C8EqzmNl.js → clock-C1Ltva07.js} +1 -1
- package/web-app/dist/assets/{cloud-xNgp4g3Y.js → cloud-B-S4oOy9.js} +1 -1
- package/web-app/dist/assets/{code-xml-B-lyNDLJ.js → code-xml-D3Femn_F.js} +1 -1
- package/web-app/dist/assets/{database-DoW5lmWc.js → database-DBSeFgO3.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-BcARA5sC.js → dollar-sign-sgDgNuGE.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-CehcII0J.js → file-code-corner-C-MbdZEc.js} +1 -1
- package/web-app/dist/assets/{file-plus-DI9Zo5hP.js → file-plus-CCB6yqXv.js} +1 -1
- package/web-app/dist/assets/{globe-CiZWBk3H.js → globe-DlbrNAj1.js} +1 -1
- package/web-app/dist/assets/{hammer-3qv_T2N-.js → hammer-B-DWm695.js} +1 -1
- package/web-app/dist/assets/{index-gF9grAz-.js → index-Dvny-1SU.js} +3 -3
- package/web-app/dist/assets/{layers-CN7d0By9.js → layers-99qsKpOT.js} +1 -1
- package/web-app/dist/assets/{loader-circle-DywcM-im.js → loader-circle-ZvZyZl3J.js} +1 -1
- package/web-app/dist/assets/{lock-_hdSmkU4.js → lock-DosGjcxP.js} +1 -1
- package/web-app/dist/assets/{package-BV5zgqxJ.js → package-C-PumesN.js} +1 -1
- package/web-app/dist/assets/{plus-CU_Hh4-x.js → plus-CTYYskTB.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-DMK-fNl-.js → refresh-cw-qW2ztTFS.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-CEB3_v5_.js → rotate-ccw-ZIKrf-vO.js} +1 -1
- package/web-app/dist/assets/{scroll-text-BSdXxcRN.js → scroll-text-BamlXjAT.js} +1 -1
- package/web-app/dist/assets/{server-CAMlP1yJ.js → server-BljXb79I.js} +1 -1
- package/web-app/dist/assets/{shield-alert-B-r1Dt3h.js → shield-alert-Dr6eT9c_.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-CYfxw9o4.js → thumbs-up-v3oBXZgU.js} +1 -1
- package/web-app/dist/assets/{trash-2-DX3nGPS8.js → trash-2-BOuIrCpi.js} +1 -1
- package/web-app/dist/assets/{trending-down-Bt-hSntd.js → trending-down-Dp7jMCgH.js} +1 -1
- package/web-app/dist/assets/{trending-up-DVSlvQp8.js → trending-up-CDiur-nP.js} +1 -1
- package/web-app/dist/assets/{upload-D45RRHAP.js → upload-DxIHXhNE.js} +1 -1
- package/web-app/dist/assets/{usePolling-fq_Xj8iw.js → usePolling-D2Uu-YZb.js} +1 -1
- package/web-app/dist/assets/{user-BWcJkLlr.js → user-BVWCUAfF.js} +1 -1
- package/web-app/dist/index.html +1 -1
- package/packages/control-plane/src/server/actions.ts +0 -60
- package/packages/control-plane/ui/dist/assets/index-CUQ65vJY.css +0 -1
- package/packages/control-plane/ui/dist/assets/index-CwwGfp6E.js +0 -129
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// CPE-05: Server-Sent Events for one run's new events and for runs-list changes.
|
|
2
|
+
// Driven by the store: a short poll of the events table sees rows from ingest (and so from the shipper watcher) with no coupling to the writer.
|
|
3
|
+
// Auth: /v1/* is already behind tokenGuard in app.ts. Streams are bounded; a disconnect frees its slot.
|
|
4
|
+
import { and, asc, eq, gt, sql } from "drizzle-orm";
|
|
5
|
+
import { events, runs } from "../../db/schema.ts";
|
|
6
|
+
import type { RouteCtx } from "./index.ts";
|
|
7
|
+
|
|
8
|
+
const num = (v: string | undefined, d: number) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : d; };
|
|
9
|
+
|
|
10
|
+
export function mount(ctx: RouteCtx): void {
|
|
11
|
+
const pollMs = num(process.env.LOKI_CONTROL_STREAM_POLL_MS, 1000);
|
|
12
|
+
const heartbeatMs = num(process.env.LOKI_CONTROL_STREAM_HEARTBEAT_MS, 15000);
|
|
13
|
+
const maxStreams = num(process.env.LOKI_CONTROL_STREAM_MAX, 32);
|
|
14
|
+
const { db } = ctx;
|
|
15
|
+
const enc = new TextEncoder();
|
|
16
|
+
let open = 0;
|
|
17
|
+
|
|
18
|
+
const headers = { "content-type": "text/event-stream", "cache-control": "no-cache, no-transform", connection: "keep-alive", "x-accel-buffering": "no" };
|
|
19
|
+
|
|
20
|
+
/** Open one SSE response. tick() runs each poll and returns frames to send; the slot is freed exactly once on abort, cancel or error. */
|
|
21
|
+
const sse = (signal: AbortSignal, hello: string, tick: () => string[]): Response => {
|
|
22
|
+
if (open >= maxStreams) return Response.json({ error: "too many streams" }, { status: 429, headers: { "retry-after": "5" } });
|
|
23
|
+
open++;
|
|
24
|
+
let freed = false;
|
|
25
|
+
let poll: ReturnType<typeof setInterval> | undefined;
|
|
26
|
+
let beat: ReturnType<typeof setInterval> | undefined;
|
|
27
|
+
const free = () => { if (freed) return; freed = true; clearInterval(poll); clearInterval(beat); open--; };
|
|
28
|
+
const body = new ReadableStream<Uint8Array>({
|
|
29
|
+
start(ctrl) {
|
|
30
|
+
const send = (s: string) => { try { ctrl.enqueue(enc.encode(s)); } catch { free(); } };
|
|
31
|
+
const close = () => { free(); try { ctrl.close(); } catch { /* already closed */ } };
|
|
32
|
+
send(hello);
|
|
33
|
+
poll = setInterval(() => { try { for (const f of tick()) send(f); } catch { close(); } }, pollMs);
|
|
34
|
+
beat = setInterval(() => send(": heartbeat\n\n"), heartbeatMs);
|
|
35
|
+
if (signal.aborted) close(); else signal.addEventListener("abort", close, { once: true });
|
|
36
|
+
},
|
|
37
|
+
cancel: free,
|
|
38
|
+
});
|
|
39
|
+
return new Response(body, { headers });
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const maxSeq = (source: string, run: string): number =>
|
|
43
|
+
db.select({ m: sql<number | null>`max(${events.seq})` }).from(events).where(and(eq(events.sourceId, source), eq(events.runId, run))).get()?.m ?? -1;
|
|
44
|
+
|
|
45
|
+
ctx.app.get("/v1/runs/:source/:run/stream", (c) => {
|
|
46
|
+
const source = c.req.param("source"), run = c.req.param("run");
|
|
47
|
+
if (!db.select({ r: runs.runId }).from(runs).where(and(eq(runs.sourceId, source), eq(runs.runId, run))).get()) return c.json({ error: "run not found" }, 404);
|
|
48
|
+
// Resume point: Last-Event-ID (reconnect) or ?after=; a fresh client with neither gets only events from now on.
|
|
49
|
+
const given = c.req.header("last-event-id") ?? c.req.query("after");
|
|
50
|
+
const parsed = given === undefined || given === "" ? NaN : Number(given);
|
|
51
|
+
let cursor = Number.isInteger(parsed) ? parsed : maxSeq(source, run);
|
|
52
|
+
const tick = (): string[] => {
|
|
53
|
+
const rows = db.select().from(events).where(and(eq(events.sourceId, source), eq(events.runId, run), gt(events.seq, cursor))).orderBy(asc(events.seq)).limit(200).all();
|
|
54
|
+
if (rows.length) cursor = rows[rows.length - 1]!.seq;
|
|
55
|
+
return rows.map((e) => `id: ${e.seq}\nevent: event\ndata: ${JSON.stringify({ run: e.runId, seq: e.seq, ts: e.ts, type: e.type, stage: e.stage, data: e.data })}\n\n`);
|
|
56
|
+
};
|
|
57
|
+
const first = tick(); // replay for a resuming client is flushed with the hello
|
|
58
|
+
return sse(c.req.raw.signal, `retry: 3000\n: connected\n\n${first.join("")}`, tick);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
ctx.app.get("/v1/stream", (c) => {
|
|
62
|
+
const snapshot = (): Map<string, string> => {
|
|
63
|
+
const m = new Map<string, string>();
|
|
64
|
+
for (const e of db.select({ s: events.sourceId, r: events.runId, m: sql<number>`max(${events.seq})` }).from(events).groupBy(events.sourceId, events.runId).all()) m.set(`${e.s}:${e.r}`, String(e.m));
|
|
65
|
+
for (const r of db.select({ s: runs.sourceId, r: runs.runId, v: runs.verdict, e: runs.endedAt }).from(runs).all()) {
|
|
66
|
+
const k = `${r.s}:${r.r}`;
|
|
67
|
+
m.set(k, `${m.get(k) ?? ""}|${r.v ?? ""}|${r.e ?? ""}`);
|
|
68
|
+
}
|
|
69
|
+
return m;
|
|
70
|
+
};
|
|
71
|
+
let seen = snapshot();
|
|
72
|
+
const tick = (): string[] => {
|
|
73
|
+
const now = snapshot();
|
|
74
|
+
const out: string[] = [];
|
|
75
|
+
for (const [k, sig] of now) {
|
|
76
|
+
if (seen.get(k) === sig) continue;
|
|
77
|
+
const i = k.indexOf(":");
|
|
78
|
+
out.push(`event: run\ndata: ${JSON.stringify({ source: k.slice(0, i), run: k.slice(i + 1) })}\n\n`);
|
|
79
|
+
}
|
|
80
|
+
seen = now;
|
|
81
|
+
return out;
|
|
82
|
+
};
|
|
83
|
+
return sse(c.req.raw.signal, "retry: 3000\n: connected\n\n", tick);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Receipt verify and public key (CPE-16). Calls the engine's own verifyReceipt in process (loki-ts verify_cmd.ts), never reimplements the crypto.
|
|
2
|
+
// Read-only: it reads <repo>/.loki/runs/<run>/receipt.json (and events.jsonl beside it) and writes nothing. Loopback-only (on `act`), same path containment as the artifacts route.
|
|
3
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
4
|
+
import { createPublicKey } from "node:crypto";
|
|
5
|
+
import { join, sep } from "node:path";
|
|
6
|
+
import { and, eq } from "drizzle-orm";
|
|
7
|
+
import { localRepos, runs } from "../../db/schema.ts";
|
|
8
|
+
import { verifyReceipt } from "../../../../../loki-ts/src/engine10/verify_cmd.ts";
|
|
9
|
+
import { kidOf, loadSigningKey } from "../../../../../loki-ts/src/engine10/stages/seal.ts";
|
|
10
|
+
import { outcomeOf, runIdGuard } from "../../../../../loki-ts/src/features/receipt_dsse.ts";
|
|
11
|
+
import { audit } from "../audit.ts";
|
|
12
|
+
import type { RouteCtx } from "./index.ts";
|
|
13
|
+
|
|
14
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
15
|
+
const safeId = (s: string) => ID.test(s) && !s.includes("..");
|
|
16
|
+
|
|
17
|
+
export function mount(ctx: RouteCtx): void {
|
|
18
|
+
const { act, db } = ctx;
|
|
19
|
+
|
|
20
|
+
act.post("/v1/runs/:source/:run/verify", async (c) => {
|
|
21
|
+
const source = c.req.param("source"), run = c.req.param("run");
|
|
22
|
+
const target = `${source}/${run}`;
|
|
23
|
+
const done = (status: 403 | 404, error: string, result: string) => { audit(db, { kind: "receipt.verify", target, result, detail: error }); return c.json({ error }, status); };
|
|
24
|
+
if (!ctx.local(c)) return done(403, "loopback only", "refused");
|
|
25
|
+
if (!safeId(source) || !safeId(run)) return done(404, "run not found", "not_found");
|
|
26
|
+
const known = db.select({ r: runs.runId }).from(runs).where(and(eq(runs.sourceId, source), eq(runs.runId, run))).get();
|
|
27
|
+
if (!known) return done(404, "run not found", "not_found");
|
|
28
|
+
const repo = db.select({ p: localRepos.realpath }).from(localRepos).where(eq(localRepos.sourceId, source)).get();
|
|
29
|
+
if (!repo) return done(404, "receipt not available", "not_found");
|
|
30
|
+
let runDir: string, file: string;
|
|
31
|
+
try {
|
|
32
|
+
const root = realpathSync(join(repo.p, ".loki", "runs"));
|
|
33
|
+
runDir = realpathSync(join(root, run));
|
|
34
|
+
if (runDir !== join(root, run)) return done(404, "receipt not found", "not_found");
|
|
35
|
+
file = realpathSync(join(runDir, "receipt.json"));
|
|
36
|
+
} catch { return done(404, "receipt not found", "not_found"); }
|
|
37
|
+
if (!file.startsWith(runDir + sep)) return done(404, "receipt not found", "not_found");
|
|
38
|
+
// Same order as `loki verify <run-id>`: the envelope must describe this run, then integrity, then the run outcome.
|
|
39
|
+
let integrity: string, reasons: string[], sha: string | null = null, outcome = "UNREADABLE";
|
|
40
|
+
try {
|
|
41
|
+
const bad = runIdGuard(file, run);
|
|
42
|
+
if (bad) { integrity = "TAMPERED"; reasons = [bad]; }
|
|
43
|
+
else { const r = await verifyReceipt(file); integrity = r.verdict; reasons = r.reasons; sha = r.receiptSha256 ?? null; }
|
|
44
|
+
try { outcome = outcomeOf(JSON.parse(readFileSync(file, "utf8"))); } catch { outcome = "UNREADABLE"; }
|
|
45
|
+
} catch (e) {
|
|
46
|
+
integrity = "UNCHECKED"; reasons = [`verifier error: ${(e as Error).message}`];
|
|
47
|
+
}
|
|
48
|
+
// Intact does not mean passing: an intact receipt of a run that did not verify is NOT_VERIFIED, as in the CLI (exit 4).
|
|
49
|
+
const intact = integrity === "VERIFIED" || integrity === "UNSIGNED";
|
|
50
|
+
const passing = outcome === "VERIFIED" || outcome === "ALREADY_SATISFIED";
|
|
51
|
+
let verdict = integrity;
|
|
52
|
+
if (intact && !passing) { verdict = "NOT_VERIFIED"; reasons = [`run outcome ${outcome}; receipt integrity ${integrity === "UNSIGNED" ? "unattested" : "intact"}`, ...reasons]; }
|
|
53
|
+
audit(db, { kind: "receipt.verify", target, result: verdict.toLowerCase(), detail: reasons[0] });
|
|
54
|
+
return c.json({ run, verdict, integrity, outcome, reasons, receipt_sha256: sha, verified_at: new Date().toISOString() });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// The public half of the receipt signer as a JWK. The private key never leaves loadSigningKey.
|
|
58
|
+
act.get("/v1/keys", (c) => {
|
|
59
|
+
if (!ctx.peerIsLoopback(c)) { audit(db, { kind: "keys.read", result: "refused", detail: "loopback only" }); return c.json({ error: "loopback only" }, 403); }
|
|
60
|
+
const priv = loadSigningKey(false);
|
|
61
|
+
if (!priv) { audit(db, { kind: "keys.read", result: "no_key" }); return c.json({ error: "no signing key on this machine (run a sealed build first or set LOKI_RECEIPT_SIGNING_KEY_FILE)" }, 404); }
|
|
62
|
+
const pub = createPublicKey(priv), { kty, crv, x } = pub.export({ format: "jwk" });
|
|
63
|
+
audit(db, { kind: "keys.read", result: "ok" });
|
|
64
|
+
return c.json({ kty, crv, x, kid: kidOf(pub), alg: "EdDSA", use: "sig" });
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -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,
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Start-a-run planning and spawning for the UI (CPE-07). A run is started by spawning the same `loki start` path an operator would type (argv array, no shell).
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, join, resolve } from "node:path";
|
|
6
|
+
|
|
7
|
+
const ISSUE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\/[A-Za-z0-9._-]{1,100}#[1-9][0-9]{0,8}$/;
|
|
8
|
+
// A free-text task: letters, digits, spaces and a small punctuation set. No colon, quotes, backticks, $, ;, &, |, <, >, parens, braces, backslash or control chars.
|
|
9
|
+
const TASK = /^[A-Za-z0-9][A-Za-z0-9 .,_/#@+=-]{0,499}$/;
|
|
10
|
+
const hasDotDot = (t: string): boolean => t.split(/[\\/ ]/).some((seg) => seg.startsWith(".."));
|
|
11
|
+
|
|
12
|
+
/** Child env: the server's own secrets and bind config never reach a spawned run. */
|
|
13
|
+
export function childEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
14
|
+
const e: NodeJS.ProcessEnv = { ...env, LOKI_NO_BROWSER: "1" };
|
|
15
|
+
for (const k of ["LOKI_CONTROL_TOKEN", "LOKI_CONTROL_DB", "LOKI_CONTROL_HOST", "PORT"]) delete e[k];
|
|
16
|
+
return e;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Optional run fields, each mapped to one known loki flag (or env var) and validated against an allowlist. Nothing else is accepted.
|
|
20
|
+
const PROVIDERS = new Set(["claude", "codex", "cline", "aider", "opencode"]);
|
|
21
|
+
const MODEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
22
|
+
const BUDGET = /^(?:[1-9][0-9]{0,5}|0)(?:\.[0-9]{1,2})?$/;
|
|
23
|
+
const WORKSPACE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
24
|
+
|
|
25
|
+
export type StartPlan = { ok: true; argv: string[]; cwd: string; env: Record<string, string> } | { ok: false; error: string };
|
|
26
|
+
|
|
27
|
+
/** Registered project paths from ~/.loki/dashboard/projects.json (the `loki projects` registry), existing directories only. */
|
|
28
|
+
export function registryRepos(env: NodeJS.ProcessEnv = process.env): string[] {
|
|
29
|
+
try {
|
|
30
|
+
const j = JSON.parse(readFileSync(join(env.HOME || homedir(), ".loki", "dashboard", "projects.json"), "utf8")) as { projects?: Record<string, { path?: unknown }> };
|
|
31
|
+
return Object.values(j.projects ?? {}).map((p) => (typeof p?.path === "string" ? p.path : "")).filter((p) => p && existsSync(p) && statSync(p).isDirectory());
|
|
32
|
+
} catch { return []; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Validate a start request. The repo must be one the server already knows (registry or cwd), never an arbitrary path. */
|
|
36
|
+
export function planStart(body: unknown, known: string[], bin = "loki"): StartPlan {
|
|
37
|
+
const b = (body && typeof body === "object" ? body : {}) as { target?: unknown; repo?: unknown; model?: unknown; provider?: unknown; budget?: unknown; workspace?: unknown };
|
|
38
|
+
if (typeof b.target !== "string") return { ok: false, error: "target must be a string" };
|
|
39
|
+
const target = b.target.trim();
|
|
40
|
+
const issue = ISSUE.test(target);
|
|
41
|
+
if (!issue && !TASK.test(target)) return { ok: false, error: "target must be owner/repo#N or a plain task (letters, digits, spaces, . , _ / # @ + = -)" };
|
|
42
|
+
if (hasDotDot(target)) return { ok: false, error: "target must not contain a .. path segment" };
|
|
43
|
+
let cwd = process.cwd();
|
|
44
|
+
if (b.repo !== undefined && b.repo !== "") {
|
|
45
|
+
if (typeof b.repo !== "string" || b.repo.includes("\0")) return { ok: false, error: "repo must be a path string" };
|
|
46
|
+
// GET /v1/repos exposes display names only, so a repo may be given as a known path or as the unique display name of a known project.
|
|
47
|
+
const want = resolve(b.repo);
|
|
48
|
+
const paths = known.map((k) => resolve(k));
|
|
49
|
+
const named = paths.filter((k) => basename(k) === b.repo);
|
|
50
|
+
const hit = paths.find((k) => k === want) ?? (named.length === 1 ? named[0] : undefined);
|
|
51
|
+
if (!hit) return { ok: false, error: named.length > 1 ? "repo name is ambiguous" : "repo is not a known project" };
|
|
52
|
+
cwd = hit;
|
|
53
|
+
}
|
|
54
|
+
const opt = (v: unknown): string | undefined => (v === undefined || v === null || v === "" ? undefined : typeof v === "number" && Number.isFinite(v) ? String(v) : typeof v === "string" ? v : "\0bad");
|
|
55
|
+
const provider = opt(b.provider), model = opt(b.model), budget = opt(b.budget), workspace = opt(b.workspace);
|
|
56
|
+
if (provider !== undefined && !PROVIDERS.has(provider)) return { ok: false, error: "provider must be one of: " + [...PROVIDERS].join(", ") };
|
|
57
|
+
if (model !== undefined && !MODEL.test(model)) return { ok: false, error: "model must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}" };
|
|
58
|
+
if (budget !== undefined && (!BUDGET.test(budget) || Number(budget) <= 0)) return { ok: false, error: "budget must be a positive USD amount such as 5 or 5.00" };
|
|
59
|
+
if (workspace !== undefined && !WORKSPACE.test(workspace)) return { ok: false, error: "workspace must be a workspace name from loki.yaml (letters, digits, _ -)" };
|
|
60
|
+
if (workspace !== undefined && (provider !== undefined || budget !== undefined)) return { ok: false, error: "workspace runs do not take provider or budget; set them per repo in loki.yaml" };
|
|
61
|
+
const env: Record<string, string> = model !== undefined ? { LOKI_SESSION_MODEL: model } : {};
|
|
62
|
+
// A workspace run is `loki workspace run <name> <ref>` (the existing multi-repo path). Otherwise free text goes as an explicit brief, never auto-detected as a PRD path.
|
|
63
|
+
if (workspace !== undefined) return { ok: true, argv: [bin, "workspace", "run", workspace, target], cwd, env };
|
|
64
|
+
const flags = [...(provider !== undefined ? ["--provider", provider] : []), ...(budget !== undefined ? ["--budget", budget] : [])];
|
|
65
|
+
return { ok: true, argv: issue ? [bin, "start", target, ...flags] : [bin, "start", "--brief", target, ...flags], cwd, env };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Spawn detached with no shell; resolves with the pid once the process exists, or an error if it cannot be launched. */
|
|
69
|
+
export function spawnStart(argv: string[], cwd: string, onExit: () => void = () => {}, extraEnv: Record<string, string> = {}): Promise<{ pid: number } | { error: string }> {
|
|
70
|
+
return new Promise((done) => {
|
|
71
|
+
try {
|
|
72
|
+
const [cmd, ...args] = argv;
|
|
73
|
+
const child = spawn(cmd!, args, { cwd, detached: true, stdio: "ignore", shell: false, env: { ...childEnv(), ...extraEnv } });
|
|
74
|
+
child.once("exit", onExit);
|
|
75
|
+
child.once("error", (e) => { onExit(); done({ error: `could not start loki: ${e.message}` }); });
|
|
76
|
+
child.once("spawn", () => { child.unref(); done({ pid: child.pid ?? 0 }); });
|
|
77
|
+
} catch (e) { done({ error: `could not start loki: ${(e as Error).message}` }); }
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
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, statSync } from "node:fs";
|
|
6
|
+
import { homedir, tmpdir } from "node:os";
|
|
7
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
8
|
+
import { sourceId } from "./ship.ts";
|
|
9
|
+
import { registryRepos } from "./watch.ts";
|
|
7
10
|
|
|
8
11
|
export const instancePath = (env: NodeJS.ProcessEnv): string => join(env.HOME || homedir(), ".loki", "control", "instance.json");
|
|
9
12
|
|
|
@@ -15,6 +18,22 @@ export function isLoopbackHttp(u: string): boolean {
|
|
|
15
18
|
try { const p = new URL(u); return p.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(p.hostname); } catch { return false; }
|
|
16
19
|
}
|
|
17
20
|
|
|
21
|
+
export interface LocalRepo { sourceId: string; realpath: string; name: string }
|
|
22
|
+
|
|
23
|
+
/** Local repo discovery for the server: its own repo plus every registered project (existing directories only). Purely local; nothing here comes from /v1/ingest. */
|
|
24
|
+
export function discoverLocalRepos(repoDir: string, env: NodeJS.ProcessEnv = process.env): LocalRepo[] {
|
|
25
|
+
const out = new Map<string, LocalRepo>();
|
|
26
|
+
for (const dir of [repoDir, ...registryRepos(env)]) {
|
|
27
|
+
try {
|
|
28
|
+
const real = realpathSync(dir);
|
|
29
|
+
if (!statSync(real).isDirectory()) continue;
|
|
30
|
+
const id = sourceId(real);
|
|
31
|
+
if (!out.has(id)) out.set(id, { sourceId: id, realpath: real, name: basename(real) });
|
|
32
|
+
} catch { /* a vanished or unreadable path is simply not a repo */ }
|
|
33
|
+
}
|
|
34
|
+
return [...out.values()];
|
|
35
|
+
}
|
|
36
|
+
|
|
18
37
|
export async function discoverControlUrl(env: NodeJS.ProcessEnv, o: DiscoverOpts = {}): Promise<string | null> {
|
|
19
38
|
if (env.LOKI_CONTROL === "0") return null;
|
|
20
39
|
if (env.LOKI_CONTROL_URL) return env.LOKI_CONTROL_URL;
|
|
@@ -27,3 +46,31 @@ export async function discoverControlUrl(env: NodeJS.ProcessEnv, o: DiscoverOpts
|
|
|
27
46
|
return h.service === "loki-control" ? url : null;
|
|
28
47
|
} catch { return null; }
|
|
29
48
|
}
|
|
49
|
+
|
|
50
|
+
// P0 guard: auto-discovery must never ship a throwaway or fixture repo to a developer's live Control Plane. An explicit
|
|
51
|
+
// LOKI_CONTROL_URL is an operator decision and is never filtered. LOKI_CONTROL_ALLOW_TMP=1 lets a sandboxed harness
|
|
52
|
+
// (its own HOME and its own server, repos under the temp dir) exercise discovery on purpose.
|
|
53
|
+
const FIXTURE_REPO = /(^|[/:])acme\/widget(\.git)?\/?$/i;
|
|
54
|
+
const FIXED_TMP_ROOTS = ["/tmp", "/private/tmp", "/var/folders", "/private/var/folders"];
|
|
55
|
+
|
|
56
|
+
const real = (p: string): string => { try { return realpathSync(p); } catch { return resolve(p); } };
|
|
57
|
+
const under = (p: string, root: string): boolean => p === root || p.startsWith(root.endsWith("/") ? root : `${root}/`);
|
|
58
|
+
|
|
59
|
+
export function isThrowawayPath(repoDir: string, tmpRoots: string[] = [real(tmpdir()), ...FIXED_TMP_ROOTS]): boolean {
|
|
60
|
+
const p = real(repoDir), raw = resolve(repoDir);
|
|
61
|
+
return tmpRoots.some((r) => under(p, r) || under(raw, r) || under(p, real(r)));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A reason when a discovered (non-explicit) Control Plane must not receive this repo's events, else null. */
|
|
65
|
+
export function discoveryRefusal(repoDir: string, originUrl: string | null, env: NodeJS.ProcessEnv = process.env, tmpRoots?: string[]): string | null {
|
|
66
|
+
if (env.LOKI_CONTROL_ALLOW_TMP === "1") return null;
|
|
67
|
+
if (isThrowawayPath(repoDir, tmpRoots)) return "repo is under the OS temp dir";
|
|
68
|
+
if (originUrl && FIXTURE_REPO.test(originUrl.trim())) return "origin is the fixture repo acme/widget";
|
|
69
|
+
if (FIXTURE_REPO.test(`${basename(dirname(repoDir))}/${basename(repoDir)}`)) return "repo name is the fixture acme/widget";
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The repo's origin url (env is threaded so the spawn never inherits ambient state), or null. */
|
|
74
|
+
export function gitOrigin(repoDir: string, env: NodeJS.ProcessEnv): string | null {
|
|
75
|
+
try { return execFileSync("git", ["-C", repoDir, "config", "--get", "remote.origin.url"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env }).trim() || null; } catch { return null; }
|
|
76
|
+
}
|
|
@@ -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
|
+
@import"https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap";:root,[data-theme=light]{--cp-bg: #F1F2F6;--cp-bg-2: #E6E8EE;--cp-bg-3: #DBDEE6;--cp-card: rgba(255,255,255,.86);--cp-hover: #E6E8EE;--cp-glass: rgba(255,255,255,.7);--cp-glass-border: rgba(255,255,255,.4);--cp-glass-shadow: 0 4px 24px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04);--cp-text: #201515;--cp-text-2: #4A4640;--cp-text-muted: #8A857C;--cp-text-inverse: #ffffff;--cp-border: rgba(0,0,0,.08);--cp-border-light: rgba(0,0,0,.05);--cp-accent: #553DE9;--cp-accent-hover: #4432c4;--cp-accent-active: #3828a0;--cp-accent-glow: rgba(85,61,233,.15);--cp-accent-muted: rgba(85,61,233,.1);--cp-success: #1f8a52;--cp-warning: #9a6a12;--cp-error: #b23a3a;--cp-info: #2F71E3;--cp-success-fill: #1FC5A8;--cp-success-muted: rgba(31,197,168,.12);--cp-warning-fill: #D4A03C;--cp-warning-muted: rgba(212,160,60,.12);--cp-error-fill: #C45B5B;--cp-error-muted: rgba(196,91,91,.12);--cp-info-muted: rgba(47,113,227,.12);--cp-accent-solid: #553DE9;--cp-accent-ink: #553DE9;--cp-error-ink: #b23a3a;--cp-success-ink: #1a7546;--cp-warning-ink: #895e10;--cp-info-ink: #2963c8;--cp-text-subtle: #6a665f;--cp-opus: #d97706;--cp-sonnet: #553DE9;--cp-haiku: #1FC5A8;--cp-shadow-sm: 0 1px 2px rgba(32,21,21,.04);--cp-shadow-md: 0 4px 6px rgba(32,21,21,.06);--cp-shadow-lg: 0 10px 15px rgba(32,21,21,.08);--cp-focus: 0 0 0 3px rgba(85,61,233,.25)}[data-theme=dark]{--cp-bg: #17161C;--cp-bg-2: #1E1D25;--cp-bg-3: #27262F;--cp-card: rgba(35,34,43,.82);--cp-hover: #27262F;--cp-glass: rgba(23,22,28,.7);--cp-glass-border: rgba(255,255,255,.08);--cp-glass-shadow: 0 4px 24px rgba(0,0,0,.25), 0 1px 2px rgba(0,0,0,.12);--cp-text: #F0ECF8;--cp-text-2: #B8B0C8;--cp-text-muted: #8B85A0;--cp-text-inverse: #17161C;--cp-border: rgba(255,255,255,.08);--cp-border-light: rgba(255,255,255,.04);--cp-accent: #8b7bf5;--cp-accent-hover: #9c8ff7;--cp-accent-active: #6258D0;--cp-accent-glow: rgba(139,123,245,.2);--cp-accent-muted: rgba(123,107,240,.18);--cp-success: #2ED8B6;--cp-warning: #E8B84A;--cp-error: #E07070;--cp-info: #5A9CF5;--cp-success-fill: #2ED8B6;--cp-success-muted: rgba(46,216,182,.18);--cp-warning-fill: #E8B84A;--cp-warning-muted: rgba(232,184,74,.18);--cp-error-fill: #E07070;--cp-error-muted: rgba(224,112,112,.18);--cp-info-muted: rgba(90,156,245,.18);--cp-success-ink: #2ED8B6;--cp-warning-ink: #E8B84A;--cp-info-ink: #6DA8F6;--cp-accent-solid: #6a55f2;--cp-accent-ink: #9c8ef6;--cp-error-ink: #E58989;--cp-text-subtle: #8B85A0;--cp-opus: #f59e0b;--cp-sonnet: #8b7bf5;--cp-haiku: #2ED8B6;--cp-shadow-sm: 0 1px 2px rgba(0,0,0,.4);--cp-shadow-md: 0 4px 12px rgba(0,0,0,.5);--cp-shadow-lg: 0 10px 25px rgba(0,0,0,.6);--cp-focus: 0 0 0 3px rgba(123,107,240,.3)}@media(prefers-color-scheme:dark){:root:not([data-theme=light]){--cp-bg: #17161C;--cp-bg-2: #1E1D25;--cp-bg-3: #27262F;--cp-card: rgba(35,34,43,.82);--cp-hover: #27262F;--cp-glass: rgba(23,22,28,.7);--cp-glass-border: rgba(255,255,255,.08);--cp-glass-shadow: 0 4px 24px rgba(0,0,0,.25), 0 1px 2px rgba(0,0,0,.12);--cp-text: #F0ECF8;--cp-text-2: #B8B0C8;--cp-text-muted: #8B85A0;--cp-text-inverse: #17161C;--cp-border: rgba(255,255,255,.08);--cp-border-light: rgba(255,255,255,.04);--cp-accent: #8b7bf5;--cp-accent-hover: #9c8ff7;--cp-accent-active: #6258D0;--cp-accent-glow: rgba(139,123,245,.2);--cp-accent-muted: rgba(123,107,240,.18);--cp-success: #2ED8B6;--cp-warning: #E8B84A;--cp-error: #E07070;--cp-info: #5A9CF5;--cp-success-fill: #2ED8B6;--cp-success-muted: rgba(46,216,182,.18);--cp-warning-fill: #E8B84A;--cp-warning-muted: rgba(232,184,74,.18);--cp-error-fill: #E07070;--cp-error-muted: rgba(224,112,112,.18);--cp-info-muted: rgba(90,156,245,.18);--cp-success-ink: #2ED8B6;--cp-warning-ink: #E8B84A;--cp-info-ink: #6DA8F6;--cp-accent-solid: #6a55f2;--cp-accent-ink: #9c8ef6;--cp-error-ink: #E58989;--cp-text-subtle: #8B85A0;--cp-opus: #f59e0b;--cp-sonnet: #8b7bf5;--cp-haiku: #2ED8B6;--cp-shadow-sm: 0 1px 2px rgba(0,0,0,.4);--cp-shadow-md: 0 4px 12px rgba(0,0,0,.5);--cp-shadow-lg: 0 10px 25px rgba(0,0,0,.6);--cp-focus: 0 0 0 3px rgba(123,107,240,.3)}}:root{--cp-font-serif: "Fraunces", Georgia, "Times New Roman", serif;--cp-font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;--cp-font-mono: "JetBrains Mono", "Fira Code", "SF Mono", Menlo, monospace;--cp-text-xs: 10px;--cp-text-sm: 11px;--cp-text-base: 12px;--cp-text-md: 13px;--cp-text-lg: 14px;--cp-text-xl: 16px;--cp-text-2xl: 18px;--cp-text-3xl: 24px;--cp-space-xs: 4px;--cp-space-sm: 8px;--cp-space-md: 12px;--cp-space-lg: 16px;--cp-space-xl: 24px;--cp-space-2xl: 32px;--cp-space-3xl: 48px;--cp-radius-sm: 2px;--cp-radius-md: 4px;--cp-radius-lg: 5px;--cp-radius-nav: 8px;--cp-radius-full: 9999px;--cp-ease: cubic-bezier(.4,0,.2,1);--cp-dur-fast: .1s;--cp-dur: .2s;--cp-dur-slow: .3s;--cp-sidebar-w: 240px;--cp-detail-w: 288px;--cp-rail-w: 48px;--cp-main-pad: 28px 32px;--cp-thread-max: 820px;--cp-z-dropdown: 100;--cp-z-sticky: 200;--cp-z-modal: 300;--cp-z-popover: 400;--cp-z-toast: 600}.cp-root{background:var(--cp-bg);color:var(--cp-text);font-family:var(--cp-font-sans);font-size:var(--cp-text-md);line-height:1.5;-webkit-font-smoothing:antialiased;font-feature-settings:"cv02","cv03","cv04","cv11"}@keyframes cp-pulse{0%,to{opacity:1}50%{opacity:.4}}@keyframes cp-spin{to{transform:rotate(360deg)}}@keyframes cp-fade-in{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}.cp-fade-in{animation:cp-fade-in var(--cp-dur) var(--cp-ease)}.cp-pulse{animation:cp-pulse 2s var(--cp-ease) infinite}.cp-spinner{animation:cp-spin .8s linear infinite}@media(prefers-reduced-motion:reduce){.cp-fade-in,.cp-pulse,.cp-spinner{animation:none}}*,: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:var(--cp-font-sans);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:var(--cp-font-mono);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}.collapse{visibility:collapse}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.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}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.block{display:block}.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%}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.resize{resize:both}.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-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-sky-700{--tw-bg-opacity: 1;background-color:rgb(3 105 161 / 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-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:var(--cp-font-mono)}.font-sans{font-family:var(--cp-font-sans)}.font-serif{font-family:var(--cp-font-serif)}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-base{font-size:1rem;line-height:1.5rem}.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-inverse{color:var(--cp-text-inverse)}.text-muted{color:var(--cp-text-muted)}.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-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))}.accent-hover{accent-color:var(--cp-hover)}.accent-muted{accent-color:var(--cp-text-muted)}.shadow-lg{--tw-shadow: var(--cp-shadow-lg);--tw-shadow-colored: var(--cp-shadow-lg);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow: var(--cp-shadow-md);--tw-shadow-colored: var(--cp-shadow-md);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: var(--cp-shadow-sm);--tw-shadow-colored: var(--cp-shadow-sm);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.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)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}body{margin:0;background:var(--cp-bg);color:var(--cp-text);font-family:var(--cp-font-sans)}.hover\:underline:hover{text-decoration-line:underline}.disabled\:opacity-50:disabled{opacity:.5}.dark\:border-slate-700:is([data-theme=dark] *){--tw-border-opacity: 1;border-color:rgb(51 65 85 / var(--tw-border-opacity, 1))}.dark\:border-slate-800:is([data-theme=dark] *){--tw-border-opacity: 1;border-color:rgb(30 41 59 / var(--tw-border-opacity, 1))}.dark\:bg-slate-800:is([data-theme=dark] *){--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity, 1))}.dark\:bg-slate-900:is([data-theme=dark] *){--tw-bg-opacity: 1;background-color:rgb(15 23 42 / var(--tw-bg-opacity, 1))}.dark\:text-amber-300:is([data-theme=dark] *){--tw-text-opacity: 1;color:rgb(252 211 77 / var(--tw-text-opacity, 1))}.dark\:text-emerald-300:is([data-theme=dark] *){--tw-text-opacity: 1;color:rgb(110 231 183 / var(--tw-text-opacity, 1))}.dark\:text-emerald-400:is([data-theme=dark] *){--tw-text-opacity: 1;color:rgb(52 211 153 / var(--tw-text-opacity, 1))}.dark\:text-red-300:is([data-theme=dark] *){--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}.dark\:text-sky-300:is([data-theme=dark] *){--tw-text-opacity: 1;color:rgb(125 211 252 / var(--tw-text-opacity, 1))}.dark\:text-sky-400:is([data-theme=dark] *){--tw-text-opacity: 1;color:rgb(56 189 248 / var(--tw-text-opacity, 1))}.dark\:text-slate-300:is([data-theme=dark] *){--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.dark\:text-slate-400:is([data-theme=dark] *){--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.dark\:text-violet-300:is([data-theme=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\:flex{display:flex}.md\:table-cell{display:table-cell}.md\:hidden{display:none}.md\:min-h-0{min-height:0px}.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\:p-6{padding:1.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}}
|