@bli-cockpit/log-digest 0.0.0-stage → 0.1.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/dist/merge.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Several digest results as one (port of Kaiben's `merge_results`): counts
3
+ * summed, times widened, instance sets united. The next slice, a fleet view of
4
+ * every machine's collector digest, merges per-machine results with this.
5
+ */
6
+ import { SUBJECTS_TRACKED } from "./digest.js";
7
+ function earliest(a, b) {
8
+ const values = [a, b].filter((value) => Boolean(value));
9
+ return values.length > 0 ? Math.min(...values) : null;
10
+ }
11
+ function latest(a, b) {
12
+ const value = Math.max(a ?? 0, b ?? 0);
13
+ return value || null;
14
+ }
15
+ export function mergeResults(results, registry = {}) {
16
+ const merged = {
17
+ lines: 0, noise: 0,
18
+ span: [null, null],
19
+ cursors: [null, null],
20
+ groups: new Map(),
21
+ routine: Object.create(null),
22
+ };
23
+ for (const result of results) {
24
+ merged.lines += result.lines ?? 0;
25
+ merged.noise += result.noise ?? 0;
26
+ const [start, end] = result.span ?? [null, null];
27
+ if (start && (merged.span[0] === null || start < merged.span[0]))
28
+ merged.span[0] = start;
29
+ if (end && (merged.span[1] === null || end > merged.span[1]))
30
+ merged.span[1] = end;
31
+ const [first, last] = result.cursors ?? [null, null];
32
+ merged.cursors[0] = merged.cursors[0] ?? first;
33
+ merged.cursors[1] = last ?? merged.cursors[1];
34
+ const groups = result.groups ?? [];
35
+ for (const record of Array.isArray(groups) ? groups : Object.values(groups)) {
36
+ const known = merged.groups.get(record.tid);
37
+ if (!known) {
38
+ merged.groups.set(record.tid, { ...registry[record.tid], ...record, instances: [...(record.instances ?? [])], tid: record.tid });
39
+ continue;
40
+ }
41
+ known.count += record.count;
42
+ known.first = earliest(known.first, record.first);
43
+ known.last = latest(known.last, record.last);
44
+ for (const instance of record.instances ?? []) {
45
+ if (!known.instances.includes(instance) && known.instances.length < 64)
46
+ known.instances.push(instance);
47
+ }
48
+ }
49
+ for (const [group, events] of Object.entries(result.routine ?? {})) {
50
+ const into = (merged.routine[group] ??= Object.create(null));
51
+ for (const [event, [count, subjects]] of Object.entries(events)) {
52
+ const record = (into[event] ??= [0, Object.create(null)]);
53
+ record[0] += count;
54
+ for (const [subject, amount] of Object.entries(subjects)) {
55
+ if (subject in record[1] || Object.keys(record[1]).length < SUBJECTS_TRACKED)
56
+ record[1][subject] = (record[1][subject] ?? 0) + amount;
57
+ else
58
+ record[1]["…"] = (record[1]["…"] ?? 0) + amount;
59
+ }
60
+ }
61
+ }
62
+ }
63
+ const groups = [...merged.groups.values()].sort((a, b) => b.count - a.count || (a.tid < b.tid ? -1 : a.tid > b.tid ? 1 : 0));
64
+ return { lines: merged.lines, noise: merged.noise, span: merged.span, cursors: merged.cursors, groups, routine: merged.routine, breaker: {} };
65
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Text renderings of a digest (port of Kaiben's `render_local` and
3
+ * `render_fleet`, plus `renderCapped`, the 2 KB view `cockpit logs` prints).
4
+ *
5
+ * Every renderer is a pure function of its input: the same digest renders
6
+ * byte-identically.
7
+ */
8
+ import type { Classify } from "./classify.js";
9
+ import type { DigestResult, RoutineEvents } from "./digest.js";
10
+ import type { MergedResult } from "./merge.js";
11
+ export declare function numberText(value: number): string;
12
+ export declare function clock(epoch: number | null | undefined): string;
13
+ export declare function timeRange(first: number | null | undefined, last: number | null | undefined): string;
14
+ /** Caveman's class-invariant enumeration: exact counts for a small value set, else nothing (never a partial list). */
15
+ export declare function subjectsText(subjects: Record<string, number>): string;
16
+ /** `stored=2,280(Volkswagen) retiredLocalCache=12,962 +3`: biggest event counts first. */
17
+ export declare function routineText(routine: Record<string, RoutineEvents>, limit?: number): string;
18
+ /** One machine's (or source's) digest as text. */
19
+ export declare function renderLocal(result: DigestResult, server?: string, classify?: Classify, maxLines?: number): string;
20
+ export interface FleetView {
21
+ window: [number, number];
22
+ servers: Record<string, MergedResult | DigestResult>;
23
+ classify: Classify;
24
+ newTids?: ReadonlySet<string>;
25
+ backTids?: ReadonlySet<string>;
26
+ machines?: string;
27
+ incidents?: string;
28
+ cursorFile?: string;
29
+ }
30
+ /** A fleet view straight from per-server results (no operator state): `--file` runs and tests. */
31
+ export declare function fleetView(resultsByServer: Record<string, MergedResult | DigestResult>, classify: Classify, window?: [number, number]): FleetView;
32
+ /** The fleet digest: every server's templates merged by tid, ERROR first, hiccups counted, WARNINGs one line. */
33
+ export declare function renderFleet(view: FleetView): string;
34
+ export interface CappedOptions {
35
+ /** `dashboard`, `railway:ladder`... */
36
+ source: string;
37
+ /** `last 1h`, `since 2026-10-07T03:00Z`. */
38
+ window: string;
39
+ classify: Classify;
40
+ /** Hard ceiling on the printed bytes. About 2 KB by default. */
41
+ maxBytes?: number;
42
+ /** Plain-words facts about the read itself (capped at N lines, no per-line timestamps...), printed as they are. */
43
+ notes?: readonly string[];
44
+ }
45
+ /**
46
+ * The view an agent or a person reads: ERROR classes first (unknown, our_bug,
47
+ * config_drift, capacity), then hiccups, then ONE line counting what is harmless,
48
+ * then the routine work counts. Whatever does not fit the byte budget becomes a
49
+ * `+N more` line naming the next template ids, so nothing is dropped silently.
50
+ */
51
+ export declare function renderCapped(result: DigestResult, options: CappedOptions): string;
package/dist/render.js ADDED
@@ -0,0 +1,214 @@
1
+ import { CLASS_ORDER } from "./classify.js";
2
+ import { cut } from "./mask.js";
3
+ export function numberText(value) {
4
+ return value.toLocaleString("en-US");
5
+ }
6
+ export function clock(epoch) {
7
+ if (!epoch)
8
+ return "?";
9
+ const date = new Date(epoch * 1000);
10
+ return `${String(date.getUTCHours()).padStart(2, "0")}:${String(date.getUTCMinutes()).padStart(2, "0")}`;
11
+ }
12
+ export function timeRange(first, last) {
13
+ return clock(first) === clock(last) ? clock(first) : `${clock(first)}-${clock(last)}`;
14
+ }
15
+ function short(text, limit) {
16
+ return Array.from(text).length <= limit ? text : `${cut(text, limit - 1)}…`;
17
+ }
18
+ /** Caveman's class-invariant enumeration: exact counts for a small value set, else nothing (never a partial list). */
19
+ export function subjectsText(subjects) {
20
+ const names = Object.keys(subjects);
21
+ if (names.length === 0 || "…" in subjects || names.length > 4)
22
+ return "";
23
+ if (names.length === 1)
24
+ return `(${names[0]})`;
25
+ const ranked = names.sort((a, b) => subjects[b] - subjects[a] || (a < b ? -1 : a > b ? 1 : 0));
26
+ return `(${ranked.map((name) => `${name}×${numberText(subjects[name])}`).join(" ")})`;
27
+ }
28
+ /** `stored=2,280(Volkswagen) retiredLocalCache=12,962 +3`: biggest event counts first. */
29
+ export function routineText(routine, limit = 2) {
30
+ const items = [];
31
+ for (const [group, events] of Object.entries(routine)) {
32
+ for (const [event, [count, subjects]] of Object.entries(events)) {
33
+ if (event === "text" || event === "long_line" || event === "relay" || event.startsWith("systemd "))
34
+ continue;
35
+ items.push([count, group, event, subjects]);
36
+ }
37
+ }
38
+ items.sort((a, b) => b[0] - a[0] || (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0) || (a[2] < b[2] ? -1 : a[2] > b[2] ? 1 : 0));
39
+ const parts = items.slice(0, limit).map(([count, , event, subjects]) => `${event}=${numberText(count)}${subjectsText(subjects)}`);
40
+ if (items.length > limit)
41
+ parts.push(`+${items.length - limit}`);
42
+ return parts.join(" ") || "-";
43
+ }
44
+ const NONE = {};
45
+ /** One machine's (or source's) digest as text. */
46
+ export function renderLocal(result, server = "this machine", classify, maxLines) {
47
+ const span = result.span;
48
+ const cursor = result.cursors[1];
49
+ const lines = [
50
+ `DIGEST ${server} ${timeRange(span[0], span[1])}Z | ${numberText(result.lines)} lines kept, ${numberText(result.noise)} noise | `
51
+ + `${result.groups.length} templates | cursor ${cursor ?? "-"}`,
52
+ ];
53
+ const verdicts = new Map(result.groups.map((record) => [record.tid, classify ? classify(record.unit, record.template, server) : NONE]));
54
+ const order = (record) => CLASS_ORDER[verdicts.get(record.tid)?.class ?? ""] ?? 0;
55
+ const groups = [...result.groups].sort((a, b) => order(a) - order(b) || b.count - a.count || (a.tid < b.tid ? -1 : a.tid > b.tid ? 1 : 0));
56
+ for (const record of groups) {
57
+ const verdict = verdicts.get(record.tid);
58
+ const word = [verdict.severity, verdict.class].filter(Boolean).join(" ");
59
+ const units = record.instances.length > 1 ? `(${record.instances.length}u)` : "";
60
+ lines.push(` ${record.tid} ${record.unit}${units} ${word} x${numberText(record.count)} ${timeRange(record.first, record.last)} ${short(record.template, 140)}`);
61
+ }
62
+ lines.push(` WORK ${routineText(result.routine, 8)}`);
63
+ if (maxLines && lines.length > maxLines) {
64
+ const hidden = lines.length - maxLines;
65
+ const last = lines[lines.length - 1];
66
+ return [...lines.slice(0, maxLines - 2), ` … ${hidden + 1} more templates (raise --max-lines)`, last].join("\n");
67
+ }
68
+ return lines.join("\n");
69
+ }
70
+ /** A fleet view straight from per-server results (no operator state): `--file` runs and tests. */
71
+ export function fleetView(resultsByServer, classify, window) {
72
+ const spans = Object.values(resultsByServer).flatMap((result) => result.span).filter((value) => Boolean(value));
73
+ return { window: window ?? [Math.min(...(spans.length ? spans : [0])), Math.max(...(spans.length ? spans : [0]))], servers: resultsByServer, classify };
74
+ }
75
+ const compareTid = (a, b) => (a.tid < b.tid ? -1 : a.tid > b.tid ? 1 : 0);
76
+ /** The fleet digest: every server's templates merged by tid, ERROR first, hiccups counted, WARNINGs one line. */
77
+ export function renderFleet(view) {
78
+ const [start, end] = view.window;
79
+ const names = Object.keys(view.servers).sort();
80
+ const kept = names.reduce((sum, name) => sum + view.servers[name].lines, 0);
81
+ const noise = names.reduce((sum, name) => sum + view.servers[name].noise, 0);
82
+ const byTid = new Map();
83
+ for (const name of names) {
84
+ for (const record of view.servers[name].groups) {
85
+ const entry = byTid.get(record.tid)
86
+ ?? { ...record, servers: [], count: 0, instances: [], first: record.first, last: record.last, class: "unknown", severity: "ERROR" };
87
+ byTid.set(record.tid, entry);
88
+ entry.servers.push(name);
89
+ entry.count += record.count;
90
+ const firsts = [entry.first, record.first].filter((value) => Boolean(value));
91
+ entry.first = firsts.length ? Math.min(...firsts) : null;
92
+ entry.last = Math.max(entry.last ?? 0, record.last ?? 0) || null;
93
+ entry.instances = [...new Set([...entry.instances, ...record.instances])].sort();
94
+ }
95
+ }
96
+ const sections = { ERROR: [], hiccup: [], WARNING: [] };
97
+ for (const entry of byTid.values()) {
98
+ const verdict = view.classify(entry.unit, entry.template, entry.servers[0]);
99
+ entry.class = verdict.class;
100
+ entry.severity = verdict.severity;
101
+ (sections[entry.severity] ??= []).push(entry);
102
+ }
103
+ const fresh = view.newTids ?? new Set();
104
+ const back = view.backTids ?? new Set();
105
+ const total = (name) => (sections[name] ?? []).reduce((sum, entry) => sum + entry.count, 0);
106
+ const day = new Date(end * 1000).toISOString().slice(0, 10);
107
+ const newHere = [...fresh].filter((tid) => byTid.has(tid)).length;
108
+ const out = [
109
+ `FLEET DIGEST ${day} ${clock(start)}-${clock(end)}Z | ${names.length} servers | ${numberText(kept + noise)} lines (${numberText(noise)} noise) | `
110
+ + `ERROR ${numberText(total("ERROR"))}, hiccup ${numberText(total("hiccup"))}, WARNING ${numberText(total("WARNING"))} in ${byTid.size} templates, ${newHere} NEW`,
111
+ ];
112
+ if (view.cursorFile)
113
+ out.push(`raw: cockpit logs <source> --raw <tid> | cursors+range: ${view.cursorFile}`);
114
+ const line = (entry, templateChars) => {
115
+ const mark = fresh.has(entry.tid) ? "NEW " : back.has(entry.tid) ? "BACK " : "";
116
+ const units = entry.instances.length > 1 ? `(${entry.instances.length}u)` : "";
117
+ return ` ${mark}${entry.tid} ${entry.servers.join(",")} ${entry.unit}${units} ${entry.class} x${numberText(entry.count)} ${timeRange(entry.first, entry.last)} ${short(entry.template, templateChars)}`;
118
+ };
119
+ const ranked = (entries) => [...entries].sort((a, b) => Number(!fresh.has(a.tid)) - Number(!fresh.has(b.tid))
120
+ || (CLASS_ORDER[a.class] ?? 0) - (CLASS_ORDER[b.class] ?? 0)
121
+ || b.count - a.count
122
+ || compareTid(a, b));
123
+ for (const [key, title, limit, chars] of [["ERROR", "ERROR (must fix, alerts)", 14, 120], ["hiccup", "hiccup (retried with back-off)", 6, 100]]) {
124
+ const entries = ranked(sections[key] ?? []);
125
+ out.push(`${title}: ${entries.length}`);
126
+ out.push(...entries.slice(0, limit).map((entry) => line(entry, chars)));
127
+ if (entries.length > limit)
128
+ out.push(` +${entries.length - limit} more: ${entries.slice(limit, limit + 12).map((entry) => entry.tid).join(" ")}`);
129
+ }
130
+ const counted = [...(sections["WARNING"] ?? [])].sort((a, b) => b.count - a.count);
131
+ if (counted.length > 0) {
132
+ const parts = counted.slice(0, 6).map((entry) => `${entry.servers.join(",")} ${short(entry.template, 48)} x${numberText(entry.count)}`);
133
+ const more = counted.length > 6 ? ` +${counted.length - 6}` : "";
134
+ out.push(`WARNING (nothing to fix, counted, no alert): ${counted.length}: ${parts.join("; ")}${more}`);
135
+ }
136
+ out.push(`WORK ${names.map((name) => `${name} ${routineText(view.servers[name].routine)}`).join(" | ")}`);
137
+ if (view.machines)
138
+ out.push(`MACHINES ${view.machines}`);
139
+ if (view.incidents)
140
+ out.push(`INCIDENTS ${view.incidents}`);
141
+ return out.join("\n");
142
+ }
143
+ /**
144
+ * The view an agent or a person reads: ERROR classes first (unknown, our_bug,
145
+ * config_drift, capacity), then hiccups, then ONE line counting what is harmless,
146
+ * then the routine work counts. Whatever does not fit the byte budget becomes a
147
+ * `+N more` line naming the next template ids, so nothing is dropped silently.
148
+ */
149
+ export function renderCapped(result, options) {
150
+ const maxBytes = options.maxBytes ?? 2048;
151
+ const entries = result.groups.map((record) => ({ record, verdict: options.classify(record.unit, record.template, options.source) }));
152
+ const rank = (a, b) => (CLASS_ORDER[a.verdict.class] ?? 0) - (CLASS_ORDER[b.verdict.class] ?? 0) || b.record.count - a.record.count || compareTid(a.record, b.record);
153
+ const bySeverity = (severity) => entries.filter((entry) => entry.verdict.severity === severity).sort(rank);
154
+ const errors = bySeverity("ERROR");
155
+ const hiccups = bySeverity("hiccup");
156
+ const counted = bySeverity("WARNING").sort((a, b) => b.record.count - a.record.count || compareTid(a.record, b.record));
157
+ const sum = (list) => list.reduce((total, entry) => total + entry.record.count, 0);
158
+ const head = `LOGS ${options.source} ${options.window}${result.span[0] ? ` ${timeRange(result.span[0], result.span[1])}Z` : ""} | ${numberText(result.lines + result.noise)} lines | `
159
+ + `${entries.length} templates | ERROR ${numberText(sum(errors))}, hiccup ${numberText(sum(hiccups))}, counted ${numberText(sum(counted))}`;
160
+ const required = [head, ...(options.notes ?? []), `WORK ${routineText(result.routine, 6)}`];
161
+ const row = ({ record, verdict }, chars) => {
162
+ const units = record.instances.length > 1 ? `(${record.instances.length}u)` : "";
163
+ const when = record.first ? ` ${timeRange(record.first, record.last)}` : "";
164
+ return ` ${record.tid} ${record.unit}${units} ${verdict.class} x${numberText(record.count)}${when} ${short(record.template, chars)}`;
165
+ };
166
+ const countedLine = counted.length
167
+ ? `COUNTED (harmless, no alert): ${counted.length}: ${counted.slice(0, 4).map((e) => `${short(e.record.template, 40)} x${numberText(e.record.count)}`).join("; ")}${counted.length > 4 ? ` +${counted.length - 4}` : ""}`
168
+ : null;
169
+ const flex = [];
170
+ const flexIds = [];
171
+ if (errors.length) {
172
+ flex.push(`ERROR (must fix): ${errors.length}`);
173
+ flexIds.push("");
174
+ }
175
+ for (const entry of errors) {
176
+ flex.push(row(entry, 110));
177
+ flexIds.push(entry.record.tid);
178
+ }
179
+ if (hiccups.length) {
180
+ flex.push(`hiccup (retried): ${hiccups.length}`);
181
+ flexIds.push("");
182
+ }
183
+ for (const entry of hiccups) {
184
+ flex.push(row(entry, 90));
185
+ flexIds.push(entry.record.tid);
186
+ }
187
+ const reserve = 90; // room for the `+N more` line
188
+ const bytes = (lines) => Buffer.byteLength(lines.join("\n") + "\n", "utf8");
189
+ const kept = [];
190
+ const keptTail = countedLine ? [countedLine] : [];
191
+ let used = bytes([...required, ...keptTail]) + reserve;
192
+ let cutAt = flex.length;
193
+ for (let index = 0; index < flex.length; index += 1) {
194
+ const size = Buffer.byteLength(flex[index] + "\n", "utf8");
195
+ if (used + size > maxBytes) {
196
+ cutAt = index;
197
+ break;
198
+ }
199
+ kept.push(flex[index]);
200
+ used += size;
201
+ }
202
+ const lost = flexIds.slice(cutAt).filter(Boolean);
203
+ const lines = [head, ...(options.notes ?? []), ...kept];
204
+ if (lost.length)
205
+ lines.push(` +${lost.length} more templates: ${lost.slice(0, 6).join(" ")} (cockpit logs ${options.source} --json for all)`);
206
+ if (countedLine)
207
+ lines.push(countedLine);
208
+ lines.push(required[required.length - 1]);
209
+ // A final guard: a single over-long note must never break the ceiling.
210
+ let text = lines.join("\n");
211
+ if (Buffer.byteLength(text, "utf8") > maxBytes)
212
+ text = `${Buffer.from(text, "utf8").subarray(0, maxBytes - 4).toString("utf8")}\n…`;
213
+ return text;
214
+ }
@@ -0,0 +1,15 @@
1
+ import { type SourceContext, type SourceLine, type SourceRead } from "./types.js";
2
+ /** The share of a file (0..1) that falls inside the window, by time, given when it started and ended. */
3
+ export declare function windowShare(fromEpoch: number, start: number | null, end: number): number;
4
+ /** The one line a tick receipt is judged as. */
5
+ export declare function receiptLine(report: Record<string, unknown>): string;
6
+ /**
7
+ * Splits the stream into digest lines, folding each column-0 `{ ... }` receipt
8
+ * into one line. Body lines of a receipt whose start is not in the read are
9
+ * counted, not judged: they are JSON syntax, not log lines.
10
+ */
11
+ export declare function streamLines(text: string, stream: string): {
12
+ lines: SourceLine[];
13
+ bodySkipped: number;
14
+ };
15
+ export declare function readCollector(context: SourceContext): Promise<SourceRead>;
@@ -0,0 +1,166 @@
1
+ /**
2
+ * `collector`: this machine's collector logs (`sync.log`, `sync.err.log`, their
3
+ * rotated archives; the BLI Memory hooks write to `sync.err.log` too).
4
+ *
5
+ * The state directory is the caller's to compute (it differs per platform and
6
+ * `--home`); on macOS and Windows alike it is `<home>/.local/state/bli-cockpit`
7
+ * and the scheduled tick appends to `sync.log` (stdout) and, on macOS,
8
+ * `sync.err.log` (stderr). Rotation is a rename, so an archive keeps the time of
9
+ * its last write and the next-newer file starts where it ended.
10
+ *
11
+ * These logs carry NO per-line time. The window is therefore the share of each
12
+ * file that falls inside it, taken from the end of the file, assuming lines were
13
+ * written evenly between the file's first and last write; the digest says so.
14
+ *
15
+ * `sync.log` is mostly the tick's `--json` receipt printed over hundreds of
16
+ * indented lines. Each receipt is folded into one line (status, mode and the
17
+ * failure reasons it names) before judging, so a failed tick is one template and
18
+ * a healthy one is one routine count.
19
+ */
20
+ import { open, readdir, stat } from "node:fs/promises";
21
+ import { join } from "node:path";
22
+ import { SourceRefusal } from "./types.js";
23
+ const LOG_FILE = /^(sync(?:\.err)?\.log)(?:\.(\d+))?$/;
24
+ const MAX_BYTES_PER_STREAM = 64 * 1024 * 1024;
25
+ /** The share of a file (0..1) that falls inside the window, by time, given when it started and ended. */
26
+ export function windowShare(fromEpoch, start, end) {
27
+ if (end <= fromEpoch)
28
+ return 0;
29
+ if (start === null || start >= end)
30
+ return 1;
31
+ return Math.min(1, Math.max(0, (end - fromEpoch) / (end - start)));
32
+ }
33
+ async function readTail(path, bytes, size) {
34
+ const handle = await open(path, "r");
35
+ try {
36
+ const length = Math.min(bytes, size);
37
+ const buffer = Buffer.alloc(length);
38
+ await handle.read(buffer, 0, length, size - length);
39
+ let text = buffer.toString("utf8");
40
+ if (length < size)
41
+ text = text.slice(text.indexOf("\n") + 1); // drop the cut-off first line
42
+ return text;
43
+ }
44
+ finally {
45
+ await handle.close();
46
+ }
47
+ }
48
+ const TAG = /^\[([a-z][a-z0-9 _-]{0,40})\]/i;
49
+ function reasonsOf(report) {
50
+ const raw = report["failure_reasons"];
51
+ if (!Array.isArray(raw))
52
+ return "";
53
+ return raw
54
+ .map((item) => (typeof item === "string" ? item : typeof item === "object" && item !== null ? String(item["reason"] ?? item["code"] ?? "object") : String(item)))
55
+ .slice(0, 3)
56
+ .join(",");
57
+ }
58
+ /** The one line a tick receipt is judged as. */
59
+ export function receiptLine(report) {
60
+ const summary = { event: "sync_report", kind: String(report["mode"] ?? "tick").slice(0, 24) };
61
+ if (typeof report["status"] === "string")
62
+ summary["status"] = report["status"];
63
+ const reasons = reasonsOf(report);
64
+ if (reasons)
65
+ summary["error"] = reasons;
66
+ return JSON.stringify(summary);
67
+ }
68
+ // A line of a pretty-printed receipt whose opening brace is not in this read (rotation cut it, or the window did).
69
+ const RECEIPT_BODY = /^\s{2,}["{}[\]]|^[}\]],?$/;
70
+ /**
71
+ * Splits the stream into digest lines, folding each column-0 `{ ... }` receipt
72
+ * into one line. Body lines of a receipt whose start is not in the read are
73
+ * counted, not judged: they are JSON syntax, not log lines.
74
+ */
75
+ export function streamLines(text, stream) {
76
+ const out = [];
77
+ let bodySkipped = 0;
78
+ const unitOf = (line) => TAG.exec(line)?.[1] ?? (stream === "sync.err.log" ? "sync.err" : "sync");
79
+ const rows = text.split("\n");
80
+ for (let index = 0; index < rows.length; index += 1) {
81
+ const row = rows[index];
82
+ if (row === "{") {
83
+ let end = index + 1;
84
+ while (end < rows.length && rows[end] !== "}")
85
+ end += 1;
86
+ if (end < rows.length) {
87
+ try {
88
+ const report = JSON.parse(rows.slice(index, end + 1).join("\n"));
89
+ out.push({ unit: "sync", message: receiptLine(report), ts: 0 });
90
+ index = end;
91
+ continue;
92
+ }
93
+ catch {
94
+ // not a receipt after all: fall through and judge its lines one by one
95
+ }
96
+ }
97
+ }
98
+ if (RECEIPT_BODY.test(row) || row === "{")
99
+ bodySkipped += 1;
100
+ else if (row.trim())
101
+ out.push({ unit: unitOf(row), message: row, ts: 0 });
102
+ }
103
+ return { lines: out, bodySkipped };
104
+ }
105
+ export async function readCollector(context) {
106
+ const { stateDir, window, log } = context;
107
+ if (!stateDir)
108
+ throw new SourceRefusal("collector_state_unknown", "the caller did not say where the collector keeps its state");
109
+ let names;
110
+ try {
111
+ names = await readdir(stateDir);
112
+ }
113
+ catch {
114
+ throw new SourceRefusal("collector_state_missing", "there is no bli-cockpit state directory here: the collector has never run on this machine (`cockpit doctor` sets it up)");
115
+ }
116
+ const files = [];
117
+ for (const name of names) {
118
+ const match = LOG_FILE.exec(name);
119
+ if (!match)
120
+ continue;
121
+ try {
122
+ const info = await stat(join(stateDir, name));
123
+ files.push({ name, stream: match[1], index: match[2] ? Number(match[2]) : 0, size: info.size, mtime: info.mtimeMs / 1000, birth: info.birthtimeMs > 0 ? info.birthtimeMs / 1000 : null });
124
+ }
125
+ catch {
126
+ log("[logs collector] a log file vanished between listing and reading", { file: name });
127
+ }
128
+ }
129
+ if (files.length === 0)
130
+ throw new SourceRefusal("collector_logs_missing", "no sync.log or sync.err.log in the state directory: the scheduled tick has not written anything on this machine");
131
+ const lines = [];
132
+ const read = [];
133
+ let skipped = 0;
134
+ let bytesRead = 0;
135
+ for (const stream of [...new Set(files.map((file) => file.stream))].sort()) {
136
+ const chain = files.filter((file) => file.stream === stream).sort((a, b) => a.index - b.index);
137
+ let budget = MAX_BYTES_PER_STREAM;
138
+ for (let position = 0; position < chain.length; position += 1) {
139
+ const file = chain[position];
140
+ const end = position === 0 ? window.toEpoch : file.mtime;
141
+ const older = chain[position + 1];
142
+ const start = older ? older.mtime : file.birth;
143
+ const share = windowShare(window.fromEpoch, start, end);
144
+ if (share === 0 || file.size === 0)
145
+ continue;
146
+ const want = Math.min(budget, Math.ceil(file.size * share));
147
+ budget -= want;
148
+ const text = await readTail(join(stateDir, file.name), want, file.size);
149
+ const parsed = streamLines(text, stream);
150
+ lines.push(...parsed.lines);
151
+ skipped += parsed.bodySkipped;
152
+ bytesRead += want;
153
+ read.push(`${file.name} ${Math.round(share * 100)}%`);
154
+ log("[logs collector] file read", { file: file.name, share_percent: Math.round(share * 100), bytes: want, lines: parsed.lines.length, receipt_body_lines_skipped: parsed.bodySkipped });
155
+ if (budget <= 0)
156
+ break;
157
+ }
158
+ }
159
+ const notes = [
160
+ "no per-line times in these logs: the window is each file's share by time, read from its end (approximate)",
161
+ `read ${read.length > 0 ? read.join(", ") : "nothing: every log file ends before the window"}`,
162
+ ];
163
+ if (skipped > 0)
164
+ notes.push(`${skipped.toLocaleString("en-US")} lines of tick receipts cut by log rotation (indented JSON) were counted, not judged`);
165
+ return { lines, notes, rawChars: bytesRead };
166
+ }
@@ -0,0 +1,2 @@
1
+ import type { RunCli } from "./types.js";
2
+ export declare const nodeRunCli: RunCli;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Runs a read-only helper program (`vercel`, `railway`) and returns what it
3
+ * said. Never throws: a missing program, a timeout and a byte ceiling are all
4
+ * answers the caller names (`vercel_cli_missing`, ...), not stack traces.
5
+ */
6
+ import { spawn } from "node:child_process";
7
+ export const nodeRunCli = (command, args, options) => new Promise((resolve) => {
8
+ const out = [];
9
+ const err = [];
10
+ let size = 0;
11
+ let failure;
12
+ let truncated = false;
13
+ let settled = false;
14
+ let child;
15
+ try {
16
+ // Windows installs `vercel` and `railway` as .cmd shims, which spawn only runs through a shell. Every argument
17
+ // is built from fixed words, ISO times we format and a service name that digestSource and readService both
18
+ // matched against ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ BEFORE this runs; nothing caller-supplied is quoted or escaped.
19
+ child = spawn(command, [...args], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true, shell: process.platform === "win32" });
20
+ }
21
+ catch (error) {
22
+ resolve({ status: null, stdout: "", stderr: "", failure: error.code ?? "spawn_failed" });
23
+ return;
24
+ }
25
+ const finish = (status) => {
26
+ if (settled)
27
+ return;
28
+ settled = true;
29
+ clearTimeout(timer);
30
+ const stdout = Buffer.concat(out).toString("utf8");
31
+ const stderr = Buffer.concat(err).toString("utf8").slice(-4000);
32
+ // cmd.exe answers a program it cannot find with exit 1 and this sentence, where POSIX gives ENOENT.
33
+ if (!failure && process.platform === "win32" && status !== 0 && /is not recognized as an internal or external command/i.test(stderr))
34
+ failure = "ENOENT";
35
+ resolve({
36
+ status,
37
+ stdout,
38
+ stderr,
39
+ ...(failure ? { failure } : {}),
40
+ ...(truncated ? { truncated } : {}),
41
+ });
42
+ };
43
+ const timer = setTimeout(() => {
44
+ failure = "timeout";
45
+ truncated = true;
46
+ child.kill("SIGTERM");
47
+ setTimeout(() => finish(null), 2000).unref();
48
+ }, options.timeoutMs);
49
+ const abortIfMatched = (chunk) => {
50
+ if (options.abortOn && !failure && chunk.toString("utf8").split("\n").some((line) => !line.startsWith("{") && options.abortOn.test(line))) {
51
+ failure = "aborted";
52
+ truncated = true;
53
+ child.kill("SIGTERM");
54
+ }
55
+ };
56
+ child.stdout?.on("data", (chunk) => {
57
+ abortIfMatched(chunk);
58
+ if (truncated && failure !== "timeout" && failure !== "aborted")
59
+ return;
60
+ size += chunk.length;
61
+ if (size > options.maxBytes) {
62
+ truncated = true;
63
+ child.kill("SIGTERM");
64
+ return;
65
+ }
66
+ out.push(chunk);
67
+ });
68
+ child.stderr?.on("data", (chunk) => {
69
+ abortIfMatched(chunk);
70
+ if (Buffer.concat(err).length < 16_000)
71
+ err.push(chunk);
72
+ });
73
+ child.on("error", (error) => {
74
+ failure = error.code ?? "spawn_failed";
75
+ finish(null);
76
+ });
77
+ child.on("close", (code) => finish(code));
78
+ });
@@ -0,0 +1,6 @@
1
+ import { SourceRefusal, type CliRun, type SourceContext, type SourceRead } from "./types.js";
2
+ export declare const RAILWAY_PROJECT_ID = "868238ce-d8fe-4629-8e05-fb7492baff1f";
3
+ export declare const RAILWAY_ENVIRONMENT = "production";
4
+ /** Which named refusal a failed `railway` run is, or null when the run is not a refusal. */
5
+ export declare function railwayRefusal(run: CliRun, service?: string): SourceRefusal | null;
6
+ export declare function readRailway(context: SourceContext, service?: string): Promise<SourceRead>;