faberun 0.21.0 → 0.23.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.
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Filesystem and command layer for `faberun metrics`. This module owns the
3
+ * command's flags and reads the campaign's run or ledger artefacts, while
4
+ * `metrics.mjs` stays a pure function over parsed records.
5
+ */
6
+
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { join, resolve } from "node:path";
9
+ import { jsonObjectOf } from "./metrics-evals.mjs";
10
+ import { excludedRunIdsFor, projectMetrics } from "./metrics.mjs";
11
+ import { renderMetricsJson, renderMetricsReport } from "../report/metrics-report.mjs";
12
+ import { campaignDir, LEDGER_SOURCE_MANIFEST_FILE } from "./layout.mjs";
13
+ import { readCampaign } from "./record.mjs";
14
+ import { listNodeSnapshots, nodeSnapshotPath } from "../run/node-store.mjs";
15
+ import { runsRoot } from "../run/paths.mjs";
16
+
17
+ /** @typedef {Record<string, unknown>} JsonObject */
18
+ /** @typedef {{runId: string, id: string, status: string, attempt?: number|null, revisions?: number|null, review?: string|null}} RunNode */
19
+
20
+ /** Flags of `faberun metrics`, declared here so the router only names them. */
21
+ /** @type {import("node:util").ParseArgsOptionsConfig} */
22
+ export const METRICS_OPTIONS = {
23
+ cwd: { type: "string" },
24
+ ledger: { type: "string" },
25
+ json: { type: "boolean" },
26
+ };
27
+
28
+ /** Text used by the generated manual for options whose effect is not derivable from their parser type. */
29
+ /** @type {Record<string, {value: string, effect: string, default: string}>} */
30
+ export const METRICS_MANUAL = {
31
+ ledger: {
32
+ value: "directory",
33
+ effect: "Read the versioned campaign ledger instead of the operator's run directories.",
34
+ default: "none",
35
+ },
36
+ };
37
+
38
+ const RUN_EVENTS_FILE = "events.jsonl";
39
+ const USAGE_LOG_FILE = "usage.jsonl";
40
+ const NOTIFY_LOG_FILE = "notify.jsonl";
41
+
42
+ /**
43
+ * @typedef {{
44
+ * campaignId: string,
45
+ * runIds: string[],
46
+ * events: unknown[],
47
+ * usageRecords: unknown[],
48
+ * notifications: unknown[],
49
+ * nodes: RunNode[],
50
+ * journal: unknown[],
51
+ * campaign: JsonObject,
52
+ * excludedRunIds: string[],
53
+ * missingSources: string[],
54
+ * }} MetricsSources
55
+ */
56
+
57
+ /**
58
+ * Read the recorded sources of one campaign: every linked run's persisted
59
+ * node snapshots, transition events (tagged with the run id, since a node id
60
+ * is only unique within one run), `usage.jsonl` and `notify.jsonl`. A missing
61
+ * artefact reads as empty, which the projector reports as a missing
62
+ * measurement and never as a measured zero.
63
+ *
64
+ * @param {string} campaignPath
65
+ * @param {{runsDir?: string, ledgerDir?: string}} [options]
66
+ * @returns {MetricsSources}
67
+ */
68
+ export function readMetricsSources(campaignPath, { runsDir = join(campaignPath, "..", ".."), ledgerDir = undefined } = {}) {
69
+ const campaign = readCampaign(ledgerDir ?? campaignPath);
70
+ /** @type {unknown[]} */
71
+ const events = [];
72
+ /** @type {unknown[]} */
73
+ const usageRecords = [];
74
+ /** @type {unknown[]} */
75
+ const notifications = [];
76
+ /** @type {RunNode[]} */
77
+ const nodes = [];
78
+ /** @type {unknown[]} */
79
+ const journal = [];
80
+ /** @type {string[]} */
81
+ const missingSources = [];
82
+ const absentAtOrigin = ledgerDir === undefined ? new Set() : readLedgerAbsentSources(ledgerDir);
83
+ const journalPath = join(ledgerDir ?? campaignPath, "journal.jsonl");
84
+ if (existsSync(journalPath)) journal.push(...readJsonlRecords(journalPath));
85
+ else missingSources.push("journal.jsonl");
86
+ for (const runId of campaign.linkedRunIds) {
87
+ if (ledgerDir === undefined) {
88
+ for (const record of readJsonlRecords(join(runsDir, runId, RUN_EVENTS_FILE))) events.push({ ...jsonObjectOf(record), runId });
89
+ for (const record of readJsonlRecords(join(runsDir, runId, USAGE_LOG_FILE))) usageRecords.push({ ...jsonObjectOf(record), runId });
90
+ for (const record of readJsonlRecords(join(runsDir, runId, NOTIFY_LOG_FILE))) notifications.push({ ...jsonObjectOf(record), runId });
91
+ for (const node of readMetricNodeSnapshots(join(runsDir, runId))) nodes.push({ ...node, runId });
92
+ continue;
93
+ }
94
+ /** @type {[string, unknown[]][]} */
95
+ const sources = [
96
+ [RUN_EVENTS_FILE, events],
97
+ [USAGE_LOG_FILE, usageRecords],
98
+ [NOTIFY_LOG_FILE, notifications],
99
+ ];
100
+ for (const [suffix, target] of sources) {
101
+ const path = join(ledgerDir, `${runId}.${suffix}`);
102
+ if (!existsSync(path)) {
103
+ if (!absentAtOrigin.has(`${runId}.${suffix}`)) missingSources.push(`${runId}.${suffix}`);
104
+ continue;
105
+ }
106
+ for (const record of readJsonlRecords(path)) target.push({ ...jsonObjectOf(record), runId });
107
+ }
108
+ const nodesPath = join(ledgerDir, `${runId}.nodes.json`);
109
+ if (!existsSync(nodesPath)) {
110
+ if (!absentAtOrigin.has(`${runId}.nodes.json`)) missingSources.push(`${runId}.nodes.json`);
111
+ continue;
112
+ }
113
+ for (const node of readLedgerNodeSnapshots(nodesPath)) nodes.push({ ...node, runId });
114
+ }
115
+ const campaignObject = /** @type {JsonObject} */ (campaign);
116
+ return {
117
+ campaignId: campaign.id,
118
+ runIds: [...campaign.linkedRunIds],
119
+ events,
120
+ usageRecords,
121
+ notifications,
122
+ nodes,
123
+ journal,
124
+ campaign: campaignObject,
125
+ excludedRunIds: excludedRunIdsFor(campaignObject, []),
126
+ missingSources,
127
+ };
128
+ }
129
+
130
+ /** @param {string} ledgerDir @returns {Set<string>} */
131
+ function readLedgerAbsentSources(ledgerDir) {
132
+ try {
133
+ const manifest = JSON.parse(readFileSync(join(ledgerDir, LEDGER_SOURCE_MANIFEST_FILE), "utf8"));
134
+ const preserved = /** @type {Set<string>} */ (new Set(Array.isArray(manifest?.preserved) ? /** @type {unknown[]} */ (manifest.preserved).filter((source) => typeof source === "string") : []));
135
+ return /** @type {Set<string>} */ (new Set(Array.isArray(manifest?.absent) ? /** @type {unknown[]} */ (manifest.absent).filter((source) => typeof source === "string" && !preserved.has(source)) : []));
136
+ } catch {
137
+ // An old ledger has no origin manifest, so an absent file remains incomplete evidence.
138
+ return new Set();
139
+ }
140
+ }
141
+
142
+ /** @param {string} path @returns {Omit<RunNode, "runId">[]} */
143
+ function readLedgerNodeSnapshots(path) {
144
+ try {
145
+ const records = JSON.parse(readFileSync(path, "utf8"));
146
+ if (!Array.isArray(records)) return [];
147
+ return records.flatMap((record) => {
148
+ const object = jsonObjectOf(record);
149
+ if (object === null || typeof object.id !== "string" || typeof object.status !== "string") return [];
150
+ return [{
151
+ id: object.id,
152
+ status: object.status,
153
+ attempt: typeof object.attempt === "number" ? object.attempt : null,
154
+ revisions: typeof object.revisions === "number" ? object.revisions : null,
155
+ review: typeof object.review === "string" ? object.review : null,
156
+ }];
157
+ });
158
+ } catch {
159
+ // A torn ledger projection is incomplete evidence, represented by its empty source below.
160
+ return [];
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Persisted node snapshots of one run, reduced to the fields metrics reads.
166
+ * Reading is tolerant of a run directory with no `nodes/` yet (freshly
167
+ * dispatched) and of a snapshot that fails to parse (never blocks a report on
168
+ * a torn write).
169
+ *
170
+ * @param {string} runDir
171
+ * @returns {Omit<RunNode, "runId">[]}
172
+ */
173
+ export function readMetricNodeSnapshots(runDir) {
174
+ /** @type {Omit<RunNode, "runId">[]} */
175
+ const nodes = [];
176
+ for (const name of listNodeSnapshots(runDir)) {
177
+ let record;
178
+ try {
179
+ record = jsonObjectOf(JSON.parse(readFileSync(nodeSnapshotPath(runDir, name.slice(0, -".json".length)), "utf8")));
180
+ } catch {
181
+ continue;
182
+ }
183
+ if (record === null || typeof record.id !== "string" || typeof record.status !== "string") continue;
184
+ nodes.push({
185
+ id: record.id,
186
+ status: record.status,
187
+ attempt: typeof record.attempt === "number" ? record.attempt : null,
188
+ revisions: typeof record.revisions === "number" ? record.revisions : null,
189
+ review: typeof record.review === "string" ? record.review : null,
190
+ });
191
+ }
192
+ return nodes;
193
+ }
194
+
195
+ /**
196
+ * `faberun metrics <campaign-id> [--cwd <dir>] [--json]`: project the
197
+ * campaign's recorded artefacts and return what the command prints. Reading
198
+ * only, and never a write: a report of a closed campaign must not touch it.
199
+ *
200
+ * @param {string} campaignId
201
+ * @param {{cwd?: unknown, ledger?: unknown, json?: unknown}} [values]
202
+ * @returns {string}
203
+ */
204
+ export function renderCampaignMetrics(campaignId, values = {}) {
205
+ const runsDir = runsRoot(resolve(typeof values.cwd === "string" && values.cwd !== "" ? values.cwd : process.cwd()));
206
+ const ledgerDir = typeof values.ledger === "string" && values.ledger !== "" ? resolve(values.ledger) : undefined;
207
+ const sources = readMetricsSources(campaignDir(runsDir, campaignId), { runsDir, ledgerDir });
208
+ const metrics = projectMetrics(sources);
209
+ return values.json === true ? renderMetricsJson(sources, metrics) : renderMetricsReport(sources, metrics);
210
+ }
211
+
212
+ /**
213
+ * Records of one JSONL artefact. An unterminated final line was never a
214
+ * committed record — the newline is written with the record — so it is skipped
215
+ * rather than parsed.
216
+ *
217
+ * @param {string} path
218
+ * @returns {unknown[]}
219
+ */
220
+ function readJsonlRecords(path) {
221
+ if (!existsSync(path)) return [];
222
+ const text = readFileSync(path, "utf8");
223
+ const lines = text.split(/\r?\n/u);
224
+ /** @type {unknown[]} */
225
+ const records = [];
226
+ for (let index = 0; index < lines.length; index += 1) {
227
+ if (!lines[index].trim()) continue;
228
+ if (index === lines.length - 1 && !text.endsWith("\n")) continue;
229
+ records.push(JSON.parse(lines[index]));
230
+ }
231
+ return records;
232
+ }