@dogfood-lab/ingest 1.2.2 → 1.3.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/lib/rename-with-retry.js +8 -2
- package/lib/sleep-sync.js +31 -0
- package/lib/unsafe-segment.js +36 -36
- package/load-context.js +345 -131
- package/package.json +4 -5
- package/persist.js +172 -172
- package/rebuild-indexes.js +384 -356
- package/run.js +676 -540
- package/validate-record.js +77 -83
package/persist.js
CHANGED
|
@@ -1,172 +1,172 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Persist layer
|
|
3
|
-
*
|
|
4
|
-
* Writes verified records to the canonical sharded path.
|
|
5
|
-
* Handles: accepted/rejected routing, atomic write (temp+rename),
|
|
6
|
-
* duplicate detection by run_id, directory creation.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { existsSync, mkdirSync, writeFileSync, renameSync, openSync, closeSync, unlinkSync } from 'node:fs';
|
|
10
|
-
import { join, dirname } from 'node:path';
|
|
11
|
-
import { randomBytes } from 'node:crypto';
|
|
12
|
-
|
|
13
|
-
import { validateRecord } from './validate-record.js';
|
|
14
|
-
import { isUnsafeSegment } from './lib/unsafe-segment.js';
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Error thrown when writeRecord loses a TOCTOU race for the same canonical path.
|
|
18
|
-
* The first concurrent writer wins; the loser sees this error.
|
|
19
|
-
*/
|
|
20
|
-
export class DuplicateRunIdError extends Error {
|
|
21
|
-
constructor(runId, path) {
|
|
22
|
-
super(`duplicate run_id: ${runId} — another writer won the race for ${path}`);
|
|
23
|
-
this.name = 'DuplicateRunIdError';
|
|
24
|
-
this.code = 'DUPLICATE_RUN_ID';
|
|
25
|
-
this.runId = runId;
|
|
26
|
-
this.path = path;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Compute the canonical file path for a persisted record.
|
|
32
|
-
*
|
|
33
|
-
* Accepted: records/<org>/<repo>/YYYY/MM/DD/run-<run_id>.json
|
|
34
|
-
* Rejected: records/_rejected/<org>/<repo>/YYYY/MM/DD/run-<run_id>.json
|
|
35
|
-
*
|
|
36
|
-
* @param {object} record - Persisted record
|
|
37
|
-
* @param {string} repoRoot - Absolute path to dogfood-labs repo root
|
|
38
|
-
* @returns {string} Absolute file path
|
|
39
|
-
*/
|
|
40
|
-
export function computeRecordPath(record, repoRoot) {
|
|
41
|
-
const status = record.verification?.status;
|
|
42
|
-
const base = status === 'rejected' ? 'records/_rejected' : 'records';
|
|
43
|
-
|
|
44
|
-
const [org, repo] = (record.repo || '').split('/');
|
|
45
|
-
if (!org || !repo) {
|
|
46
|
-
throw new Error(`invalid repo format: ${record.repo}`);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Path-traversal guard: reject `..` substrings and any path separator.
|
|
50
|
-
// Single dots are legal in GitHub org/repo names (e.g. `next.js`,
|
|
51
|
-
// `mcp-tool-shop.github.io`) and the submission schema's repo pattern
|
|
52
|
-
// `^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$` allows them. Centralized in
|
|
53
|
-
// ./lib/unsafe-segment.js so all three callsites (persist, load-context,
|
|
54
|
-
// findings/derive/load-records) agree by import — F-916867-005.
|
|
55
|
-
if (isUnsafeSegment(org) || isUnsafeSegment(repo)) {
|
|
56
|
-
throw new Error(`unsafe repo segment: ${record.repo}`);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
if (!/^[\w-]+$/.test(record.run_id)) {
|
|
60
|
-
throw new Error(`unsafe run_id: ${record.run_id}`);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const finishedAt = record.timing?.finished_at;
|
|
64
|
-
if (!finishedAt) {
|
|
65
|
-
throw new Error('record missing timing.finished_at');
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const date = new Date(finishedAt);
|
|
69
|
-
if (isNaN(date.getTime())) {
|
|
70
|
-
throw new Error('Invalid finished_at timestamp');
|
|
71
|
-
}
|
|
72
|
-
const year = String(date.getUTCFullYear());
|
|
73
|
-
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
74
|
-
const day = String(date.getUTCDate()).padStart(2, '0');
|
|
75
|
-
|
|
76
|
-
const filename = `run-${record.run_id}.json`;
|
|
77
|
-
|
|
78
|
-
return join(repoRoot, base, org, repo, year, month, day, filename);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Check if a record with this run_id already exists (accepted or rejected).
|
|
83
|
-
*
|
|
84
|
-
* @param {string} runId
|
|
85
|
-
* @param {object} record - The record (used for repo/timing to compute path)
|
|
86
|
-
* @param {string} repoRoot
|
|
87
|
-
* @returns {boolean}
|
|
88
|
-
*/
|
|
89
|
-
export function isDuplicate(runId, record, repoRoot) {
|
|
90
|
-
// Check accepted path
|
|
91
|
-
const acceptedRecord = { ...record, verification: { ...record.verification, status: 'accepted' } };
|
|
92
|
-
const acceptedPath = computeRecordPath(acceptedRecord, repoRoot);
|
|
93
|
-
if (existsSync(acceptedPath)) return true;
|
|
94
|
-
|
|
95
|
-
// Check rejected path
|
|
96
|
-
const rejectedRecord = { ...record, verification: { ...record.verification, status: 'rejected' } };
|
|
97
|
-
const rejectedPath = computeRecordPath(rejectedRecord, repoRoot);
|
|
98
|
-
if (existsSync(rejectedPath)) return true;
|
|
99
|
-
|
|
100
|
-
return false;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Write a record atomically: write to temp file, then exclusive-rename into place.
|
|
105
|
-
*
|
|
106
|
-
* Race semantics: the canonical path is created via `open(path, 'wx')` (exclusive
|
|
107
|
-
* create — fails if the path exists). Two concurrent ingests for the same run_id
|
|
108
|
-
* can both pass `isDuplicate` (no file yet); the FIRST `open(wx)` wins and the
|
|
109
|
-
* SECOND throws `DuplicateRunIdError` instead of silently overwriting. The
|
|
110
|
-
* temp+rename pattern still provides crash-atomicity — the canonical file is
|
|
111
|
-
* either fully written or absent, never partial.
|
|
112
|
-
*
|
|
113
|
-
* Why not just `existsSync` then `writeFileSync`? That's the original race —
|
|
114
|
-
* the existsSync check and the write are not atomic. `open(wx)` collapses both
|
|
115
|
-
* into a single OS-level call.
|
|
116
|
-
*
|
|
117
|
-
* @param {object} record - Persisted record
|
|
118
|
-
* @param {string} repoRoot - Absolute path to dogfood-labs repo root
|
|
119
|
-
* @returns {{ path: string, written: boolean }} path and whether a write occurred
|
|
120
|
-
* @throws {DuplicateRunIdError} when a concurrent writer won the race
|
|
121
|
-
*/
|
|
122
|
-
export function writeRecord(record, repoRoot) {
|
|
123
|
-
if (isDuplicate(record.run_id, record, repoRoot)) {
|
|
124
|
-
const path = computeRecordPath(record, repoRoot);
|
|
125
|
-
return { path, written: false };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Enforce dogfood-record.schema.json BEFORE touching the filesystem.
|
|
129
|
-
// Better to throw loudly than silently persist a malformed record — the
|
|
130
|
-
// schema is the contract every downstream consumer relies on.
|
|
131
|
-
validateRecord(record);
|
|
132
|
-
|
|
133
|
-
const path = computeRecordPath(record, repoRoot);
|
|
134
|
-
const dir = dirname(path);
|
|
135
|
-
|
|
136
|
-
mkdirSync(dir, { recursive: true });
|
|
137
|
-
|
|
138
|
-
// Race-safe atomic create: try to claim the canonical path with O_EXCL first.
|
|
139
|
-
// If another writer already won the race, fail closed with DuplicateRunIdError
|
|
140
|
-
// — never silently overwrite. On success, hold an empty file we'll fill via
|
|
141
|
-
// temp+rename so the visible bytes are still atomic.
|
|
142
|
-
let claimed = false;
|
|
143
|
-
try {
|
|
144
|
-
const fd = openSync(path, 'wx');
|
|
145
|
-
closeSync(fd);
|
|
146
|
-
claimed = true;
|
|
147
|
-
} catch (err) {
|
|
148
|
-
if (err && err.code === 'EEXIST') {
|
|
149
|
-
throw new DuplicateRunIdError(record.run_id, path);
|
|
150
|
-
}
|
|
151
|
-
throw err;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Atomic write: temp file → rename over the empty placeholder.
|
|
155
|
-
const tmpSuffix = randomBytes(4).toString('hex');
|
|
156
|
-
const tmpPath = `${path}.${tmpSuffix}.tmp`;
|
|
157
|
-
|
|
158
|
-
try {
|
|
159
|
-
writeFileSync(tmpPath, JSON.stringify(record, null, 2) + '\n', 'utf-8');
|
|
160
|
-
renameSync(tmpPath, path);
|
|
161
|
-
} catch (err) {
|
|
162
|
-
// On any failure after we claimed the path, release the claim so a retry
|
|
163
|
-
// can succeed. The tmp file is best-effort cleanup.
|
|
164
|
-
if (claimed) {
|
|
165
|
-
try { unlinkSync(path); } catch { /* placeholder already gone */ }
|
|
166
|
-
}
|
|
167
|
-
try { unlinkSync(tmpPath); } catch { /* tmp may not exist */ }
|
|
168
|
-
throw err;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
return { path, written: true };
|
|
172
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Persist layer
|
|
3
|
+
*
|
|
4
|
+
* Writes verified records to the canonical sharded path.
|
|
5
|
+
* Handles: accepted/rejected routing, atomic write (temp+rename),
|
|
6
|
+
* duplicate detection by run_id, directory creation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, mkdirSync, writeFileSync, renameSync, openSync, closeSync, unlinkSync } from 'node:fs';
|
|
10
|
+
import { join, dirname } from 'node:path';
|
|
11
|
+
import { randomBytes } from 'node:crypto';
|
|
12
|
+
|
|
13
|
+
import { validateRecord } from './validate-record.js';
|
|
14
|
+
import { isUnsafeSegment } from './lib/unsafe-segment.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Error thrown when writeRecord loses a TOCTOU race for the same canonical path.
|
|
18
|
+
* The first concurrent writer wins; the loser sees this error.
|
|
19
|
+
*/
|
|
20
|
+
export class DuplicateRunIdError extends Error {
|
|
21
|
+
constructor(runId, path) {
|
|
22
|
+
super(`duplicate run_id: ${runId} — another writer won the race for ${path}`);
|
|
23
|
+
this.name = 'DuplicateRunIdError';
|
|
24
|
+
this.code = 'DUPLICATE_RUN_ID';
|
|
25
|
+
this.runId = runId;
|
|
26
|
+
this.path = path;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Compute the canonical file path for a persisted record.
|
|
32
|
+
*
|
|
33
|
+
* Accepted: records/<org>/<repo>/YYYY/MM/DD/run-<run_id>.json
|
|
34
|
+
* Rejected: records/_rejected/<org>/<repo>/YYYY/MM/DD/run-<run_id>.json
|
|
35
|
+
*
|
|
36
|
+
* @param {object} record - Persisted record
|
|
37
|
+
* @param {string} repoRoot - Absolute path to dogfood-labs repo root
|
|
38
|
+
* @returns {string} Absolute file path
|
|
39
|
+
*/
|
|
40
|
+
export function computeRecordPath(record, repoRoot) {
|
|
41
|
+
const status = record.verification?.status;
|
|
42
|
+
const base = status === 'rejected' ? 'records/_rejected' : 'records';
|
|
43
|
+
|
|
44
|
+
const [org, repo] = (record.repo || '').split('/');
|
|
45
|
+
if (!org || !repo) {
|
|
46
|
+
throw new Error(`invalid repo format: ${record.repo}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Path-traversal guard: reject `..` substrings and any path separator.
|
|
50
|
+
// Single dots are legal in GitHub org/repo names (e.g. `next.js`,
|
|
51
|
+
// `mcp-tool-shop.github.io`) and the submission schema's repo pattern
|
|
52
|
+
// `^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$` allows them. Centralized in
|
|
53
|
+
// ./lib/unsafe-segment.js so all three callsites (persist, load-context,
|
|
54
|
+
// findings/derive/load-records) agree by import — F-916867-005.
|
|
55
|
+
if (isUnsafeSegment(org) || isUnsafeSegment(repo)) {
|
|
56
|
+
throw new Error(`unsafe repo segment: ${record.repo}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!/^[\w-]+$/.test(record.run_id)) {
|
|
60
|
+
throw new Error(`unsafe run_id: ${record.run_id}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const finishedAt = record.timing?.finished_at;
|
|
64
|
+
if (!finishedAt) {
|
|
65
|
+
throw new Error('record missing timing.finished_at');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const date = new Date(finishedAt);
|
|
69
|
+
if (isNaN(date.getTime())) {
|
|
70
|
+
throw new Error('Invalid finished_at timestamp');
|
|
71
|
+
}
|
|
72
|
+
const year = String(date.getUTCFullYear());
|
|
73
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
74
|
+
const day = String(date.getUTCDate()).padStart(2, '0');
|
|
75
|
+
|
|
76
|
+
const filename = `run-${record.run_id}.json`;
|
|
77
|
+
|
|
78
|
+
return join(repoRoot, base, org, repo, year, month, day, filename);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Check if a record with this run_id already exists (accepted or rejected).
|
|
83
|
+
*
|
|
84
|
+
* @param {string} runId
|
|
85
|
+
* @param {object} record - The record (used for repo/timing to compute path)
|
|
86
|
+
* @param {string} repoRoot
|
|
87
|
+
* @returns {boolean}
|
|
88
|
+
*/
|
|
89
|
+
export function isDuplicate(runId, record, repoRoot) {
|
|
90
|
+
// Check accepted path
|
|
91
|
+
const acceptedRecord = { ...record, verification: { ...record.verification, status: 'accepted' } };
|
|
92
|
+
const acceptedPath = computeRecordPath(acceptedRecord, repoRoot);
|
|
93
|
+
if (existsSync(acceptedPath)) return true;
|
|
94
|
+
|
|
95
|
+
// Check rejected path
|
|
96
|
+
const rejectedRecord = { ...record, verification: { ...record.verification, status: 'rejected' } };
|
|
97
|
+
const rejectedPath = computeRecordPath(rejectedRecord, repoRoot);
|
|
98
|
+
if (existsSync(rejectedPath)) return true;
|
|
99
|
+
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Write a record atomically: write to temp file, then exclusive-rename into place.
|
|
105
|
+
*
|
|
106
|
+
* Race semantics: the canonical path is created via `open(path, 'wx')` (exclusive
|
|
107
|
+
* create — fails if the path exists). Two concurrent ingests for the same run_id
|
|
108
|
+
* can both pass `isDuplicate` (no file yet); the FIRST `open(wx)` wins and the
|
|
109
|
+
* SECOND throws `DuplicateRunIdError` instead of silently overwriting. The
|
|
110
|
+
* temp+rename pattern still provides crash-atomicity — the canonical file is
|
|
111
|
+
* either fully written or absent, never partial.
|
|
112
|
+
*
|
|
113
|
+
* Why not just `existsSync` then `writeFileSync`? That's the original race —
|
|
114
|
+
* the existsSync check and the write are not atomic. `open(wx)` collapses both
|
|
115
|
+
* into a single OS-level call.
|
|
116
|
+
*
|
|
117
|
+
* @param {object} record - Persisted record
|
|
118
|
+
* @param {string} repoRoot - Absolute path to dogfood-labs repo root
|
|
119
|
+
* @returns {{ path: string, written: boolean }} path and whether a write occurred
|
|
120
|
+
* @throws {DuplicateRunIdError} when a concurrent writer won the race
|
|
121
|
+
*/
|
|
122
|
+
export function writeRecord(record, repoRoot) {
|
|
123
|
+
if (isDuplicate(record.run_id, record, repoRoot)) {
|
|
124
|
+
const path = computeRecordPath(record, repoRoot);
|
|
125
|
+
return { path, written: false };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Enforce dogfood-record.schema.json BEFORE touching the filesystem.
|
|
129
|
+
// Better to throw loudly than silently persist a malformed record — the
|
|
130
|
+
// schema is the contract every downstream consumer relies on.
|
|
131
|
+
validateRecord(record);
|
|
132
|
+
|
|
133
|
+
const path = computeRecordPath(record, repoRoot);
|
|
134
|
+
const dir = dirname(path);
|
|
135
|
+
|
|
136
|
+
mkdirSync(dir, { recursive: true });
|
|
137
|
+
|
|
138
|
+
// Race-safe atomic create: try to claim the canonical path with O_EXCL first.
|
|
139
|
+
// If another writer already won the race, fail closed with DuplicateRunIdError
|
|
140
|
+
// — never silently overwrite. On success, hold an empty file we'll fill via
|
|
141
|
+
// temp+rename so the visible bytes are still atomic.
|
|
142
|
+
let claimed = false;
|
|
143
|
+
try {
|
|
144
|
+
const fd = openSync(path, 'wx');
|
|
145
|
+
closeSync(fd);
|
|
146
|
+
claimed = true;
|
|
147
|
+
} catch (err) {
|
|
148
|
+
if (err && err.code === 'EEXIST') {
|
|
149
|
+
throw new DuplicateRunIdError(record.run_id, path);
|
|
150
|
+
}
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Atomic write: temp file → rename over the empty placeholder.
|
|
155
|
+
const tmpSuffix = randomBytes(4).toString('hex');
|
|
156
|
+
const tmpPath = `${path}.${tmpSuffix}.tmp`;
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
writeFileSync(tmpPath, JSON.stringify(record, null, 2) + '\n', 'utf-8');
|
|
160
|
+
renameSync(tmpPath, path);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
// On any failure after we claimed the path, release the claim so a retry
|
|
163
|
+
// can succeed. The tmp file is best-effort cleanup.
|
|
164
|
+
if (claimed) {
|
|
165
|
+
try { unlinkSync(path); } catch { /* placeholder already gone */ }
|
|
166
|
+
}
|
|
167
|
+
try { unlinkSync(tmpPath); } catch { /* tmp may not exist */ }
|
|
168
|
+
throw err;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { path, written: true };
|
|
172
|
+
}
|