loki-mode 10.6.0 → 10.6.6
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 +15 -308
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/loki +34 -1
- package/autonomy/telemetry.sh +1 -1
- package/bin/loki +1 -1
- package/completions/_loki +1 -0
- package/completions/loki.bash +2 -2
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +6 -5
- package/docs/v10/CONTROL-PLANE.md +1 -1
- package/docs/v10/DECISIONS.md +3 -0
- package/docs/v10/DEPS.md +16 -0
- package/loki-ts/dist/loki.js +485 -464
- package/mcp/__init__.py +1 -1
- package/package.json +7 -2
- package/packages/control-plane/dist/server.js +5919 -0
- package/packages/control-plane/drizzle/0000_new_maelstrom.sql +49 -0
- package/packages/control-plane/drizzle/meta/0000_snapshot.json +343 -0
- package/packages/control-plane/drizzle/meta/_journal.json +13 -0
- package/packages/control-plane/package.json +20 -0
- package/packages/control-plane/src/db/migrate.ts +20 -0
- package/packages/control-plane/src/db/schema.ts +54 -0
- package/packages/control-plane/src/server/app.ts +63 -0
- package/packages/control-plane/src/server/ingest.ts +52 -0
- package/packages/control-plane/src/server/runs.ts +116 -0
- package/packages/control-plane/src/server/serve.ts +15 -0
- package/packages/control-plane/src/shipper/backfill.ts +40 -0
- package/packages/control-plane/src/shipper/ship.ts +95 -0
- package/packages/control-plane/ui/dist/assets/index-B6EiyukW.js +114 -0
- package/packages/control-plane/ui/dist/assets/index-BH3LtKcG.css +1 -0
- package/packages/control-plane/ui/dist/index.html +13 -0
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/web-app/dist/assets/{AdminPage-B8LTaWIa.js → AdminPage-CH-bmw4l.js} +1 -1
- package/web-app/dist/assets/{Avatar-BbxCTNns.js → Avatar-Bos-ky_x.js} +1 -1
- package/web-app/dist/assets/{Badge-DIjrBQFC.js → Badge-DNJyHg9Y.js} +1 -1
- package/web-app/dist/assets/{Button-LgRYqCHv.js → Button-KqD3Tt-I.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-BgEdvdXX.js → CockpitPage-l6oTsRaL.js} +1 -1
- package/web-app/dist/assets/{ComparePage-BPttVkSu.js → ComparePage-DN6uxXko.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-DCPrb7ec.js → ErrorBoundary-CKQhnvU9.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-BLqfCaoK.js → EvidenceReceiptPanel-CzSIqLmy.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-Ch61Yw6v.js → GitHubIssuesPanel-YJUcDezn.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-Cju9OEP1.js → GitHubPRsPanel-Bv7xFvJN.js} +1 -1
- package/web-app/dist/assets/{HomePage-BSt5tg3Z.js → HomePage-CJsxBbii.js} +2 -2
- package/web-app/dist/assets/{LoginPage-Cj9xl0pH.js → LoginPage-D1V2Ag0s.js} +1 -1
- package/web-app/dist/assets/{MagicPage-CyfrX553.js → MagicPage-BBVNMEr5.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-Ck6jFgAN.js → MetricsPage-CtHM-IlA.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-CA_HlCgz.js → NotFoundPage-BBEEJLj5.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-BkpztOmm.js → ProjectPage-BVRJoEaQ.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-urTkLe8m.js → ProjectsPage-DqaA9rUa.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-COvHhevh.js → SettingsPage-B_RQV_vF.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-BNIiqhqk.js → ShowcasePage-DqN-mrZ8.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-CBPa5u-v.js → SystemSettingsPage-D-CvuL-K.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-DuuHXfkP.js → TeamsPage-CzkkDcPj.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-C8tRhU8-.js → TemplatesPage-Bbchk7YQ.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-BOL99fiQ.js → TerminalOutput-B32BAX--.js} +1 -1
- package/web-app/dist/assets/{activity-92O1sR3_.js → activity-C9vnEmhG.js} +1 -1
- package/web-app/dist/assets/{bell-CeWSKe4w.js → bell-XsIhkyjq.js} +1 -1
- package/web-app/dist/assets/{bot-n8nzxbS2.js → bot-CvQhqBws.js} +1 -1
- package/web-app/dist/assets/{check-C-8ho0t2.js → check-HmG8WcD-.js} +1 -1
- package/web-app/dist/assets/{chevron-left-D4UQrt8-.js → chevron-left-C1KNgnbD.js} +1 -1
- package/web-app/dist/assets/{circle-alert-BnxgnWHv.js → circle-alert-DSmCvZju.js} +1 -1
- package/web-app/dist/assets/{clock-DgbJFbcN.js → clock-CPhE3RXd.js} +1 -1
- package/web-app/dist/assets/{cloud-BSEYN8y8.js → cloud-29lwqocB.js} +1 -1
- package/web-app/dist/assets/{code-xml-CFYOlC1u.js → code-xml-4jZUzbPt.js} +1 -1
- package/web-app/dist/assets/{database-DNJ4cz62.js → database-H0Ct2r-8.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-DZ1zEfep.js → dollar-sign-DsXkO0yT.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-D9vIN2Ru.js → file-code-corner-BM8w_nvX.js} +1 -1
- package/web-app/dist/assets/{file-plus-B4TugJYw.js → file-plus-BWkYTJ4D.js} +1 -1
- package/web-app/dist/assets/{globe-BPNU50l7.js → globe-FOFDl_8Z.js} +1 -1
- package/web-app/dist/assets/{hammer-CwVF_APh.js → hammer-orm5gvky.js} +1 -1
- package/web-app/dist/assets/{index-DnAhEaY4.js → index-B6juCuPX.js} +3 -3
- package/web-app/dist/assets/{layers-DNBXP7Gs.js → layers-DUQPkPAl.js} +1 -1
- package/web-app/dist/assets/{loader-circle-bh-SWjhS.js → loader-circle-Deq6bDar.js} +1 -1
- package/web-app/dist/assets/{lock-CpbXWrBs.js → lock-CSzWTPRC.js} +1 -1
- package/web-app/dist/assets/{package-3NG8Vkod.js → package-CmACgZuF.js} +1 -1
- package/web-app/dist/assets/{plus-C1DPukhG.js → plus-BjPW5v3A.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-Cgl65iQ2.js → refresh-cw-DhI1sHaj.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-BS5sMMYV.js → rotate-ccw-Byi2ouWB.js} +1 -1
- package/web-app/dist/assets/{scroll-text-BpvzZAZR.js → scroll-text-gq7bJ81H.js} +1 -1
- package/web-app/dist/assets/{server-DSLFedy_.js → server-YbY5s1-X.js} +1 -1
- package/web-app/dist/assets/{shield-alert-BlG8AkvF.js → shield-alert-DhKjtwZt.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-BJcYgjRU.js → thumbs-up-D2X5JR-5.js} +1 -1
- package/web-app/dist/assets/{trash-2-CScLwgFk.js → trash-2-BHAmMxTq.js} +1 -1
- package/web-app/dist/assets/{trending-down-BPRGGG_g.js → trending-down-C41kQa-Q.js} +1 -1
- package/web-app/dist/assets/{trending-up-BpLBFaZo.js → trending-up-M-OK_9DC.js} +1 -1
- package/web-app/dist/assets/{upload-LgGE0PEa.js → upload-CarpPiGw.js} +1 -1
- package/web-app/dist/assets/{usePolling-0W4sMm7u.js → usePolling-rJ7rM7R0.js} +1 -1
- package/web-app/dist/assets/{user-D4KBUK1Z.js → user-cBhWpZen.js} +1 -1
- package/web-app/dist/index.html +1 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { and, asc, desc, eq, gte, lt, sql } from "drizzle-orm";
|
|
2
|
+
import { fold, partialCost } from "../../../../loki-ts/src/engine10/events.ts";
|
|
3
|
+
import type { EventEnvelope } from "../../../../loki-ts/src/engine10/types.ts";
|
|
4
|
+
import type { Db } from "../db/migrate.ts";
|
|
5
|
+
import { events, runs } from "../db/schema.ts";
|
|
6
|
+
|
|
7
|
+
const str = (x: unknown): string | null => (typeof x === "string" ? x : null);
|
|
8
|
+
|
|
9
|
+
export function loadEvents(db: Db, sourceId: string, runId: string): EventEnvelope[] {
|
|
10
|
+
return db.select().from(events).where(and(eq(events.sourceId, sourceId), eq(events.runId, runId))).orderBy(asc(events.seq)).all()
|
|
11
|
+
.map((r) => ({ v: 1, seq: r.seq, ts: r.ts, run: r.runId, type: r.type, stage: r.stage, data: r.data }) as EventEnvelope);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Rebuilds the runs projection row from stored events with the real fold(). The only writer of `runs`. */
|
|
15
|
+
export function rebuildRun(db: Db, sourceId: string, runId: string): void {
|
|
16
|
+
const evs = loadEvents(db, sourceId, runId);
|
|
17
|
+
if (evs.length === 0) return;
|
|
18
|
+
const f = fold(evs);
|
|
19
|
+
const pc = partialCost(evs, f.run.tampered);
|
|
20
|
+
const sd = f.run.started?.data ?? {};
|
|
21
|
+
const done = f.run.completed?.data ?? {};
|
|
22
|
+
const pr = evs.find((e) => e.type === "pr.opened")?.data;
|
|
23
|
+
const row = {
|
|
24
|
+
sourceId, runId,
|
|
25
|
+
originRepo: str(sd.origin_repo), issueRef: str(sd.issue_ref), taskSource: str(sd.task_source),
|
|
26
|
+
provider: str(sd.provider), model: str(sd.model),
|
|
27
|
+
startedAt: f.run.started?.ts ?? evs[0]?.ts ?? null, endedAt: f.run.completed?.ts ?? null,
|
|
28
|
+
verdict: f.run.verdict,
|
|
29
|
+
prUrl: str(done.pr_url) ?? str(pr?.url), prDraft: typeof pr?.draft === "boolean" ? Number(pr.draft) : null,
|
|
30
|
+
costUsd: f.cost.usd, partialUsd: pc.usd, measuredSessions: pc.measured, totalSessions: pc.total,
|
|
31
|
+
inputTokens: f.cost.inputTokens, outputTokens: f.cost.outputTokens,
|
|
32
|
+
wallS: typeof done.wall_s === "number" ? done.wall_s : null,
|
|
33
|
+
lastSeq: f.lastSeq, lastEventAt: evs[evs.length - 1]?.ts ?? null, tampered: Number(f.run.tampered),
|
|
34
|
+
};
|
|
35
|
+
// conflict is owned by ingest, so it is left out of the update set and survives a rebuild
|
|
36
|
+
db.insert(runs).values(row).onConflictDoUpdate({ target: [runs.sourceId, runs.runId], set: row }).run();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const parseRun = (r: typeof runs.$inferSelect) => ({
|
|
40
|
+
source_id: r.sourceId, run_id: r.runId, origin_repo: r.originRepo, issue_ref: r.issueRef, task_source: r.taskSource,
|
|
41
|
+
provider: r.provider, model: r.model, started_at: r.startedAt, ended_at: r.endedAt, verdict: r.verdict,
|
|
42
|
+
pr_url: r.prUrl, pr_draft: r.prDraft === null ? null : r.prDraft === 1,
|
|
43
|
+
cost_usd: r.costUsd, partial_usd: r.partialUsd, measured_sessions: r.measuredSessions, total_sessions: r.totalSessions,
|
|
44
|
+
input_tokens: r.inputTokens, output_tokens: r.outputTokens, wall_s: r.wallS, last_seq: r.lastSeq,
|
|
45
|
+
last_event_at: r.lastEventAt, tampered: r.tampered === 1, conflict: r.conflict === 1,
|
|
46
|
+
status: r.endedAt ? "completed" : "running", elapsed_s: elapsedS(r),
|
|
47
|
+
});
|
|
48
|
+
// A run with no run.completed yet is running: add its live fields (stage, files) from the stored events.
|
|
49
|
+
const withLive = (db: Db, r: typeof runs.$inferSelect) => {
|
|
50
|
+
const p = parseRun(r);
|
|
51
|
+
return r.endedAt ? { ...p, current_stage: null, files_touched: [] as string[] } : { ...p, ...liveInfo(loadEvents(db, r.sourceId, r.runId)) };
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Live view of one run from its events so far: current stage, files touched. Elapsed is computed per request. */
|
|
55
|
+
export function liveInfo(evs: EventEnvelope[]) {
|
|
56
|
+
const open: string[] = [];
|
|
57
|
+
for (const e of evs) {
|
|
58
|
+
if (e.stage === null || !e.type.startsWith("stage.")) continue;
|
|
59
|
+
const i = open.indexOf(e.stage);
|
|
60
|
+
if (e.type === "stage.started") { if (i < 0) open.push(e.stage); } else if (i >= 0) open.splice(i, 1);
|
|
61
|
+
}
|
|
62
|
+
const lastStage = [...evs].reverse().find((e) => e.stage !== null && e.type.startsWith("stage."))?.stage ?? null;
|
|
63
|
+
const files = new Set<string>();
|
|
64
|
+
for (const e of evs) for (const k of ["changed_files", "files"]) {
|
|
65
|
+
const v = e.data[k];
|
|
66
|
+
if (Array.isArray(v)) for (const f of v) if (typeof f === "string") files.add(f);
|
|
67
|
+
}
|
|
68
|
+
return { current_stage: open[open.length - 1] ?? lastStage, files_touched: [...files] };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const elapsedS = (r: typeof runs.$inferSelect): number | null => {
|
|
72
|
+
if (!r.startedAt) return null;
|
|
73
|
+
const end = r.endedAt ? Date.parse(r.endedAt) : Date.now();
|
|
74
|
+
return Math.max(0, (end - Date.parse(r.startedAt)) / 1000);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export interface ListQuery { verdict?: string; repo?: string; since?: string; until?: string; limit?: number; cursor?: string }
|
|
78
|
+
|
|
79
|
+
export function listRuns(db: Db, q: ListQuery) {
|
|
80
|
+
const limit = Math.min(Math.max(q.limit ?? 50, 1), 200);
|
|
81
|
+
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
|
|
82
|
+
const where = and(
|
|
83
|
+
q.verdict ? eq(runs.verdict, q.verdict) : undefined,
|
|
84
|
+
q.repo ? eq(runs.originRepo, q.repo) : undefined,
|
|
85
|
+
q.since ? gte(runs.startedAt, q.since) : undefined,
|
|
86
|
+
q.until ? lt(runs.startedAt, q.until) : undefined,
|
|
87
|
+
);
|
|
88
|
+
const total = db.select({ n: sql<number>`count(*)` }).from(runs).where(where).get()?.n ?? 0;
|
|
89
|
+
const rows = db.select().from(runs).where(where).orderBy(desc(runs.startedAt), asc(runs.runId)).limit(limit).offset(offset).all();
|
|
90
|
+
return { runs: rows.map((r) => withLive(db, r)), total, next_cursor: offset + rows.length < total ? String(offset + rows.length) : null };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Summary row plus the folded detail: stage timeline, receipt, not_proven. Null when the run is unknown. */
|
|
94
|
+
export function runDetail(db: Db, sourceId: string, runId: string) {
|
|
95
|
+
const r = db.select().from(runs).where(and(eq(runs.sourceId, sourceId), eq(runs.runId, runId))).get();
|
|
96
|
+
if (!r) return null;
|
|
97
|
+
const evs = loadEvents(db, sourceId, runId);
|
|
98
|
+
const stages: { stage: string; started_at: string | null; ended_at: string | null; status: string }[] = [];
|
|
99
|
+
for (const e of evs) {
|
|
100
|
+
if (e.stage === null || !e.type.startsWith("stage.")) continue;
|
|
101
|
+
let s = stages.find((x) => x.stage === e.stage);
|
|
102
|
+
if (!s) stages.push((s = { stage: e.stage, started_at: null, ended_at: null, status: "started" }));
|
|
103
|
+
if (e.type === "stage.started") s.started_at = e.ts;
|
|
104
|
+
else { s.ended_at = e.ts; s.status = e.type.slice("stage.".length); }
|
|
105
|
+
}
|
|
106
|
+
const f = fold(evs);
|
|
107
|
+
const done = f.run.completed?.data;
|
|
108
|
+
const sealed = evs.find((e) => e.type === "receipt.sealed")?.data;
|
|
109
|
+
return {
|
|
110
|
+
...withLive(db, r),
|
|
111
|
+
stages,
|
|
112
|
+
stages_completed: f.completed,
|
|
113
|
+
receipt: sealed ? { sha256: str(sealed.receipt_sha256), signed: sealed.signed === true, verdict: str(sealed.verdict), path: str(sealed.path) } : null,
|
|
114
|
+
not_proven: Array.isArray(done?.not_proven) ? (done.not_proven as string[]) : [],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Entry for `loki control serve` (bundled to dist/server.js). Config comes from env: PORT, LOKI_CONTROL_DB.
|
|
2
|
+
// Binds loopback only. LOKI_CONTROL_PORT_FALLBACK=1 (set for the default port, never for an explicit --port)
|
|
3
|
+
// retries on any free port when PORT is taken; the URL printed below is always the real one.
|
|
4
|
+
import { createApp } from "./app.ts";
|
|
5
|
+
|
|
6
|
+
const { app } = createApp({ dbPath: process.env.LOKI_CONTROL_DB ?? "control.db" });
|
|
7
|
+
const serve = (port: number) => Bun.serve({ port, hostname: "127.0.0.1", fetch: app.fetch });
|
|
8
|
+
let s: ReturnType<typeof serve>;
|
|
9
|
+
try {
|
|
10
|
+
s = serve(Number(process.env.PORT ?? 0));
|
|
11
|
+
} catch (e) {
|
|
12
|
+
if ((e as { code?: string }).code !== "EADDRINUSE" || process.env.LOKI_CONTROL_PORT_FALLBACK !== "1") throw e;
|
|
13
|
+
s = serve(0);
|
|
14
|
+
}
|
|
15
|
+
console.log(`loki-control listening on ${s.url}`);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// CP-02 backfill: ship every .loki/runs/*/events.jsonl of a repo through the same shipRun path as the live hook.
|
|
2
|
+
import { readdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { backoffMs, hasSpool, shipEnabled, shipRun, sourceId, type ShipResult } from "./ship.ts";
|
|
5
|
+
|
|
6
|
+
export interface BackfillOpts {
|
|
7
|
+
repoDir: string;
|
|
8
|
+
env?: NodeJS.ProcessEnv;
|
|
9
|
+
attempts?: number; // per run, with backoff between
|
|
10
|
+
sleep?: (ms: number) => Promise<void>;
|
|
11
|
+
fetchImpl?: typeof fetch;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function backfill(o: BackfillOpts): Promise<{ runs: number; sent: number; failed: string[] }> {
|
|
15
|
+
const env = o.env ?? process.env;
|
|
16
|
+
const url = shipEnabled(env);
|
|
17
|
+
if (!url) return { runs: 0, sent: 0, failed: [] };
|
|
18
|
+
const sleep = o.sleep ?? ((ms) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
19
|
+
const attempts = o.attempts ?? 3;
|
|
20
|
+
const root = join(o.repoDir, ".loki", "runs");
|
|
21
|
+
let ids: string[] = [];
|
|
22
|
+
try { ids = readdirSync(root).sort(); } catch { return { runs: 0, sent: 0, failed: [] }; }
|
|
23
|
+
const source = sourceId(o.repoDir);
|
|
24
|
+
let runs = 0, sent = 0;
|
|
25
|
+
const failed: string[] = [];
|
|
26
|
+
for (const id of ids) {
|
|
27
|
+
const runDir = join(root, id);
|
|
28
|
+
if (!hasSpool(runDir)) continue;
|
|
29
|
+
runs++;
|
|
30
|
+
let res: ShipResult = { ok: false, sent: 0, acked_seq: -1, pending: 0 };
|
|
31
|
+
for (let a = 1; a <= attempts; a++) {
|
|
32
|
+
res = await shipRun({ runDir, url, source, token: env.LOKI_CONTROL_TOKEN, fetchImpl: o.fetchImpl });
|
|
33
|
+
sent += res.sent;
|
|
34
|
+
if (res.ok) break;
|
|
35
|
+
if (a < attempts) await sleep(backoffMs(a));
|
|
36
|
+
}
|
|
37
|
+
if (!res.ok) failed.push(id);
|
|
38
|
+
}
|
|
39
|
+
return { runs, sent, failed };
|
|
40
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// CP-02 shipper (docs/v10/CONTROL-PLANE.md section 5). Reads events.jsonl (the spool, never written here) and POSTs
|
|
2
|
+
// batches to /v1/ingest. Progress lives in ship.json next to the log. Never throws: a failure returns ok:false.
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { existsSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { hostname } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { readEvents } from "../../../../loki-ts/src/engine10/events.ts";
|
|
8
|
+
import type { EventEnvelope } from "../../../../loki-ts/src/engine10/types.ts";
|
|
9
|
+
import { redactSecrets } from "../../../../loki-ts/src/util/redact.ts";
|
|
10
|
+
|
|
11
|
+
export const BATCH = 200;
|
|
12
|
+
const MAX_BYTES = 800_000; // the service rejects a body over 1 MB
|
|
13
|
+
|
|
14
|
+
/** sha256(hostname NUL realpath(repo)), 16 hex. No raw path or hostname leaves the machine. */
|
|
15
|
+
export function sourceId(repoDir: string): string {
|
|
16
|
+
let real = repoDir;
|
|
17
|
+
try { real = realpathSync(repoDir); } catch { /* keep as given */ }
|
|
18
|
+
return createHash("sha256").update(`${hostname()}\0${real}`).digest("hex").slice(0, 16);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const redactDeep = (x: unknown): unknown =>
|
|
22
|
+
typeof x === "string" ? redactSecrets(x)
|
|
23
|
+
: Array.isArray(x) ? x.map(redactDeep)
|
|
24
|
+
: x && typeof x === "object" ? Object.fromEntries(Object.entries(x).map(([k, v]) => [k, redactDeep(v)]))
|
|
25
|
+
: x;
|
|
26
|
+
|
|
27
|
+
export const redactEvent = (e: EventEnvelope): EventEnvelope => ({ ...e, data: redactDeep(e.data) as EventEnvelope["data"] });
|
|
28
|
+
|
|
29
|
+
/** Backoff 1, 2, 4 ... 60 s with up to 25% jitter. */
|
|
30
|
+
export const backoffMs = (failures: number, rnd: () => number = Math.random): number =>
|
|
31
|
+
Math.min(60_000, 1000 * 2 ** Math.max(0, failures - 1)) * (1 + 0.25 * rnd());
|
|
32
|
+
|
|
33
|
+
export interface ShipState { acked_seq: number; url: string; skipped?: number }
|
|
34
|
+
export function readShip(runDir: string, url: string): ShipState {
|
|
35
|
+
try {
|
|
36
|
+
const s = JSON.parse(readFileSync(join(runDir, "ship.json"), "utf8")) as Partial<ShipState>;
|
|
37
|
+
if (s.url === url && Number.isInteger(s.acked_seq)) return { acked_seq: s.acked_seq as number, url };
|
|
38
|
+
} catch { /* none or corrupt: start over; the server dedupes */ }
|
|
39
|
+
return { acked_seq: -1, url };
|
|
40
|
+
}
|
|
41
|
+
function writeShip(runDir: string, s: ShipState): void {
|
|
42
|
+
const tmp = join(runDir, `ship.json.${process.pid}.tmp`);
|
|
43
|
+
writeFileSync(tmp, JSON.stringify(s) + "\n");
|
|
44
|
+
renameSync(tmp, join(runDir, "ship.json"));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ShipOpts {
|
|
48
|
+
runDir: string; // <repo>/.loki/runs/<id>
|
|
49
|
+
url: string; // base URL of the control plane
|
|
50
|
+
source: string;
|
|
51
|
+
token?: string | undefined;
|
|
52
|
+
timeoutMs?: number;
|
|
53
|
+
fetchImpl?: typeof fetch | undefined; // tests
|
|
54
|
+
}
|
|
55
|
+
export interface ShipResult { ok: boolean; sent: number; acked_seq: number; pending: number }
|
|
56
|
+
|
|
57
|
+
/** One pass: ship every event above acked_seq. ship.json is written only after a 2xx (or a 409, which a retry cannot fix). */
|
|
58
|
+
export async function shipRun(o: ShipOpts): Promise<ShipResult> {
|
|
59
|
+
const st = readShip(o.runDir, o.url);
|
|
60
|
+
const all = readEvents(join(o.runDir, "events.jsonl"));
|
|
61
|
+
let lines = 0;
|
|
62
|
+
try { lines = readFileSync(join(o.runDir, "events.jsonl"), "utf8").split("\n").filter((l) => l.trim() !== "").length; } catch { /* none */ }
|
|
63
|
+
const skipped = lines - all.length; // invalid lines (for example a tamper forgery) are never sent: the service 400s a whole batch on one
|
|
64
|
+
const todo = all.filter((e) => e.seq > st.acked_seq).sort((a, b) => a.seq - b.seq);
|
|
65
|
+
if (todo.length === 0) return { ok: true, sent: 0, acked_seq: st.acked_seq, pending: 0 };
|
|
66
|
+
const run_id = todo[0]!.run;
|
|
67
|
+
const f = o.fetchImpl ?? fetch;
|
|
68
|
+
let sent = 0;
|
|
69
|
+
const fail = (): ShipResult => ({ ok: false, sent, acked_seq: st.acked_seq, pending: todo.length - sent });
|
|
70
|
+
for (let i = 0; i < todo.length;) {
|
|
71
|
+
const batch: EventEnvelope[] = [];
|
|
72
|
+
for (let bytes = 0; i < todo.length && batch.length < BATCH; i++) {
|
|
73
|
+
bytes += JSON.stringify(todo[i]).length;
|
|
74
|
+
if (batch.length > 0 && bytes > MAX_BYTES) break;
|
|
75
|
+
batch.push(redactEvent(todo[i]!));
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const r = await f(`${o.url.replace(/\/+$/, "")}/v1/ingest`, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { "content-type": "application/json", ...(o.token ? { authorization: `Bearer ${o.token}` } : {}) },
|
|
81
|
+
body: JSON.stringify({ source: o.source, run_id, events: batch }),
|
|
82
|
+
signal: AbortSignal.timeout(o.timeoutMs ?? 10_000),
|
|
83
|
+
});
|
|
84
|
+
if (!r.ok && r.status !== 409) return fail();
|
|
85
|
+
} catch { return fail(); }
|
|
86
|
+
st.acked_seq = batch[batch.length - 1]!.seq;
|
|
87
|
+
st.skipped = skipped;
|
|
88
|
+
sent += batch.length;
|
|
89
|
+
try { writeShip(o.runDir, st); } catch { return fail(); }
|
|
90
|
+
}
|
|
91
|
+
return { ok: true, sent, acked_seq: st.acked_seq, pending: 0 };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const shipEnabled = (env: NodeJS.ProcessEnv): string | null => env.LOKI_CONTROL_URL || null;
|
|
95
|
+
export const hasSpool = (runDir: string): boolean => existsSync(join(runDir, "events.jsonl"));
|