@dogfood-lab/ingest 1.2.3 → 1.3.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.
- package/lib/rename-with-retry.js +8 -2
- package/lib/sleep-sync.js +31 -0
- package/load-context.js +218 -34
- package/package.json +3 -4
- package/rebuild-indexes.js +30 -2
- package/run.js +125 -9
- package/validate-record.js +33 -39
package/lib/rename-with-retry.js
CHANGED
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { renameSync } from 'node:fs';
|
|
26
26
|
|
|
27
|
+
import { sleepSync } from './sleep-sync.js';
|
|
28
|
+
|
|
27
29
|
export function renameWithRetry(tmp, dest, { retries = 10, baseMs = 15, maxMs = 200 } = {}) {
|
|
28
30
|
for (let i = 0; i <= retries; i++) {
|
|
29
31
|
try {
|
|
@@ -32,8 +34,12 @@ export function renameWithRetry(tmp, dest, { retries = 10, baseMs = 15, maxMs =
|
|
|
32
34
|
} catch (err) {
|
|
33
35
|
if ((err.code !== 'EPERM' && err.code !== 'EBUSY') || i === retries) throw err;
|
|
34
36
|
const delay = Math.min(baseMs * (1 << i), maxMs);
|
|
35
|
-
|
|
36
|
-
while (Date.now() < until)
|
|
37
|
+
// D1B-002-ingest (Stage C humanization fold): replaced the
|
|
38
|
+
// `while (Date.now() < until)` busy-spin with the Atomics.wait-
|
|
39
|
+
// based sleepSync helper. Same workspace-cycle pattern as
|
|
40
|
+
// `ingest/lib/atomic-write.js` — sibling helper, not a
|
|
41
|
+
// cross-package import.
|
|
42
|
+
sleepSync(delay);
|
|
37
43
|
}
|
|
38
44
|
}
|
|
39
45
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sleepSync — `Atomics.wait`-based synchronous sleep.
|
|
3
|
+
*
|
|
4
|
+
* Sibling of `packages/findings/lib/file-lock.js`'s private `sleepSync`. The
|
|
5
|
+
* duplication exists because npm workspaces do not allow `ingest → findings`
|
|
6
|
+
* imports (findings already depends on ingest, and the reverse edge would
|
|
7
|
+
* create a workspace dependency cycle — same constraint that forced
|
|
8
|
+
* `packages/ingest/lib/atomic-write.js` to duplicate
|
|
9
|
+
* `packages/findings/lib/atomic-write.js`).
|
|
10
|
+
*
|
|
11
|
+
* The helper exists to replace the `while (Date.now() < until)` busy-spin
|
|
12
|
+
* loops sprinkled across synchronous backoff paths (notably
|
|
13
|
+
* `packages/ingest/lib/rename-with-retry.js`'s EPERM/EBUSY retry on Windows
|
|
14
|
+
* NTFS). Spin loops burn a full CPU core for the entire backoff window
|
|
15
|
+
* — Atomics.wait cedes the thread.
|
|
16
|
+
*
|
|
17
|
+
* Why synchronous: the callsites (atomic-write helpers, rebuild-indexes,
|
|
18
|
+
* event-log appender, rename-with-retry) are themselves synchronous and
|
|
19
|
+
* cascading an `await` would leak the boundary into otherwise-deterministic
|
|
20
|
+
* flush paths — same rationale documented at the findings sibling.
|
|
21
|
+
*
|
|
22
|
+
* @param {number} ms - Milliseconds to sleep. Non-finite or non-positive
|
|
23
|
+
* values are no-ops (we don't trust caller arithmetic to never produce
|
|
24
|
+
* negative deltas if a system clock skews).
|
|
25
|
+
*/
|
|
26
|
+
export function sleepSync(ms) {
|
|
27
|
+
if (!Number.isFinite(ms) || ms <= 0) return;
|
|
28
|
+
const sab = new SharedArrayBuffer(4);
|
|
29
|
+
const view = new Int32Array(sab);
|
|
30
|
+
Atomics.wait(view, 0, 0, ms);
|
|
31
|
+
}
|
package/load-context.js
CHANGED
|
@@ -14,8 +14,39 @@ import { readFileSync, existsSync } from 'node:fs';
|
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
import yaml from 'js-yaml';
|
|
16
16
|
|
|
17
|
+
import { validatePayload } from '@dogfood-lab/schemas';
|
|
18
|
+
import { logStage as sharedLogStage } from '@dogfood-lab/dogfood-swarm/lib/log-stage.js';
|
|
17
19
|
import { isUnsafeSegment } from './lib/unsafe-segment.js';
|
|
18
20
|
|
|
21
|
+
/**
|
|
22
|
+
* D2B-005 (Phase 10 Step 1): summarise a `validatePayload('policy', …)`
|
|
23
|
+
* result down to a single-line operator-actionable reason string. The
|
|
24
|
+
* canonical seam returns `{ path, message, keyword, params }` per error;
|
|
25
|
+
* we collapse the first 3 violations into a `path message` join so the
|
|
26
|
+
* sentinel's `reason` field stays grep-friendly without truncating the
|
|
27
|
+
* structural detail an operator needs to find the offending YAML key.
|
|
28
|
+
*
|
|
29
|
+
* Three errors is a deliberate ceiling — a policy with 20 violations
|
|
30
|
+
* almost certainly has a single root cause (wrong section nesting,
|
|
31
|
+
* malformed root); flooding the reason string with 20 lines makes the
|
|
32
|
+
* error harder to read, not easier.
|
|
33
|
+
*/
|
|
34
|
+
function summarizePolicyErrors(errors) {
|
|
35
|
+
const trimmed = errors.slice(0, 3).map(e => `${e.path || '/'} ${e.message}`);
|
|
36
|
+
const ellipsis = errors.length > 3 ? `; (+${errors.length - 3} more)` : '';
|
|
37
|
+
return trimmed.join('; ') + ellipsis;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* D1B-006 (Stage C humanization): one structured `logStage(...)` helper,
|
|
42
|
+
* component-pinned to `ingest` so the NDJSON stream is greppable with the
|
|
43
|
+
* rest of the pipeline. Same wave-22 wrapper-strip pattern as `run.js`.
|
|
44
|
+
*/
|
|
45
|
+
function logStage(stage, fields = {}) {
|
|
46
|
+
const { stage: _ignored, ...rest } = fields;
|
|
47
|
+
sharedLogStage(stage, { component: 'ingest', ...rest });
|
|
48
|
+
}
|
|
49
|
+
|
|
19
50
|
/**
|
|
20
51
|
* Load the global policy.
|
|
21
52
|
*
|
|
@@ -46,8 +77,9 @@ export function loadGlobalPolicy(repoRoot) {
|
|
|
46
77
|
}
|
|
47
78
|
throw new Error(`Global policy unreadable: ${path} — ${e.message}`);
|
|
48
79
|
}
|
|
80
|
+
let parsed;
|
|
49
81
|
try {
|
|
50
|
-
|
|
82
|
+
parsed = yaml.load(raw);
|
|
51
83
|
} catch (e) {
|
|
52
84
|
const where = e.mark ? ` at line ${e.mark.line + 1}, column ${e.mark.column + 1}` : '';
|
|
53
85
|
throw new Error(
|
|
@@ -55,14 +87,46 @@ export function loadGlobalPolicy(repoRoot) {
|
|
|
55
87
|
`Fix the YAML and re-run.`
|
|
56
88
|
);
|
|
57
89
|
}
|
|
90
|
+
|
|
91
|
+
// D2B-005: schema-gate the parsed policy against policy.schema.json.
|
|
92
|
+
// Pre-fix, a YAML that parsed but didn't conform (extra fields, wrong
|
|
93
|
+
// enums, missing required) silently loaded and the verifier ran with
|
|
94
|
+
// ad-hoc field probing — a structurally-invalid policy could produce
|
|
95
|
+
// "verifier accepts everything." Post-H3 the canonical seam is one
|
|
96
|
+
// call away. Global policy is required + load-once, so a schema fault
|
|
97
|
+
// here is fail-loud (matches the YAML-invalid sibling above).
|
|
98
|
+
const validation = validatePayload('policy', parsed);
|
|
99
|
+
if (!validation.valid) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`Global policy schema-invalid: ${path} — ${summarizePolicyErrors(validation.errors)}\n` +
|
|
102
|
+
`Fix the policy to conform to policy.schema.json and re-run.`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return parsed;
|
|
58
106
|
}
|
|
59
107
|
|
|
60
108
|
/**
|
|
61
|
-
* Load repo-specific policy.
|
|
109
|
+
* Load repo-specific policy.
|
|
110
|
+
*
|
|
111
|
+
* Three discriminable return shapes:
|
|
112
|
+
* - `null` → no repo-policy file exists; defaults apply
|
|
113
|
+
* (documented design — submission is NOT rejected for absent policy).
|
|
114
|
+
* - parsed YAML object → policy loaded cleanly.
|
|
115
|
+
* - `{ __torn: true, reason, path }` → D1B-006 sentinel: the policy
|
|
116
|
+
* file EXISTS but cannot be parsed. Pre-fix this case returned null,
|
|
117
|
+
* which the verifier then treated as "no policy → defaults apply →
|
|
118
|
+
* accept". That sentinel inversion silently approved every
|
|
119
|
+
* submission against a corrupt policy. The torn sentinel surfaces
|
|
120
|
+
* the broken state so the verifier can reject with a
|
|
121
|
+
* `'policy: <reason>'` rejection AND `policy_valid=false`.
|
|
122
|
+
*
|
|
123
|
+
* The sentinel is intentionally a distinct shape (`__torn: true`) so the
|
|
124
|
+
* verifier pattern-match needs no schema knowledge — the existence of
|
|
125
|
+
* `__torn` discriminates without parsing the policy twice.
|
|
62
126
|
*
|
|
63
127
|
* @param {string} repoSlug - e.g. "mcp-tool-shop-org/dogfood-labs"
|
|
64
128
|
* @param {string} repoRoot
|
|
65
|
-
* @returns {object|null}
|
|
129
|
+
* @returns {object|null|{ __torn: true, reason: string, path: string }}
|
|
66
130
|
*/
|
|
67
131
|
export function loadRepoPolicy(repoSlug, repoRoot) {
|
|
68
132
|
const [org, repo] = repoSlug.split('/');
|
|
@@ -70,12 +134,43 @@ export function loadRepoPolicy(repoSlug, repoRoot) {
|
|
|
70
134
|
const path = join(repoRoot, 'policies', 'repos', org, `${repo}.yaml`);
|
|
71
135
|
|
|
72
136
|
if (!existsSync(path)) return null;
|
|
137
|
+
let parsed;
|
|
73
138
|
try {
|
|
74
|
-
|
|
75
|
-
} catch {
|
|
76
|
-
|
|
77
|
-
|
|
139
|
+
parsed = yaml.load(readFileSync(path, 'utf-8'));
|
|
140
|
+
} catch (e) {
|
|
141
|
+
// D1B-006: torn-policy sentinel + structured warn event for YAML
|
|
142
|
+
// parse failures. The verifier (verify/index.js:189-192) surfaces
|
|
143
|
+
// this as a `policy: <reason>` rejection.
|
|
144
|
+
const reason = e && e.message ? e.message : String(e);
|
|
145
|
+
logStage('warn', {
|
|
146
|
+
kind: 'repo_policy_unreadable',
|
|
147
|
+
repo: repoSlug,
|
|
148
|
+
path,
|
|
149
|
+
error: reason
|
|
150
|
+
});
|
|
151
|
+
return { __torn: true, reason, path };
|
|
78
152
|
}
|
|
153
|
+
|
|
154
|
+
// D2B-005 (Phase 10 Step 1): schema-gate the parsed repo policy.
|
|
155
|
+
// Reuses the `__torn` sentinel so the verifier's existing single-
|
|
156
|
+
// branch handler (verify/index.js:189) catches both "YAML failed
|
|
157
|
+
// to parse" (D1B-006) and "YAML parsed but doesn't conform to
|
|
158
|
+
// policy.schema.json" (D2B-005). The verify-side reason prefix
|
|
159
|
+
// (`policy: repo policy unreadable — <reason>`) covers both
|
|
160
|
+
// classes; the embedded schema-violation detail tells the operator
|
|
161
|
+
// which YAML key to fix.
|
|
162
|
+
const validation = validatePayload('policy', parsed);
|
|
163
|
+
if (!validation.valid) {
|
|
164
|
+
const reason = `schema-invalid — ${summarizePolicyErrors(validation.errors)}`;
|
|
165
|
+
logStage('warn', {
|
|
166
|
+
kind: 'repo_policy_unreadable',
|
|
167
|
+
repo: repoSlug,
|
|
168
|
+
path,
|
|
169
|
+
error: reason
|
|
170
|
+
});
|
|
171
|
+
return { __torn: true, reason, path };
|
|
172
|
+
}
|
|
173
|
+
return parsed;
|
|
79
174
|
}
|
|
80
175
|
|
|
81
176
|
/**
|
|
@@ -96,64 +191,153 @@ export function localScenarioFetcher(repoRoot) {
|
|
|
96
191
|
};
|
|
97
192
|
}
|
|
98
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Default per-request timeout for the GitHub scenario fetch. Mirrors the
|
|
196
|
+
* sibling `GITHUB_PROVENANCE_TIMEOUT_MS` constant at
|
|
197
|
+
* `packages/verify/validators/provenance.js`: a hung GitHub API call
|
|
198
|
+
* would otherwise stall ingest until the surrounding GitHub Actions
|
|
199
|
+
* runner timeout fires (default 6h). 30s fails fast with a typed
|
|
200
|
+
* `'timeout'` reason the operator can pivot on.
|
|
201
|
+
*/
|
|
202
|
+
export const GITHUB_SCENARIO_FETCH_TIMEOUT_MS = 30000;
|
|
203
|
+
|
|
99
204
|
/**
|
|
100
205
|
* GitHub scenario fetcher. Loads scenario definitions from a source repo
|
|
101
206
|
* via the GitHub API at a specific commit SHA.
|
|
102
207
|
*
|
|
208
|
+
* Two surfaces on the returned adapter:
|
|
209
|
+
* - `fetch(scenarioId)`: legacy contract — returns the scenario object
|
|
210
|
+
* on success, `null` on any failure. Preserved for back-compat with
|
|
211
|
+
* callers that pattern-match on the truthiness of the result.
|
|
212
|
+
* - `fetchWithReason(scenarioId)`: D1B-004 typed contract — always
|
|
213
|
+
* returns `{ scenario, reason }` where `scenario` is the loaded
|
|
214
|
+
* object on success or `null` on failure, and `reason` is one of
|
|
215
|
+
* `'timeout' | 'not_found' | 'parse_error' | 'invalid_id'` on
|
|
216
|
+
* failure (absent on success). The reason gives operators a
|
|
217
|
+
* pivot key when diagnosing a stale scenario-load chain.
|
|
218
|
+
*
|
|
219
|
+
* Both surfaces honour the per-request AbortController timeout
|
|
220
|
+
* (`GITHUB_SCENARIO_FETCH_TIMEOUT_MS`, overridable via `opts.timeoutMs`).
|
|
221
|
+
*
|
|
103
222
|
* @param {string} token - GitHub PAT
|
|
104
223
|
* @param {string} repoSlug - e.g. "mcp-tool-shop-org/shipcheck"
|
|
105
224
|
* @param {string} commitSha - Commit to fetch scenarios from
|
|
106
|
-
* @
|
|
225
|
+
* @param {{ timeoutMs?: number, fetchImpl?: typeof fetch }} [opts]
|
|
226
|
+
* @returns {{ fetch(scenarioId: string): Promise<object|null>, fetchWithReason(scenarioId: string): Promise<{ scenario: object|null, reason?: string }> }}
|
|
107
227
|
*/
|
|
108
|
-
export function githubScenarioFetcher(token, repoSlug, commitSha) {
|
|
228
|
+
export function githubScenarioFetcher(token, repoSlug, commitSha, opts = {}) {
|
|
229
|
+
const timeoutMs = opts.timeoutMs ?? GITHUB_SCENARIO_FETCH_TIMEOUT_MS;
|
|
230
|
+
const fetchImpl = opts.fetchImpl ?? ((url, init) => globalThis.fetch(url, init));
|
|
231
|
+
|
|
109
232
|
const [org, repo] = repoSlug.split('/');
|
|
110
233
|
if (!org || !repo || isUnsafeSegment(org) || isUnsafeSegment(repo)) {
|
|
111
|
-
return {
|
|
234
|
+
return {
|
|
235
|
+
async fetch() { return null; },
|
|
236
|
+
async fetchWithReason() { return { scenario: null, reason: 'invalid_id' }; }
|
|
237
|
+
};
|
|
112
238
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
239
|
+
|
|
240
|
+
async function fetchWithReason(scenarioId) {
|
|
241
|
+
if (!/^[\w-]+$/.test(scenarioId)) {
|
|
242
|
+
return { scenario: null, reason: 'invalid_id' };
|
|
243
|
+
}
|
|
244
|
+
const path = `dogfood/scenarios/${scenarioId}.yaml`;
|
|
245
|
+
const url = `https://api.github.com/repos/${repoSlug}/contents/${path}?ref=${commitSha}`;
|
|
246
|
+
|
|
247
|
+
// D1B-004: AbortController-bounded request. Copied from
|
|
248
|
+
// `packages/verify/validators/provenance.js:80-104`. The 30s default
|
|
249
|
+
// matches that sibling so a single end-to-end ingest cannot stall
|
|
250
|
+
// longer than ~60s on cumulative GitHub-API timeouts.
|
|
251
|
+
const controller = new AbortController();
|
|
252
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
253
|
+
|
|
254
|
+
let text;
|
|
255
|
+
try {
|
|
256
|
+
const resp = await fetchImpl(url, {
|
|
257
|
+
headers: {
|
|
258
|
+
Authorization: `Bearer ${token}`,
|
|
259
|
+
Accept: 'application/vnd.github.raw+json',
|
|
260
|
+
'X-GitHub-Api-Version': '2022-11-28'
|
|
261
|
+
},
|
|
262
|
+
signal: controller.signal
|
|
263
|
+
});
|
|
264
|
+
if (!resp.ok) {
|
|
265
|
+
return { scenario: null, reason: 'not_found' };
|
|
266
|
+
}
|
|
267
|
+
text = await resp.text();
|
|
268
|
+
} catch (err) {
|
|
269
|
+
if (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) {
|
|
270
|
+
return { scenario: null, reason: 'timeout' };
|
|
132
271
|
}
|
|
272
|
+
// Network reject, DNS failure, etc. — surface as not_found for
|
|
273
|
+
// back-compat with the legacy null contract.
|
|
274
|
+
return { scenario: null, reason: 'not_found' };
|
|
275
|
+
} finally {
|
|
276
|
+
clearTimeout(timer);
|
|
133
277
|
}
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
const scenario = yaml.load(text);
|
|
281
|
+
if (!scenario || typeof scenario !== 'object') {
|
|
282
|
+
return { scenario: null, reason: 'parse_error' };
|
|
283
|
+
}
|
|
284
|
+
return { scenario };
|
|
285
|
+
} catch {
|
|
286
|
+
return { scenario: null, reason: 'parse_error' };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
// Legacy: success returns the scenario object, failure returns null
|
|
292
|
+
// — every existing caller pattern-matches on truthiness.
|
|
293
|
+
async fetch(scenarioId) {
|
|
294
|
+
const result = await fetchWithReason(scenarioId);
|
|
295
|
+
return result.scenario;
|
|
296
|
+
},
|
|
297
|
+
fetchWithReason
|
|
134
298
|
};
|
|
135
299
|
}
|
|
136
300
|
|
|
137
301
|
/**
|
|
138
302
|
* Load all scenario definitions referenced by a submission's scenario_results.
|
|
139
303
|
*
|
|
304
|
+
* D1B-004 / L1-007 (Wave A2 Stage C amend2): when the fetcher exposes
|
|
305
|
+
* the typed `fetchWithReason` surface (the canonical
|
|
306
|
+
* `githubScenarioFetcher` does; the legacy `localScenarioFetcher` and any
|
|
307
|
+
* legacy test stub may not), the discriminated `reason` (`timeout` /
|
|
308
|
+
* `not_found` / `parse_error` / `invalid_id`) is propagated into the
|
|
309
|
+
* error string so the operator can pivot on the failure class. Otherwise
|
|
310
|
+
* we fall back to the legacy `fetch(id)` truthiness contract.
|
|
311
|
+
*
|
|
140
312
|
* @param {object} submission
|
|
141
|
-
* @param {object} scenarioFetcher - { fetch(scenarioId)
|
|
313
|
+
* @param {object} scenarioFetcher - { fetch(scenarioId), fetchWithReason?(scenarioId) }
|
|
142
314
|
* @returns {Promise<{ scenarios: Map<string, object>, errors: string[] }>}
|
|
143
315
|
*/
|
|
144
316
|
export async function loadScenarios(submission, scenarioFetcher) {
|
|
145
317
|
const scenarios = new Map();
|
|
146
318
|
const errors = [];
|
|
147
319
|
|
|
320
|
+
const supportsTypedReason = typeof scenarioFetcher.fetchWithReason === 'function';
|
|
321
|
+
|
|
148
322
|
for (const sr of submission.scenario_results || []) {
|
|
149
323
|
const id = sr.scenario_id;
|
|
150
324
|
if (scenarios.has(id)) continue;
|
|
151
325
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
326
|
+
if (supportsTypedReason) {
|
|
327
|
+
const result = await scenarioFetcher.fetchWithReason(id);
|
|
328
|
+
if (result && result.scenario) {
|
|
329
|
+
scenarios.set(id, result.scenario);
|
|
330
|
+
} else {
|
|
331
|
+
const reason = result && result.reason ? result.reason : 'unknown';
|
|
332
|
+
errors.push(`scenario "${id}" could not be loaded from source repo (reason: ${reason})`);
|
|
333
|
+
}
|
|
155
334
|
} else {
|
|
156
|
-
|
|
335
|
+
const definition = await scenarioFetcher.fetch(id);
|
|
336
|
+
if (definition) {
|
|
337
|
+
scenarios.set(id, definition);
|
|
338
|
+
} else {
|
|
339
|
+
errors.push(`scenario "${id}" could not be loaded from source repo`);
|
|
340
|
+
}
|
|
157
341
|
}
|
|
158
342
|
}
|
|
159
343
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dogfood-lab/ingest",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Ingestion pipeline for testing-os. Thin glue: dispatch → verifier → persist → indexes.",
|
|
6
6
|
"main": "run.js",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./run.js",
|
|
9
|
-
"./lib/*": "./lib/*"
|
|
9
|
+
"./lib/*": "./lib/*",
|
|
10
|
+
"./validate-record.js": "./validate-record.js"
|
|
10
11
|
},
|
|
11
12
|
"scripts": {
|
|
12
13
|
"test": "node --test",
|
|
@@ -29,8 +30,6 @@
|
|
|
29
30
|
"@dogfood-lab/dogfood-swarm": "^1.2.0",
|
|
30
31
|
"@dogfood-lab/schemas": "^1.2.0",
|
|
31
32
|
"@dogfood-lab/verify": "^1.2.0",
|
|
32
|
-
"ajv": "^8.18.0",
|
|
33
|
-
"ajv-formats": "^3.0.1",
|
|
34
33
|
"js-yaml": "^4.1.0"
|
|
35
34
|
},
|
|
36
35
|
"engines": {
|
package/rebuild-indexes.js
CHANGED
|
@@ -28,8 +28,24 @@ import { readdirSync, readFileSync, writeFileSync, mkdirSync, existsSync, unlink
|
|
|
28
28
|
import { join, relative } from 'node:path';
|
|
29
29
|
import { randomBytes } from 'node:crypto';
|
|
30
30
|
|
|
31
|
+
import { logStage as sharedLogStage } from '@dogfood-lab/dogfood-swarm/lib/log-stage.js';
|
|
31
32
|
import { stageWriteFileSync, promoteStaged, discardStaged } from './lib/atomic-write.js';
|
|
32
33
|
|
|
34
|
+
/**
|
|
35
|
+
* D1B-005 (Stage C humanization): one structured `logStage('warn', ...)`
|
|
36
|
+
* helper, component-pinned to `ingest` so the NDJSON line is greppable
|
|
37
|
+
* with the rest of the pipeline. Wave-22 sibling pattern — see
|
|
38
|
+
* `packages/ingest/run.js`'s private `logStage` wrapper for the same
|
|
39
|
+
* `component: 'ingest'` pinning convention.
|
|
40
|
+
*/
|
|
41
|
+
function logStage(stage, fields = {}) {
|
|
42
|
+
// Defensive: strip any caller-supplied `stage:` before delegating so
|
|
43
|
+
// the outer positional stage always wins. Same shape as run.js (see
|
|
44
|
+
// wave-22 F-827321-035 hardening rationale).
|
|
45
|
+
const { stage: _ignored, ...rest } = fields;
|
|
46
|
+
sharedLogStage(stage, { component: 'ingest', ...rest });
|
|
47
|
+
}
|
|
48
|
+
|
|
33
49
|
/**
|
|
34
50
|
* Recursively find all .json files under a directory.
|
|
35
51
|
*
|
|
@@ -103,12 +119,24 @@ export function rebuildIndexes(repoRoot, options = {}) {
|
|
|
103
119
|
const { record, error } = loadRecord(f);
|
|
104
120
|
if (error) {
|
|
105
121
|
corrupted.push({ path: relPath, error });
|
|
106
|
-
|
|
122
|
+
// D1B-005: structured warn event so the NDJSON stream carries the
|
|
123
|
+
// skip with the same discipline as the ingest pipeline. Greppable
|
|
124
|
+
// via `"kind":"record_skipped"` + `"reason":"corrupted"`.
|
|
125
|
+
logStage('warn', {
|
|
126
|
+
kind: 'record_skipped',
|
|
127
|
+
reason: 'corrupted',
|
|
128
|
+
path: relPath,
|
|
129
|
+
error
|
|
130
|
+
});
|
|
107
131
|
continue;
|
|
108
132
|
}
|
|
109
133
|
if (!record || !record.run_id) {
|
|
110
134
|
skipped.push({ path: relPath, reason: 'missing run_id' });
|
|
111
|
-
|
|
135
|
+
logStage('warn', {
|
|
136
|
+
kind: 'record_skipped',
|
|
137
|
+
reason: 'missing_run_id',
|
|
138
|
+
path: relPath
|
|
139
|
+
});
|
|
112
140
|
continue;
|
|
113
141
|
}
|
|
114
142
|
record._path = relPath;
|
package/run.js
CHANGED
|
@@ -100,7 +100,7 @@ function resolveCorrelationId(submission) {
|
|
|
100
100
|
*
|
|
101
101
|
* @param {object} submission - Source-authored submission payload
|
|
102
102
|
* @param {object} options
|
|
103
|
-
* @param {string} options.repoRoot - Absolute path to dogfood-
|
|
103
|
+
* @param {string} options.repoRoot - Absolute path to the dogfood-lab/testing-os repo root
|
|
104
104
|
* @param {object} options.provenance - Provenance adapter (REQUIRED — no default, no implicit stub)
|
|
105
105
|
* @param {object} [options.scenarioFetcher] - Scenario fetch adapter
|
|
106
106
|
* @returns {Promise<{ record: object, path: string, written: boolean, duplicate: boolean }>}
|
|
@@ -435,6 +435,31 @@ export async function verifyOnly(submission, options) {
|
|
|
435
435
|
return { record, would_persist_to, verify_only: true };
|
|
436
436
|
}
|
|
437
437
|
|
|
438
|
+
/**
|
|
439
|
+
* D1B-001: emit a single structured `stage:'error'` NDJSON event for any
|
|
440
|
+
* CLI-toplevel exit-2 failure, mirroring the shape used by the
|
|
441
|
+
* `rebuild_indexes` inner catch. Truncates the stack to 20 lines so the
|
|
442
|
+
* event stays grep-friendly. The human-readable `console.error` line is
|
|
443
|
+
* preserved so log-only readers continue to get the same actionable hint.
|
|
444
|
+
*
|
|
445
|
+
* Keep this hoisted (above the `isMain` block) so it is callable from every
|
|
446
|
+
* branch inside the CLI body — including the JSON.parse catch which fires
|
|
447
|
+
* BEFORE the pipeline has assigned a correlation_id from `submission.run_id`.
|
|
448
|
+
*/
|
|
449
|
+
function emitCliErrorEvent({ failedStage, correlationId, submissionId = null, err, humanPrefix }) {
|
|
450
|
+
const truncatedStack = err && err.stack
|
|
451
|
+
? err.stack.split('\n').slice(0, 20).join('\n')
|
|
452
|
+
: null;
|
|
453
|
+
logStage('error', {
|
|
454
|
+
submission_id: submissionId,
|
|
455
|
+
correlation_id: correlationId,
|
|
456
|
+
failed_stage: failedStage,
|
|
457
|
+
message: err && err.message ? err.message : String(err),
|
|
458
|
+
stack: truncatedStack
|
|
459
|
+
});
|
|
460
|
+
console.error(`ERROR: ${humanPrefix}: ${err && err.message ? err.message : String(err)}`);
|
|
461
|
+
}
|
|
462
|
+
|
|
438
463
|
// --- CLI entrypoint ---
|
|
439
464
|
// When run directly, reads submission from stdin or file argument
|
|
440
465
|
|
|
@@ -455,7 +480,24 @@ if (isMain) {
|
|
|
455
480
|
provenanceMode = args[++i];
|
|
456
481
|
} else if (args[i] === '--file' && args[i + 1]) {
|
|
457
482
|
const { readFileSync } = await import('node:fs');
|
|
458
|
-
|
|
483
|
+
// D1B-001 family (operator-legibility): a --file read failure
|
|
484
|
+
// (ENOENT/EACCES) routes through the structured error event and exits 2
|
|
485
|
+
// — pre-fix it propagated as a raw uncaught stack + exit 1, with NO
|
|
486
|
+
// grep-able `"stage":"error"` NDJSON line. This read runs during
|
|
487
|
+
// arg-parsing, BEFORE `cliCorrelationId` is seeded below, so synth a
|
|
488
|
+
// correlation id here (the same pivot the JSON.parse catch uses when
|
|
489
|
+
// there is no submission to derive a run_id from yet).
|
|
490
|
+
try {
|
|
491
|
+
submissionJson = readFileSync(resolve(args[++i]), 'utf-8');
|
|
492
|
+
} catch (err) {
|
|
493
|
+
emitCliErrorEvent({
|
|
494
|
+
failedStage: 'cli_read_file',
|
|
495
|
+
correlationId: synthCorrelationId(),
|
|
496
|
+
err,
|
|
497
|
+
humanPrefix: 'could not read --file payload'
|
|
498
|
+
});
|
|
499
|
+
process.exit(2);
|
|
500
|
+
}
|
|
459
501
|
} else if (args[i] === '--payload' && args[i + 1]) {
|
|
460
502
|
submissionJson = args[++i];
|
|
461
503
|
} else if (args[i] === '--verify-only') {
|
|
@@ -476,23 +518,53 @@ if (isMain) {
|
|
|
476
518
|
submissionJson = Buffer.concat(chunks).toString('utf-8');
|
|
477
519
|
}
|
|
478
520
|
|
|
521
|
+
// D1B-001 (Stage C humanization): every CLI exit-2 path emits a structured
|
|
522
|
+
// `logStage('error', ...)` event before `process.exit(2)` so a grep of
|
|
523
|
+
// `"stage":"error"` across runner logs surfaces the failure with the same
|
|
524
|
+
// discipline as the inner `rebuild_indexes` catch. `failed_stage` names
|
|
525
|
+
// the last-successful pipeline stage; `correlation_id` carries the pivot
|
|
526
|
+
// key (submission.run_id when available, synth `ing-…` otherwise).
|
|
527
|
+
let lastSuccessfulStage = 'cli_startup';
|
|
528
|
+
let cliCorrelationId = synthCorrelationId();
|
|
529
|
+
|
|
479
530
|
let submission;
|
|
480
531
|
try {
|
|
481
532
|
submission = JSON.parse(submissionJson);
|
|
482
533
|
if (typeof submission === 'string') {
|
|
483
534
|
submission = JSON.parse(submission);
|
|
484
535
|
}
|
|
536
|
+
lastSuccessfulStage = 'cli_parse_payload';
|
|
537
|
+
// Promote the synth id to submission.run_id when we have one.
|
|
538
|
+
cliCorrelationId = resolveCorrelationId(submission);
|
|
485
539
|
} catch (err) {
|
|
486
|
-
|
|
540
|
+
emitCliErrorEvent({
|
|
541
|
+
failedStage: 'cli_parse_payload',
|
|
542
|
+
correlationId: cliCorrelationId,
|
|
543
|
+
err,
|
|
544
|
+
humanPrefix: 'invalid JSON payload'
|
|
545
|
+
});
|
|
487
546
|
process.exit(2);
|
|
488
547
|
}
|
|
489
548
|
|
|
490
|
-
// Resolve provenance adapter — explicit, never implicit
|
|
549
|
+
// Resolve provenance adapter — explicit, never implicit.
|
|
550
|
+
//
|
|
551
|
+
// L1-001 (Wave A2 amend2): every exit-2 path here routes through
|
|
552
|
+
// `emitCliErrorEvent` so the D1B-001 documented invariant ("every CLI
|
|
553
|
+
// exit-2 path emits a structured logStage('error', …) event") holds.
|
|
554
|
+
// `failed_stage='cli_provenance_resolve'` names the precondition; the
|
|
555
|
+
// `console.error` line is preserved inside the helper so log-only
|
|
556
|
+
// readers keep the same actionable hint.
|
|
491
557
|
let provenance;
|
|
492
558
|
if (provenanceMode === 'stub') {
|
|
493
559
|
// Structural anti-misuse: stub only allowed outside CI
|
|
494
560
|
if (process.env.CI || process.env.GITHUB_ACTIONS) {
|
|
495
|
-
|
|
561
|
+
emitCliErrorEvent({
|
|
562
|
+
failedStage: 'cli_provenance_resolve',
|
|
563
|
+
correlationId: cliCorrelationId,
|
|
564
|
+
submissionId: submission && submission.run_id ? submission.run_id : null,
|
|
565
|
+
err: new Error('--provenance=stub is not allowed in CI/production. Use --provenance=github.'),
|
|
566
|
+
humanPrefix: 'provenance precondition unmet'
|
|
567
|
+
});
|
|
496
568
|
process.exit(2);
|
|
497
569
|
}
|
|
498
570
|
console.error('WARNING: Using stub provenance (test/dev only). Records will NOT have real provenance verification.');
|
|
@@ -500,7 +572,13 @@ if (isMain) {
|
|
|
500
572
|
} else if (provenanceMode === 'github') {
|
|
501
573
|
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
502
574
|
if (!token) {
|
|
503
|
-
|
|
575
|
+
emitCliErrorEvent({
|
|
576
|
+
failedStage: 'cli_provenance_resolve',
|
|
577
|
+
correlationId: cliCorrelationId,
|
|
578
|
+
submissionId: submission && submission.run_id ? submission.run_id : null,
|
|
579
|
+
err: new Error('--provenance=github requires GITHUB_TOKEN or GH_TOKEN environment variable.'),
|
|
580
|
+
humanPrefix: 'provenance precondition unmet'
|
|
581
|
+
});
|
|
504
582
|
process.exit(2);
|
|
505
583
|
}
|
|
506
584
|
provenance = githubProvenance(token);
|
|
@@ -508,18 +586,40 @@ if (isMain) {
|
|
|
508
586
|
// In CI without explicit flag: default to github provenance, fail if no token
|
|
509
587
|
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
510
588
|
if (!token) {
|
|
511
|
-
|
|
589
|
+
emitCliErrorEvent({
|
|
590
|
+
failedStage: 'cli_provenance_resolve',
|
|
591
|
+
correlationId: cliCorrelationId,
|
|
592
|
+
submissionId: submission && submission.run_id ? submission.run_id : null,
|
|
593
|
+
err: new Error('Running in CI without --provenance flag and no GITHUB_TOKEN. Cannot verify provenance.'),
|
|
594
|
+
humanPrefix: 'provenance precondition unmet'
|
|
595
|
+
});
|
|
512
596
|
process.exit(2);
|
|
513
597
|
}
|
|
514
598
|
provenance = githubProvenance(token);
|
|
515
599
|
} else {
|
|
516
|
-
|
|
600
|
+
emitCliErrorEvent({
|
|
601
|
+
failedStage: 'cli_provenance_resolve',
|
|
602
|
+
correlationId: cliCorrelationId,
|
|
603
|
+
submissionId: submission && submission.run_id ? submission.run_id : null,
|
|
604
|
+
err: new Error('--provenance flag is required. Use --provenance=github (production) or --provenance=stub (test/dev only).'),
|
|
605
|
+
humanPrefix: 'provenance precondition unmet'
|
|
606
|
+
});
|
|
517
607
|
process.exit(2);
|
|
518
608
|
}
|
|
519
609
|
|
|
610
|
+
// D1B-001: track the last successful pipeline stage so the outer catch
|
|
611
|
+
// can surface a useful `failed_stage` in its structured error event.
|
|
612
|
+
// We update it once the verify/ingest call has RETURNED — anything
|
|
613
|
+
// thrown inside `ingest()` or `verifyOnly()` is, by definition, a
|
|
614
|
+
// pipeline-runtime failure for which the wrapper itself is the failing
|
|
615
|
+
// boundary. `cli_pipeline` is the right label there; the inner code
|
|
616
|
+
// paths that throw have already emitted their own structured rejection
|
|
617
|
+
// events (`rejected_pre_persist`, `rebuild_indexes_complete` etc.)
|
|
618
|
+
// when they could.
|
|
520
619
|
try {
|
|
521
620
|
if (verifyOnlyFlag) {
|
|
522
621
|
const result = await verifyOnly(submission, { repoRoot, provenance });
|
|
622
|
+
lastSuccessfulStage = 'verify_only';
|
|
523
623
|
|
|
524
624
|
console.log(JSON.stringify({
|
|
525
625
|
status: result.record.verification.status,
|
|
@@ -537,6 +637,7 @@ if (isMain) {
|
|
|
537
637
|
}
|
|
538
638
|
|
|
539
639
|
const result = await ingest(submission, { repoRoot, provenance });
|
|
640
|
+
lastSuccessfulStage = 'ingest';
|
|
540
641
|
|
|
541
642
|
if (result.duplicate) {
|
|
542
643
|
console.log(JSON.stringify({ status: 'duplicate', run_id: submission.run_id }));
|
|
@@ -554,7 +655,22 @@ if (isMain) {
|
|
|
554
655
|
|
|
555
656
|
process.exit(result.record.verification.status === 'accepted' ? 0 : 1);
|
|
556
657
|
} catch (err) {
|
|
557
|
-
|
|
658
|
+
// D1B-001 (Stage C humanization): emit the structured error event
|
|
659
|
+
// BEFORE exit 2 so `"stage":"error"` greps land. `failed_stage` is
|
|
660
|
+
// the last stage that DID complete — anything inside `ingest()` /
|
|
661
|
+
// `verifyOnly()` that throws has, by definition, blown the boundary
|
|
662
|
+
// we were about to cross.
|
|
663
|
+
const submissionId =
|
|
664
|
+
submission && typeof submission === 'object' && !Array.isArray(submission)
|
|
665
|
+
? (submission.run_id || null)
|
|
666
|
+
: null;
|
|
667
|
+
emitCliErrorEvent({
|
|
668
|
+
failedStage: lastSuccessfulStage,
|
|
669
|
+
correlationId: cliCorrelationId,
|
|
670
|
+
submissionId,
|
|
671
|
+
err,
|
|
672
|
+
humanPrefix: 'ingest failed'
|
|
673
|
+
});
|
|
558
674
|
process.exit(2);
|
|
559
675
|
}
|
|
560
676
|
}
|
package/validate-record.js
CHANGED
|
@@ -6,41 +6,30 @@
|
|
|
6
6
|
* gate on the outbound payload — the central verifier assembles the record
|
|
7
7
|
* and the persist layer must not write anything that violates the contract.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
9
|
+
* H3 hop 4 (LAST — the C1-sealing hop): delegates to the canonical
|
|
10
|
+
* {@link validatePayload} from `@dogfood-lab/schemas`. Pre-H3 this module
|
|
11
|
+
* compiled its own Ajv2020 + ajv-formats instance for the record schema,
|
|
12
|
+
* which was the SAME schema the verifier-side path (now also canonical)
|
|
13
|
+
* compiled in a SECOND instance — the structural root of the C1 two-Ajv
|
|
14
|
+
* gap. After H3, ingest and verify share the single cached validator the
|
|
15
|
+
* canonical seam holds for `dogfood-record.schema.json`.
|
|
16
|
+
*
|
|
17
|
+
* Contract preserved:
|
|
18
|
+
* - Throws {@link RecordValidationError} on validation failure (NOT a
|
|
19
|
+
* return value — the persist layer treats a malformed record as a
|
|
20
|
+
* programming error, not user input).
|
|
21
|
+
* - `.code === 'RECORD_SCHEMA_INVALID'` is the structural error-codes
|
|
22
|
+
* gate pin (just hardened in A2.1 FX2 — see scripts/doc-drift-patterns.json
|
|
23
|
+
* error-codes check); the migration keeps it intact.
|
|
24
|
+
* - Each `errors[]` entry carries `{ path, keyword, message, params }` —
|
|
25
|
+
* the keyword pin (packages/ingest/ingest.test.js:208-220) asserts
|
|
26
|
+
* `typeof e.keyword === 'string'` for every error. The H3 keyword
|
|
27
|
+
* extension at the canonical seam (validate.ts ValidationError) is
|
|
28
|
+
* what makes this contract preservable without ingest holding its
|
|
29
|
+
* own Ajv instance.
|
|
16
30
|
*/
|
|
17
31
|
|
|
18
|
-
import
|
|
19
|
-
import addFormats from 'ajv-formats';
|
|
20
|
-
import { readFileSync } from 'node:fs';
|
|
21
|
-
import { createRequire } from 'node:module';
|
|
22
|
-
|
|
23
|
-
const require = createRequire(import.meta.url);
|
|
24
|
-
const SCHEMA_PATH = require.resolve('@dogfood-lab/schemas/json/dogfood-record.schema.json');
|
|
25
|
-
|
|
26
|
-
let _validator = null;
|
|
27
|
-
let _loadError = null;
|
|
28
|
-
|
|
29
|
-
function getValidator() {
|
|
30
|
-
if (_validator) return _validator;
|
|
31
|
-
if (_loadError) throw _loadError;
|
|
32
|
-
|
|
33
|
-
try {
|
|
34
|
-
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
35
|
-
addFormats(ajv);
|
|
36
|
-
const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf-8'));
|
|
37
|
-
_validator = ajv.compile(schema);
|
|
38
|
-
return _validator;
|
|
39
|
-
} catch (e) {
|
|
40
|
-
_loadError = new Error(`record schema load failed: ${e.message}`);
|
|
41
|
-
throw _loadError;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
32
|
+
import { validatePayload } from '@dogfood-lab/schemas';
|
|
44
33
|
|
|
45
34
|
/**
|
|
46
35
|
* Structured error thrown when a persisted record violates the schema.
|
|
@@ -69,15 +58,20 @@ export class RecordValidationError extends Error {
|
|
|
69
58
|
* @throws {RecordValidationError}
|
|
70
59
|
*/
|
|
71
60
|
export function validateRecord(record) {
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
if (valid) return record;
|
|
61
|
+
const result = validatePayload('record', record);
|
|
62
|
+
if (result.valid) return record;
|
|
75
63
|
|
|
76
|
-
|
|
77
|
-
|
|
64
|
+
// Project canonical ValidationError → ingest's historical error shape:
|
|
65
|
+
// pre-H3 entries had `{ path, keyword, message, params }`. Canonical
|
|
66
|
+
// ships the same fields (keyword added in H3 hop 0). Re-construct
|
|
67
|
+
// explicitly so any future change to the canonical shape (extra
|
|
68
|
+
// fields, renames) trips an explicit migration here rather than
|
|
69
|
+
// silently widening RecordValidationError.errors[].
|
|
70
|
+
const errors = result.errors.map(err => ({
|
|
71
|
+
path: err.path,
|
|
78
72
|
keyword: err.keyword,
|
|
79
73
|
message: err.message,
|
|
80
|
-
params: err.params
|
|
74
|
+
params: err.params,
|
|
81
75
|
}));
|
|
82
76
|
throw new RecordValidationError(errors);
|
|
83
77
|
}
|