@dogfood-lab/ingest 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Index generator
3
+ *
4
+ * Scans records/ and records/_rejected/ to produce:
5
+ * - indexes/latest-by-repo.json (keyed by repo + product_surface)
6
+ * - indexes/failing.json (records where verified verdict is not pass)
7
+ * - indexes/stale.json (repos/surfaces with no recent accepted record)
8
+ *
9
+ * Regenerated on every accepted/rejected write in Phase 1.
10
+ *
11
+ * Multi-file commit-group atomicity (W3-PIPE-002):
12
+ * The 3 indexes are written together via a two-phase commit pattern. Phase 1
13
+ * stages all 3 files to temp paths AND records them in a journal file. Phase 2
14
+ * renames each temp into its final location, then deletes the journal. If the
15
+ * process crashes mid-rename, the next run detects the journal, deletes any
16
+ * residual temps it lists, and re-runs the rebuild from scratch. The rebuild
17
+ * is idempotent (it scans records/ end-to-end), so re-running is the correct
18
+ * recovery action.
19
+ *
20
+ * Pattern reference: choke-point fix (Pattern #4) for multi-file atomicity.
21
+ * Single-file `atomicWriteFileSync` (lib/atomic-write.js) handles each leg;
22
+ * the journal handles the cross-file boundary. The single-file helper is
23
+ * the same one Class #6 helper-adoption-sweep enforces as canonical for
24
+ * temp+rename writes under `packages/ingest/`.
25
+ */
26
+
27
+ import { readdirSync, readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
28
+ import { join, relative } from 'node:path';
29
+ import { randomBytes } from 'node:crypto';
30
+
31
+ import { stageWriteFileSync, promoteStaged, discardStaged } from './lib/atomic-write.js';
32
+
33
+ /**
34
+ * Recursively find all .json files under a directory.
35
+ *
36
+ * @param {string} dir
37
+ * @returns {string[]}
38
+ */
39
+ function findJsonFiles(dir) {
40
+ const results = [];
41
+ if (!existsSync(dir)) return results;
42
+
43
+ const entries = readdirSync(dir, { withFileTypes: true });
44
+ for (const entry of entries) {
45
+ const fullPath = join(dir, entry.name);
46
+ if (entry.isDirectory()) {
47
+ results.push(...findJsonFiles(fullPath));
48
+ } else if (entry.name.endsWith('.json') && !entry.name.endsWith('.tmp')) {
49
+ results.push(fullPath);
50
+ }
51
+ }
52
+ return results;
53
+ }
54
+
55
+ /**
56
+ * Load and parse a record file.
57
+ *
58
+ * @param {string} filePath
59
+ * @returns {{ record: object|null, error: string|null }}
60
+ */
61
+ function loadRecord(filePath) {
62
+ try {
63
+ return { record: JSON.parse(readFileSync(filePath, 'utf-8')), error: null };
64
+ } catch (err) {
65
+ return { record: null, error: err && err.message ? err.message : String(err) };
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Rebuild all indexes from the records directory.
71
+ *
72
+ * Corrupted records (parse failure) and records missing run_id are surfaced
73
+ * via the returned `corrupted` and `skipped` arrays AND logged to stderr so
74
+ * operators see them. The function does NOT crash on a single bad file —
75
+ * the index must keep building so the rest of the portfolio stays current —
76
+ * but the bad files are NOT silently dropped.
77
+ *
78
+ * @param {string} repoRoot - Absolute path to dogfood-labs repo root
79
+ * @param {object} [options]
80
+ * @param {number} [options.staleDays=30] - Days after which a surface is stale
81
+ * @returns {{ latestByRepo: object, failing: object[], stale: object[], accepted: number, rejected: number, corrupted: Array<{ path: string, error: string }>, skipped: Array<{ path: string, reason: string }> }}
82
+ */
83
+ export function rebuildIndexes(repoRoot, options = {}) {
84
+ const { staleDays = 30 } = options;
85
+ const indexDir = join(repoRoot, 'indexes');
86
+ mkdirSync(indexDir, { recursive: true });
87
+
88
+ // Collect all records (accepted + rejected)
89
+ const recordsDir = join(repoRoot, 'records');
90
+ const acceptedFiles = findJsonFiles(recordsDir)
91
+ .filter(f => {
92
+ const rel = relative(recordsDir, f);
93
+ return !rel.startsWith('_rejected/') && !rel.startsWith('_rejected\\');
94
+ });
95
+ const rejectedFiles = findJsonFiles(join(repoRoot, 'records', '_rejected'));
96
+
97
+ const allRecords = [];
98
+ const corrupted = [];
99
+ const skipped = [];
100
+
101
+ for (const f of [...acceptedFiles, ...rejectedFiles]) {
102
+ const relPath = relative(repoRoot, f);
103
+ const { record, error } = loadRecord(f);
104
+ if (error) {
105
+ corrupted.push({ path: relPath, error });
106
+ console.error(`[rebuild-indexes] corrupted record skipped: ${relPath} — ${error}`);
107
+ continue;
108
+ }
109
+ if (!record || !record.run_id) {
110
+ skipped.push({ path: relPath, reason: 'missing run_id' });
111
+ console.error(`[rebuild-indexes] record skipped (missing run_id): ${relPath}`);
112
+ continue;
113
+ }
114
+ record._path = relPath;
115
+ allRecords.push(record);
116
+ }
117
+
118
+ // --- latest-by-repo.json ---
119
+ // Keyed by repo, then product_surface. Only accepted records count.
120
+ const latestByRepo = {};
121
+
122
+ for (const record of allRecords) {
123
+ if (record.verification?.status !== 'accepted') continue;
124
+
125
+ const repo = record.repo;
126
+ if (!latestByRepo[repo]) latestByRepo[repo] = {};
127
+
128
+ for (const sr of record.scenario_results || []) {
129
+ const surface = sr.product_surface;
130
+ const existing = latestByRepo[repo][surface];
131
+
132
+ const finishedAt = record.timing?.finished_at;
133
+ // Compare timestamps numerically. ISO 8601 lex-compare only agrees with
134
+ // chronological order when both strings share identical precision and
135
+ // timezone format — `2026-03-19T15:45:12Z` lex-compares AFTER
136
+ // `2026-03-19T15:45:12.500Z` (because `Z` (0x5A) > `.` (0x2E)), so
137
+ // mixed-precision timestamps would pick the wrong "latest." Date.parse
138
+ // normalizes to ms-since-epoch; NaN (bad/missing) is treated as oldest.
139
+ const finishedMs = finishedAt ? new Date(finishedAt).getTime() : NaN;
140
+ const existingMs = existing?.finished_at ? new Date(existing.finished_at).getTime() : NaN;
141
+ const isNewer = !existing || (Number.isFinite(finishedMs) && (!Number.isFinite(existingMs) || finishedMs > existingMs));
142
+ if (isNewer) {
143
+ latestByRepo[repo][surface] = {
144
+ run_id: record.run_id,
145
+ verified: record.overall_verdict?.verified,
146
+ verification_status: 'accepted',
147
+ finished_at: finishedAt,
148
+ path: record._path
149
+ };
150
+ }
151
+ }
152
+ }
153
+
154
+ // --- failing.json ---
155
+ // Latest accepted records where verified verdict is not "pass"
156
+ const failing = [];
157
+
158
+ for (const [repo, surfaces] of Object.entries(latestByRepo)) {
159
+ for (const [surface, entry] of Object.entries(surfaces)) {
160
+ if (entry.verified !== 'pass') {
161
+ failing.push({
162
+ repo,
163
+ surface,
164
+ run_id: entry.run_id,
165
+ verified: entry.verified,
166
+ finished_at: entry.finished_at,
167
+ path: entry.path
168
+ });
169
+ }
170
+ }
171
+ }
172
+
173
+ // --- stale.json ---
174
+ // Surfaces where the latest accepted record is older than staleDays
175
+ const stale = [];
176
+ const cutoffMs = Date.now() - staleDays * 24 * 60 * 60 * 1000;
177
+
178
+ for (const [repo, surfaces] of Object.entries(latestByRepo)) {
179
+ for (const [surface, entry] of Object.entries(surfaces)) {
180
+ // Compare numerically — see latest-by-repo block above for the
181
+ // mixed-precision lex-compare hazard. A missing/unparseable
182
+ // finished_at is treated as stale (NaN < cutoff is false in lex,
183
+ // hiding records with no usable timing — the original behavior
184
+ // silently dropped them from stale-detection).
185
+ const entryMs = entry.finished_at ? new Date(entry.finished_at).getTime() : NaN;
186
+ const isStale = !Number.isFinite(entryMs) || entryMs < cutoffMs;
187
+ if (isStale) {
188
+ const ageDays = Number.isFinite(entryMs)
189
+ ? Math.floor((Date.now() - entryMs) / (24 * 60 * 60 * 1000))
190
+ : null;
191
+ stale.push({
192
+ repo,
193
+ surface,
194
+ run_id: entry.run_id,
195
+ finished_at: entry.finished_at,
196
+ age_days: ageDays,
197
+ path: entry.path
198
+ });
199
+ }
200
+ }
201
+ }
202
+
203
+ // Write indexes via commit-group two-phase commit. See module header
204
+ // for the full design rationale.
205
+ const latestPath = join(indexDir, 'latest-by-repo.json');
206
+ const failingPath = join(indexDir, 'failing.json');
207
+ const stalePath = join(indexDir, 'stale.json');
208
+
209
+ // Phase 0: clean up any residual journal from a previous crashed run.
210
+ // Idempotent: rerun-from-scratch is the correct recovery (the rebuild
211
+ // scans all records every time), so we just delete the journal and any
212
+ // temp files it lists, then proceed normally.
213
+ cleanupCrashedJournals(indexDir);
214
+
215
+ commitGroupRename(indexDir, [
216
+ { finalPath: latestPath, content: JSON.stringify(latestByRepo, null, 2) + '\n' },
217
+ { finalPath: failingPath, content: JSON.stringify(failing, null, 2) + '\n' },
218
+ // Stale renames LAST: it is the most-derivative index (depends on
219
+ // latestByRepo's timestamps). If a partial-failure escape ever does
220
+ // happen, readers see a stale-by-stale.json that's a previous-pass
221
+ // shape — never a future shape pointing at run_ids the latest index
222
+ // doesn't reflect. Recovery on next run completes the renames.
223
+ { finalPath: stalePath, content: JSON.stringify(stale, null, 2) + '\n' },
224
+ ]);
225
+
226
+ return {
227
+ latestByRepo,
228
+ failing,
229
+ stale,
230
+ accepted: acceptedFiles.length,
231
+ rejected: rejectedFiles.length,
232
+ corrupted,
233
+ skipped
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Two-phase commit for a group of files written together. Stages all temps
239
+ * AND records them in a journal first; then renames them in caller-given
240
+ * order. The journal is deleted only after every rename succeeds.
241
+ *
242
+ * Crash semantics:
243
+ * - Crash during STAGE phase: every staged temp is unlinked in the catch
244
+ * block; the journal (if written) is unlinked too. No partial visible
245
+ * state.
246
+ * - Crash during PROMOTE phase: any successfully-renamed file is at its
247
+ * final path; remaining temps are still next to their finals. The
248
+ * journal still exists. Next run's `cleanupCrashedJournals` deletes
249
+ * residual temps and the journal; the next normal `rebuildIndexes`
250
+ * call rewrites all 3 indexes from scratch (idempotent).
251
+ *
252
+ * Why journal-then-rename rather than journal-only: the rename phase needs
253
+ * to be the visible commit point. A journal-only design would require
254
+ * readers to consult the journal, which couples readers to writers. The
255
+ * present design keeps reader code untouched (read each index path
256
+ * directly).
257
+ *
258
+ * @param {string} indexDir - Where the journal lives.
259
+ * @param {Array<{ finalPath: string, content: string }>} entries
260
+ */
261
+ function commitGroupRename(indexDir, entries) {
262
+ const journalPath = join(indexDir, `.in-progress.${process.pid}.${randomBytes(4).toString('hex')}.json`);
263
+ const stagedTmps = [];
264
+
265
+ // Phase 1: stage all temps. If anything fails, unlink everything we staged.
266
+ try {
267
+ for (const entry of entries) {
268
+ const tmpPath = stageWriteFileSync(entry.finalPath, entry.content);
269
+ stagedTmps.push({ tmpPath, finalPath: entry.finalPath });
270
+ }
271
+
272
+ // Write journal AFTER staging so it never points at a non-existent temp.
273
+ // Atomic write of the journal itself: writeFileSync directly is fine here
274
+ // because the journal is process-private (the pid suffix guarantees no
275
+ // collision with concurrent rebuilds).
276
+ writeFileSync(
277
+ journalPath,
278
+ JSON.stringify({
279
+ pid: process.pid,
280
+ started_at: new Date().toISOString(),
281
+ entries: stagedTmps,
282
+ }, null, 2) + '\n',
283
+ 'utf-8'
284
+ );
285
+ } catch (err) {
286
+ // STAGE-phase failure — roll back every temp we managed to write. The
287
+ // journal might or might not exist; clean it up too.
288
+ for (const { tmpPath } of stagedTmps) discardStaged(tmpPath);
289
+ try { unlinkSync(journalPath); } catch { /* may not exist */ }
290
+ throw err;
291
+ }
292
+
293
+ // Phase 2: promote each staged temp to its final path.
294
+ // Order matters — the caller chose `entries` ordering for partial-failure
295
+ // recoverability (most-derivative file last). We promote in that order.
296
+ let promotedCount = 0;
297
+ try {
298
+ for (const { tmpPath, finalPath } of stagedTmps) {
299
+ promoteStaged(tmpPath, finalPath);
300
+ promotedCount++;
301
+ }
302
+ } catch (err) {
303
+ // PROMOTE-phase failure: leave the journal in place so the next run's
304
+ // `cleanupCrashedJournals` can finish the cleanup. Any unpromoted temps
305
+ // are still on disk; we do NOT roll back already-promoted finals
306
+ // (their previous content is already overwritten — the rename was
307
+ // atomic at each individual leg, just not as a group). The next run
308
+ // is idempotent and will rewrite all three from scratch.
309
+ throw new Error(
310
+ `commitGroupRename: promote failed after ${promotedCount}/${stagedTmps.length} files; ` +
311
+ `journal preserved at ${journalPath} for next-run cleanup. Original error: ${err.message}`
312
+ );
313
+ }
314
+
315
+ // Phase 3: clean up the journal. If this fails, the next run's
316
+ // `cleanupCrashedJournals` will pick up the slack — the journal's
317
+ // entries all reference temps that no longer exist (we promoted them),
318
+ // so the cleanup is a no-op except for unlinking the journal itself.
319
+ try { unlinkSync(journalPath); } catch { /* will be cleaned next run */ }
320
+ }
321
+
322
+ /**
323
+ * Find and clean up any in-progress journals from previous runs. Each journal
324
+ * lists the temp paths that were staged; we unlink any that still exist
325
+ * (they are residue from a crashed run) and delete the journal.
326
+ *
327
+ * Idempotent: on a clean filesystem it's a no-op; on a crashed-mid-promote
328
+ * filesystem it cleans the slate so the upcoming `commitGroupRename` can
329
+ * stage fresh temps without colliding.
330
+ *
331
+ * @param {string} indexDir
332
+ */
333
+ function cleanupCrashedJournals(indexDir) {
334
+ if (!existsSync(indexDir)) return;
335
+ const entries = readdirSync(indexDir);
336
+ for (const entry of entries) {
337
+ if (!entry.startsWith('.in-progress.') || !entry.endsWith('.json')) continue;
338
+ const journalPath = join(indexDir, entry);
339
+ let parsed = null;
340
+ try {
341
+ parsed = JSON.parse(readFileSync(journalPath, 'utf-8'));
342
+ } catch {
343
+ // Unreadable journal — best we can do is delete it. The temps it
344
+ // referenced will linger but they're harmless (they have a unique
345
+ // suffix that won't be re-used).
346
+ }
347
+ if (parsed && Array.isArray(parsed.entries)) {
348
+ for (const e of parsed.entries) {
349
+ if (e && typeof e.tmpPath === 'string') {
350
+ try { unlinkSync(e.tmpPath); } catch { /* may not exist */ }
351
+ }
352
+ }
353
+ }
354
+ try { unlinkSync(journalPath); } catch { /* race with another cleaner */ }
355
+ }
356
+ }