@dogfood-lab/ingest 1.2.2 → 1.2.3
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/unsafe-segment.js +36 -36
- package/load-context.js +161 -131
- package/package.json +2 -2
- package/persist.js +172 -172
- package/rebuild-indexes.js +356 -356
- package/run.js +560 -540
- package/validate-record.js +83 -83
package/run.js
CHANGED
|
@@ -1,540 +1,560 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Ingestion orchestrator
|
|
3
|
-
*
|
|
4
|
-
* Thin glue: dispatch → load context → verifier → persist → rebuild indexes.
|
|
5
|
-
*
|
|
6
|
-
* Does NOT:
|
|
7
|
-
* - decide verdicts on its own
|
|
8
|
-
* - enforce policy outside the verifier
|
|
9
|
-
* - inspect step results beyond passing them through
|
|
10
|
-
* - mutate source-authored fields except through the verifier result
|
|
11
|
-
*
|
|
12
|
-
* Does:
|
|
13
|
-
* - parse payload
|
|
14
|
-
* - gather needed inputs
|
|
15
|
-
* - call verifier
|
|
16
|
-
* - persist output
|
|
17
|
-
* - regenerate indexes
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import { resolve, dirname } from 'node:path';
|
|
21
|
-
import { fileURLToPath } from 'node:url';
|
|
22
|
-
import { randomBytes } from 'node:crypto';
|
|
23
|
-
|
|
24
|
-
import { verify } from '@dogfood-lab/verify';
|
|
25
|
-
import { stubProvenance, githubProvenance } from '@dogfood-lab/verify/validators/provenance.js';
|
|
26
|
-
import { logStage as sharedLogStage } from '@dogfood-lab/dogfood-swarm/lib/log-stage.js';
|
|
27
|
-
import { loadGlobalPolicy, loadRepoPolicy, loadScenarios } from './load-context.js';
|
|
28
|
-
import { isDuplicate, writeRecord, computeRecordPath } from './persist.js';
|
|
29
|
-
import { rebuildIndexes } from './rebuild-indexes.js';
|
|
30
|
-
|
|
31
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Emit a single structured stage-transition log line via the shared helper.
|
|
35
|
-
*
|
|
36
|
-
* Pins `component: 'ingest'` so every ingest event is tagged regardless of
|
|
37
|
-
* caller-supplied fields. Delegates to the canonical helper at
|
|
38
|
-
* `@dogfood-lab/dogfood-swarm/lib/log-stage.js`, which adds the wave-17
|
|
39
|
-
* verdict-first human banner (TTY or DOGFOOD_LOG_HUMAN=1) on top of the
|
|
40
|
-
* NDJSON line that ingest.yml's CI log captures.
|
|
41
|
-
*
|
|
42
|
-
* Stages: dispatch_received | context_loaded | verify_complete |
|
|
43
|
-
* persist_complete | rebuild_indexes_complete | verify_only_complete |
|
|
44
|
-
* rejected_pre_persist | error.
|
|
45
|
-
*
|
|
46
|
-
* F-252714-061 (FT-PIPELINE-004): callers may include `correlation_id` in
|
|
47
|
-
* `fields` so a downstream log aggregator can pivot a multi-line NDJSON
|
|
48
|
-
* stream into a per-submission trace. The wrapper passes it through; the
|
|
49
|
-
* canonical generation site is `ingest()`/`verifyOnly()` (one ID per run).
|
|
50
|
-
*
|
|
51
|
-
* @param {string} stage
|
|
52
|
-
* @param {object} fields - Stage-specific fields. `submission_id` and
|
|
53
|
-
* `correlation_id` strongly recommended. Do NOT pass `stage` as an inner
|
|
54
|
-
* field — it would collide with the outer stage name and the spread is
|
|
55
|
-
* last-wins. For "this stage failed inside that stage" use `failed_stage`
|
|
56
|
-
* (e.g. `logStage('error', { failed_stage: 'rebuild_indexes', ... })`).
|
|
57
|
-
*/
|
|
58
|
-
function logStage(stage, fields = {}) {
|
|
59
|
-
// Defensive against F-827321-035: strip any caller-supplied `stage:`
|
|
60
|
-
// before spreading, so the positional `stage` always wins. The shared
|
|
61
|
-
// helper itself spreads fields last; without this strip, an inner
|
|
62
|
-
// `stage:` would silently overwrite the outer name and a grep of
|
|
63
|
-
// `"stage":"error"` across runner logs would miss the failure.
|
|
64
|
-
// `correlation_id` (FT-PIPELINE-004) is destructured-and-passed: it has
|
|
65
|
-
// no collision with the outer stage name, but naming it explicitly here
|
|
66
|
-
// documents the wave-22 wrapper-strip pattern's safe-field contract.
|
|
67
|
-
const { stage: _ignored, correlation_id, ...rest } = fields;
|
|
68
|
-
sharedLogStage(stage, { component: 'ingest', correlation_id, ...rest });
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Generate a synthetic correlation_id for ingests where the submission has
|
|
73
|
-
* no usable run_id (null/non-object/malformed). Format: `ing-<base36-ts>-<rand4>`.
|
|
74
|
-
*
|
|
75
|
-
* Examples: `ing-1abc234d-x7f9` — readable, sortable, distinct from real
|
|
76
|
-
* `run_id` values (which never start with the `ing-` prefix in practice).
|
|
77
|
-
*/
|
|
78
|
-
function synthCorrelationId() {
|
|
79
|
-
const ts = Date.now().toString(36);
|
|
80
|
-
const rand = randomBytes(2).toString('hex');
|
|
81
|
-
return `ing-${ts}-${rand}`;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Resolve the correlation_id for a single ingest run.
|
|
86
|
-
* Prefer `submission.run_id` (operator pivots stay on the user-meaningful
|
|
87
|
-
* key); fall back to a synthetic id for invalid/malformed submissions.
|
|
88
|
-
*/
|
|
89
|
-
function resolveCorrelationId(submission) {
|
|
90
|
-
if (submission && typeof submission === 'object' && !Array.isArray(submission)) {
|
|
91
|
-
if (typeof submission.run_id === 'string' && submission.run_id.length > 0) {
|
|
92
|
-
return submission.run_id;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
return synthCorrelationId();
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Run the full ingestion pipeline.
|
|
100
|
-
*
|
|
101
|
-
* @param {object} submission - Source-authored submission payload
|
|
102
|
-
* @param {object} options
|
|
103
|
-
* @param {string} options.repoRoot - Absolute path to dogfood-labs repo root
|
|
104
|
-
* @param {object} options.provenance - Provenance adapter (REQUIRED — no default, no implicit stub)
|
|
105
|
-
* @param {object} [options.scenarioFetcher] - Scenario fetch adapter
|
|
106
|
-
* @returns {Promise<{ record: object, path: string, written: boolean, duplicate: boolean }>}
|
|
107
|
-
*/
|
|
108
|
-
export async function ingest(submission, options) {
|
|
109
|
-
const {
|
|
110
|
-
repoRoot,
|
|
111
|
-
provenance,
|
|
112
|
-
scenarioFetcher = null
|
|
113
|
-
} = options;
|
|
114
|
-
|
|
115
|
-
// Provenance adapter is REQUIRED. No implicit stub. Fail closed.
|
|
116
|
-
if (!provenance || typeof provenance.confirm !== 'function') {
|
|
117
|
-
throw new Error(
|
|
118
|
-
'Provenance adapter is required. Use githubProvenance(token) for production ' +
|
|
119
|
-
'or stubProvenance for tests. No implicit default — fail closed.'
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const submissionIsObject = submission && typeof submission === 'object' && !Array.isArray(submission);
|
|
124
|
-
const submissionId = submissionIsObject ? (submission.run_id || null) : null;
|
|
125
|
-
const submissionRepo = submissionIsObject ? (submission.repo || null) : null;
|
|
126
|
-
|
|
127
|
-
// F-252714-061 (FT-PIPELINE-004): one correlation_id per ingest run, pinned
|
|
128
|
-
// across every stage. For valid submissions, prefer submission.run_id so
|
|
129
|
-
// operator pivots stay on the user-meaningful key; for invalid/malformed
|
|
130
|
-
// submissions (no run_id) generate a synthetic `ing-<base36-ts>-<rand4>`.
|
|
131
|
-
const correlation_id = resolveCorrelationId(submission);
|
|
132
|
-
|
|
133
|
-
logStage('dispatch_received', {
|
|
134
|
-
submission_id: submissionId,
|
|
135
|
-
correlation_id,
|
|
136
|
-
repo: submissionRepo,
|
|
137
|
-
has_scenario_results: !!(submissionIsObject && submission.scenario_results)
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
// 1. Check for duplicate before doing any work
|
|
141
|
-
// We need a minimal record shape to compute the path for duplicate check
|
|
142
|
-
// Guard against null/non-object submissions — those flow straight to verify()
|
|
143
|
-
// which produces a rejection record marked _skipPersist.
|
|
144
|
-
if (submissionIsObject && submission.run_id && submission.repo && submission.timing?.finished_at) {
|
|
145
|
-
const probeRecord = {
|
|
146
|
-
run_id: submission.run_id,
|
|
147
|
-
repo: submission.repo,
|
|
148
|
-
timing: submission.timing,
|
|
149
|
-
verification: { status: 'accepted' }
|
|
150
|
-
};
|
|
151
|
-
if (isDuplicate(submission.run_id, probeRecord, repoRoot)) {
|
|
152
|
-
logStage('rejected_pre_persist', {
|
|
153
|
-
submission_id: submissionId,
|
|
154
|
-
correlation_id,
|
|
155
|
-
reason: 'duplicate'
|
|
156
|
-
});
|
|
157
|
-
return {
|
|
158
|
-
record: null,
|
|
159
|
-
path: null,
|
|
160
|
-
written: false,
|
|
161
|
-
duplicate: true
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// 2. Load context
|
|
167
|
-
const globalPolicy = loadGlobalPolicy(repoRoot);
|
|
168
|
-
const repoPolicy = loadRepoPolicy(submissionIsObject ? (submission.repo || '') : '', repoRoot);
|
|
169
|
-
const policyVersion = repoPolicy?.policy_version || globalPolicy.policy_version || '1.0.0';
|
|
170
|
-
|
|
171
|
-
logStage('context_loaded', {
|
|
172
|
-
submission_id: submissionId,
|
|
173
|
-
correlation_id,
|
|
174
|
-
policy_version: policyVersion,
|
|
175
|
-
repo_policy_present: !!repoPolicy
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
// 3. Load scenario definitions (non-fatal if missing — becomes rejection reason)
|
|
179
|
-
let scenarioErrors = [];
|
|
180
|
-
if (scenarioFetcher && submissionIsObject && submission.scenario_results) {
|
|
181
|
-
const result = await loadScenarios(submission, scenarioFetcher);
|
|
182
|
-
scenarioErrors = result.errors;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// 4. Call verifier — the law engine makes all decisions
|
|
186
|
-
const record = await verify(submission, {
|
|
187
|
-
globalPolicy,
|
|
188
|
-
repoPolicy,
|
|
189
|
-
provenance,
|
|
190
|
-
policyVersion
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
logStage('verify_complete', {
|
|
194
|
-
submission_id: submissionId,
|
|
195
|
-
correlation_id,
|
|
196
|
-
status: record.verification?.status ?? null,
|
|
197
|
-
rejection_reason_count: record.verification?.rejection_reasons?.length ?? 0,
|
|
198
|
-
verdict: record.overall_verdict?.verified ?? null
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
// 4b. Append scenario loading errors to rejection reasons if any
|
|
202
|
-
if (scenarioErrors.length > 0) {
|
|
203
|
-
record.verification.rejection_reasons.push(
|
|
204
|
-
...scenarioErrors.map(e => `scenario-load: ${e}`)
|
|
205
|
-
);
|
|
206
|
-
// If scenario loading failed, this is a rejection
|
|
207
|
-
if (record.verification.status === 'accepted' && scenarioErrors.length > 0) {
|
|
208
|
-
record.verification.status = 'rejected';
|
|
209
|
-
record.verification.policy_valid = false;
|
|
210
|
-
// Downgrade verdict if needed
|
|
211
|
-
if (record.overall_verdict.verified === 'pass') {
|
|
212
|
-
record.overall_verdict.verified = 'fail';
|
|
213
|
-
record.overall_verdict.downgraded = true;
|
|
214
|
-
if (!record.overall_verdict.downgrade_reasons) {
|
|
215
|
-
record.overall_verdict.downgrade_reasons = [];
|
|
216
|
-
}
|
|
217
|
-
record.overall_verdict.downgrade_reasons.push('scenario definitions could not be loaded');
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// 5. Persist record
|
|
223
|
-
// Verifier marks _skipPersist when input was null/non-object — the stub record
|
|
224
|
-
// lacks repo/run_id/timing.finished_at and would crash computeRecordPath().
|
|
225
|
-
// Surface the structured rejection cleanly without writing.
|
|
226
|
-
if (record._skipPersist) {
|
|
227
|
-
delete record._skipPersist;
|
|
228
|
-
logStage('rejected_pre_persist', {
|
|
229
|
-
submission_id: submissionId,
|
|
230
|
-
correlation_id,
|
|
231
|
-
reason: 'skip_persist',
|
|
232
|
-
rejection_reasons: record.verification?.rejection_reasons ?? []
|
|
233
|
-
});
|
|
234
|
-
return { record, path: null, written: false, duplicate: false };
|
|
235
|
-
}
|
|
236
|
-
const persistStart = Date.now();
|
|
237
|
-
const { path, written } = writeRecord(record, repoRoot);
|
|
238
|
-
logStage('persist_complete', {
|
|
239
|
-
submission_id: submissionId,
|
|
240
|
-
correlation_id,
|
|
241
|
-
path,
|
|
242
|
-
written,
|
|
243
|
-
duplicate: !written,
|
|
244
|
-
duration_ms: Date.now() - persistStart
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
// 6. Rebuild indexes
|
|
248
|
-
if (written) {
|
|
249
|
-
const rebuildStart = Date.now();
|
|
250
|
-
try {
|
|
251
|
-
const indexResult = rebuildIndexes(repoRoot);
|
|
252
|
-
logStage('rebuild_indexes_complete', {
|
|
253
|
-
submission_id: submissionId,
|
|
254
|
-
correlation_id,
|
|
255
|
-
duration_ms: Date.now() - rebuildStart,
|
|
256
|
-
accepted: indexResult.accepted,
|
|
257
|
-
rejected: indexResult.rejected,
|
|
258
|
-
corrupted_count: indexResult.corrupted?.length ?? 0
|
|
259
|
-
});
|
|
260
|
-
} catch (err) {
|
|
261
|
-
// failed_stage (not stage) — outer stage='error' must survive the
|
|
262
|
-
// spread inside the shared logStage helper. F-827321-035: an inner
|
|
263
|
-
// `stage:` field overwrites the outer name, hiding the error event
|
|
264
|
-
// from any `"stage":"error"` grep across the runner log.
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
//
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
if (
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
if (
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Ingestion orchestrator
|
|
3
|
+
*
|
|
4
|
+
* Thin glue: dispatch → load context → verifier → persist → rebuild indexes.
|
|
5
|
+
*
|
|
6
|
+
* Does NOT:
|
|
7
|
+
* - decide verdicts on its own
|
|
8
|
+
* - enforce policy outside the verifier
|
|
9
|
+
* - inspect step results beyond passing them through
|
|
10
|
+
* - mutate source-authored fields except through the verifier result
|
|
11
|
+
*
|
|
12
|
+
* Does:
|
|
13
|
+
* - parse payload
|
|
14
|
+
* - gather needed inputs
|
|
15
|
+
* - call verifier
|
|
16
|
+
* - persist output
|
|
17
|
+
* - regenerate indexes
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { resolve, dirname } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { randomBytes } from 'node:crypto';
|
|
23
|
+
|
|
24
|
+
import { verify } from '@dogfood-lab/verify';
|
|
25
|
+
import { stubProvenance, githubProvenance } from '@dogfood-lab/verify/validators/provenance.js';
|
|
26
|
+
import { logStage as sharedLogStage } from '@dogfood-lab/dogfood-swarm/lib/log-stage.js';
|
|
27
|
+
import { loadGlobalPolicy, loadRepoPolicy, loadScenarios } from './load-context.js';
|
|
28
|
+
import { isDuplicate, writeRecord, computeRecordPath } from './persist.js';
|
|
29
|
+
import { rebuildIndexes } from './rebuild-indexes.js';
|
|
30
|
+
|
|
31
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Emit a single structured stage-transition log line via the shared helper.
|
|
35
|
+
*
|
|
36
|
+
* Pins `component: 'ingest'` so every ingest event is tagged regardless of
|
|
37
|
+
* caller-supplied fields. Delegates to the canonical helper at
|
|
38
|
+
* `@dogfood-lab/dogfood-swarm/lib/log-stage.js`, which adds the wave-17
|
|
39
|
+
* verdict-first human banner (TTY or DOGFOOD_LOG_HUMAN=1) on top of the
|
|
40
|
+
* NDJSON line that ingest.yml's CI log captures.
|
|
41
|
+
*
|
|
42
|
+
* Stages: dispatch_received | context_loaded | verify_complete |
|
|
43
|
+
* persist_complete | rebuild_indexes_complete | verify_only_complete |
|
|
44
|
+
* rejected_pre_persist | error.
|
|
45
|
+
*
|
|
46
|
+
* F-252714-061 (FT-PIPELINE-004): callers may include `correlation_id` in
|
|
47
|
+
* `fields` so a downstream log aggregator can pivot a multi-line NDJSON
|
|
48
|
+
* stream into a per-submission trace. The wrapper passes it through; the
|
|
49
|
+
* canonical generation site is `ingest()`/`verifyOnly()` (one ID per run).
|
|
50
|
+
*
|
|
51
|
+
* @param {string} stage
|
|
52
|
+
* @param {object} fields - Stage-specific fields. `submission_id` and
|
|
53
|
+
* `correlation_id` strongly recommended. Do NOT pass `stage` as an inner
|
|
54
|
+
* field — it would collide with the outer stage name and the spread is
|
|
55
|
+
* last-wins. For "this stage failed inside that stage" use `failed_stage`
|
|
56
|
+
* (e.g. `logStage('error', { failed_stage: 'rebuild_indexes', ... })`).
|
|
57
|
+
*/
|
|
58
|
+
function logStage(stage, fields = {}) {
|
|
59
|
+
// Defensive against F-827321-035: strip any caller-supplied `stage:`
|
|
60
|
+
// before spreading, so the positional `stage` always wins. The shared
|
|
61
|
+
// helper itself spreads fields last; without this strip, an inner
|
|
62
|
+
// `stage:` would silently overwrite the outer name and a grep of
|
|
63
|
+
// `"stage":"error"` across runner logs would miss the failure.
|
|
64
|
+
// `correlation_id` (FT-PIPELINE-004) is destructured-and-passed: it has
|
|
65
|
+
// no collision with the outer stage name, but naming it explicitly here
|
|
66
|
+
// documents the wave-22 wrapper-strip pattern's safe-field contract.
|
|
67
|
+
const { stage: _ignored, correlation_id, ...rest } = fields;
|
|
68
|
+
sharedLogStage(stage, { component: 'ingest', correlation_id, ...rest });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Generate a synthetic correlation_id for ingests where the submission has
|
|
73
|
+
* no usable run_id (null/non-object/malformed). Format: `ing-<base36-ts>-<rand4>`.
|
|
74
|
+
*
|
|
75
|
+
* Examples: `ing-1abc234d-x7f9` — readable, sortable, distinct from real
|
|
76
|
+
* `run_id` values (which never start with the `ing-` prefix in practice).
|
|
77
|
+
*/
|
|
78
|
+
function synthCorrelationId() {
|
|
79
|
+
const ts = Date.now().toString(36);
|
|
80
|
+
const rand = randomBytes(2).toString('hex');
|
|
81
|
+
return `ing-${ts}-${rand}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolve the correlation_id for a single ingest run.
|
|
86
|
+
* Prefer `submission.run_id` (operator pivots stay on the user-meaningful
|
|
87
|
+
* key); fall back to a synthetic id for invalid/malformed submissions.
|
|
88
|
+
*/
|
|
89
|
+
function resolveCorrelationId(submission) {
|
|
90
|
+
if (submission && typeof submission === 'object' && !Array.isArray(submission)) {
|
|
91
|
+
if (typeof submission.run_id === 'string' && submission.run_id.length > 0) {
|
|
92
|
+
return submission.run_id;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return synthCorrelationId();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Run the full ingestion pipeline.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} submission - Source-authored submission payload
|
|
102
|
+
* @param {object} options
|
|
103
|
+
* @param {string} options.repoRoot - Absolute path to dogfood-labs repo root
|
|
104
|
+
* @param {object} options.provenance - Provenance adapter (REQUIRED — no default, no implicit stub)
|
|
105
|
+
* @param {object} [options.scenarioFetcher] - Scenario fetch adapter
|
|
106
|
+
* @returns {Promise<{ record: object, path: string, written: boolean, duplicate: boolean }>}
|
|
107
|
+
*/
|
|
108
|
+
export async function ingest(submission, options) {
|
|
109
|
+
const {
|
|
110
|
+
repoRoot,
|
|
111
|
+
provenance,
|
|
112
|
+
scenarioFetcher = null
|
|
113
|
+
} = options;
|
|
114
|
+
|
|
115
|
+
// Provenance adapter is REQUIRED. No implicit stub. Fail closed.
|
|
116
|
+
if (!provenance || typeof provenance.confirm !== 'function') {
|
|
117
|
+
throw new Error(
|
|
118
|
+
'Provenance adapter is required. Use githubProvenance(token) for production ' +
|
|
119
|
+
'or stubProvenance for tests. No implicit default — fail closed.'
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const submissionIsObject = submission && typeof submission === 'object' && !Array.isArray(submission);
|
|
124
|
+
const submissionId = submissionIsObject ? (submission.run_id || null) : null;
|
|
125
|
+
const submissionRepo = submissionIsObject ? (submission.repo || null) : null;
|
|
126
|
+
|
|
127
|
+
// F-252714-061 (FT-PIPELINE-004): one correlation_id per ingest run, pinned
|
|
128
|
+
// across every stage. For valid submissions, prefer submission.run_id so
|
|
129
|
+
// operator pivots stay on the user-meaningful key; for invalid/malformed
|
|
130
|
+
// submissions (no run_id) generate a synthetic `ing-<base36-ts>-<rand4>`.
|
|
131
|
+
const correlation_id = resolveCorrelationId(submission);
|
|
132
|
+
|
|
133
|
+
logStage('dispatch_received', {
|
|
134
|
+
submission_id: submissionId,
|
|
135
|
+
correlation_id,
|
|
136
|
+
repo: submissionRepo,
|
|
137
|
+
has_scenario_results: !!(submissionIsObject && submission.scenario_results)
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// 1. Check for duplicate before doing any work
|
|
141
|
+
// We need a minimal record shape to compute the path for duplicate check
|
|
142
|
+
// Guard against null/non-object submissions — those flow straight to verify()
|
|
143
|
+
// which produces a rejection record marked _skipPersist.
|
|
144
|
+
if (submissionIsObject && submission.run_id && submission.repo && submission.timing?.finished_at) {
|
|
145
|
+
const probeRecord = {
|
|
146
|
+
run_id: submission.run_id,
|
|
147
|
+
repo: submission.repo,
|
|
148
|
+
timing: submission.timing,
|
|
149
|
+
verification: { status: 'accepted' }
|
|
150
|
+
};
|
|
151
|
+
if (isDuplicate(submission.run_id, probeRecord, repoRoot)) {
|
|
152
|
+
logStage('rejected_pre_persist', {
|
|
153
|
+
submission_id: submissionId,
|
|
154
|
+
correlation_id,
|
|
155
|
+
reason: 'duplicate'
|
|
156
|
+
});
|
|
157
|
+
return {
|
|
158
|
+
record: null,
|
|
159
|
+
path: null,
|
|
160
|
+
written: false,
|
|
161
|
+
duplicate: true
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 2. Load context
|
|
167
|
+
const globalPolicy = loadGlobalPolicy(repoRoot);
|
|
168
|
+
const repoPolicy = loadRepoPolicy(submissionIsObject ? (submission.repo || '') : '', repoRoot);
|
|
169
|
+
const policyVersion = repoPolicy?.policy_version || globalPolicy.policy_version || '1.0.0';
|
|
170
|
+
|
|
171
|
+
logStage('context_loaded', {
|
|
172
|
+
submission_id: submissionId,
|
|
173
|
+
correlation_id,
|
|
174
|
+
policy_version: policyVersion,
|
|
175
|
+
repo_policy_present: !!repoPolicy
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// 3. Load scenario definitions (non-fatal if missing — becomes rejection reason)
|
|
179
|
+
let scenarioErrors = [];
|
|
180
|
+
if (scenarioFetcher && submissionIsObject && submission.scenario_results) {
|
|
181
|
+
const result = await loadScenarios(submission, scenarioFetcher);
|
|
182
|
+
scenarioErrors = result.errors;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 4. Call verifier — the law engine makes all decisions
|
|
186
|
+
const record = await verify(submission, {
|
|
187
|
+
globalPolicy,
|
|
188
|
+
repoPolicy,
|
|
189
|
+
provenance,
|
|
190
|
+
policyVersion
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
logStage('verify_complete', {
|
|
194
|
+
submission_id: submissionId,
|
|
195
|
+
correlation_id,
|
|
196
|
+
status: record.verification?.status ?? null,
|
|
197
|
+
rejection_reason_count: record.verification?.rejection_reasons?.length ?? 0,
|
|
198
|
+
verdict: record.overall_verdict?.verified ?? null
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// 4b. Append scenario loading errors to rejection reasons if any
|
|
202
|
+
if (scenarioErrors.length > 0) {
|
|
203
|
+
record.verification.rejection_reasons.push(
|
|
204
|
+
...scenarioErrors.map(e => `scenario-load: ${e}`)
|
|
205
|
+
);
|
|
206
|
+
// If scenario loading failed, this is a rejection
|
|
207
|
+
if (record.verification.status === 'accepted' && scenarioErrors.length > 0) {
|
|
208
|
+
record.verification.status = 'rejected';
|
|
209
|
+
record.verification.policy_valid = false;
|
|
210
|
+
// Downgrade verdict if needed
|
|
211
|
+
if (record.overall_verdict.verified === 'pass') {
|
|
212
|
+
record.overall_verdict.verified = 'fail';
|
|
213
|
+
record.overall_verdict.downgraded = true;
|
|
214
|
+
if (!record.overall_verdict.downgrade_reasons) {
|
|
215
|
+
record.overall_verdict.downgrade_reasons = [];
|
|
216
|
+
}
|
|
217
|
+
record.overall_verdict.downgrade_reasons.push('scenario definitions could not be loaded');
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 5. Persist record
|
|
223
|
+
// Verifier marks _skipPersist when input was null/non-object — the stub record
|
|
224
|
+
// lacks repo/run_id/timing.finished_at and would crash computeRecordPath().
|
|
225
|
+
// Surface the structured rejection cleanly without writing.
|
|
226
|
+
if (record._skipPersist) {
|
|
227
|
+
delete record._skipPersist;
|
|
228
|
+
logStage('rejected_pre_persist', {
|
|
229
|
+
submission_id: submissionId,
|
|
230
|
+
correlation_id,
|
|
231
|
+
reason: 'skip_persist',
|
|
232
|
+
rejection_reasons: record.verification?.rejection_reasons ?? []
|
|
233
|
+
});
|
|
234
|
+
return { record, path: null, written: false, duplicate: false };
|
|
235
|
+
}
|
|
236
|
+
const persistStart = Date.now();
|
|
237
|
+
const { path, written } = writeRecord(record, repoRoot);
|
|
238
|
+
logStage('persist_complete', {
|
|
239
|
+
submission_id: submissionId,
|
|
240
|
+
correlation_id,
|
|
241
|
+
path,
|
|
242
|
+
written,
|
|
243
|
+
duplicate: !written,
|
|
244
|
+
duration_ms: Date.now() - persistStart
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// 6. Rebuild indexes
|
|
248
|
+
if (written) {
|
|
249
|
+
const rebuildStart = Date.now();
|
|
250
|
+
try {
|
|
251
|
+
const indexResult = rebuildIndexes(repoRoot);
|
|
252
|
+
logStage('rebuild_indexes_complete', {
|
|
253
|
+
submission_id: submissionId,
|
|
254
|
+
correlation_id,
|
|
255
|
+
duration_ms: Date.now() - rebuildStart,
|
|
256
|
+
accepted: indexResult.accepted,
|
|
257
|
+
rejected: indexResult.rejected,
|
|
258
|
+
corrupted_count: indexResult.corrupted?.length ?? 0
|
|
259
|
+
});
|
|
260
|
+
} catch (err) {
|
|
261
|
+
// failed_stage (not stage) — outer stage='error' must survive the
|
|
262
|
+
// spread inside the shared logStage helper. F-827321-035: an inner
|
|
263
|
+
// `stage:` field overwrites the outer name, hiding the error event
|
|
264
|
+
// from any `"stage":"error"` grep across the runner log.
|
|
265
|
+
//
|
|
266
|
+
// `rebuildIndexes()` is called as one unit — there is no partial
|
|
267
|
+
// `indexResult` to surface from this catch (counts only exist on the
|
|
268
|
+
// success path above). The structured event surfaces what the operator
|
|
269
|
+
// actually needs: throw site (stack, truncated), where the record
|
|
270
|
+
// landed, and the recovery path. The console warning mirrors the same
|
|
271
|
+
// shape so log-only readers get the same actionable hint.
|
|
272
|
+
const truncatedStack = err.stack
|
|
273
|
+
? err.stack.split('\n').slice(0, 20).join('\n')
|
|
274
|
+
: null;
|
|
275
|
+
const stackPreview = err.stack
|
|
276
|
+
? err.stack.split('\n').slice(0, 5).join(' / ')
|
|
277
|
+
: 'n/a';
|
|
278
|
+
logStage('error', {
|
|
279
|
+
submission_id: submissionId,
|
|
280
|
+
correlation_id,
|
|
281
|
+
failed_stage: 'rebuild_indexes',
|
|
282
|
+
message: err.message,
|
|
283
|
+
stack: truncatedStack,
|
|
284
|
+
record_persisted_at: path,
|
|
285
|
+
recovery: 'next ingest will trigger a full rebuild of indexes/'
|
|
286
|
+
});
|
|
287
|
+
console.error(
|
|
288
|
+
`WARNING: record persisted at ${path}, but index rebuild failed: ${err.message}\n` +
|
|
289
|
+
` indexes/ may be stale until next ingest. To force rebuild now, re-run any test ingest.\n` +
|
|
290
|
+
` stack: ${stackPreview}`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return { record, path, written, duplicate: false };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Run the verify-only pipeline: steps 0-4 (load context + verify), assemble
|
|
300
|
+
* the would-be record, return it WITHOUT touching the filesystem or rebuilding
|
|
301
|
+
* indexes. Surfaces what `ingest()` WOULD have persisted plus `would_persist_to`
|
|
302
|
+
* — the path where the record would have landed.
|
|
303
|
+
*
|
|
304
|
+
* F-252714-058 (FT-PIPELINE-001): the verify pipeline already has a
|
|
305
|
+
* `_skipPersist` internal sentinel for null/non-object inputs; this function
|
|
306
|
+
* generalizes that path into a public entrypoint operators can use to dry-run
|
|
307
|
+
* any submission without side effects.
|
|
308
|
+
*
|
|
309
|
+
* Same logStage events fire as a real ingest EXCEPT `persist_complete` and
|
|
310
|
+
* `rebuild_indexes_complete` (which would lie about persistence). A
|
|
311
|
+
* `verify_only_complete` event takes their place so CI logs read coherently.
|
|
312
|
+
*
|
|
313
|
+
* @param {object} submission - Source-authored submission payload
|
|
314
|
+
* @param {object} options
|
|
315
|
+
* @param {string} options.repoRoot - Absolute path to repo root (still
|
|
316
|
+
* needed for policy + scenario lookup)
|
|
317
|
+
* @param {object} options.provenance - Provenance adapter (REQUIRED)
|
|
318
|
+
* @param {object} [options.scenarioFetcher] - Scenario fetch adapter
|
|
319
|
+
* @returns {Promise<{
|
|
320
|
+
* record: object,
|
|
321
|
+
* would_persist_to: string|null,
|
|
322
|
+
* verify_only: true
|
|
323
|
+
* }>}
|
|
324
|
+
*/
|
|
325
|
+
export async function verifyOnly(submission, options) {
|
|
326
|
+
const {
|
|
327
|
+
repoRoot,
|
|
328
|
+
provenance,
|
|
329
|
+
scenarioFetcher = null
|
|
330
|
+
} = options;
|
|
331
|
+
|
|
332
|
+
// Provenance adapter is REQUIRED. Same fail-closed contract as ingest().
|
|
333
|
+
if (!provenance || typeof provenance.confirm !== 'function') {
|
|
334
|
+
throw new Error(
|
|
335
|
+
'Provenance adapter is required. Use githubProvenance(token) for production ' +
|
|
336
|
+
'or stubProvenance for tests. No implicit default — fail closed.'
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const submissionIsObject = submission && typeof submission === 'object' && !Array.isArray(submission);
|
|
341
|
+
const submissionId = submissionIsObject ? (submission.run_id || null) : null;
|
|
342
|
+
const submissionRepo = submissionIsObject ? (submission.repo || null) : null;
|
|
343
|
+
const correlation_id = resolveCorrelationId(submission);
|
|
344
|
+
|
|
345
|
+
logStage('dispatch_received', {
|
|
346
|
+
submission_id: submissionId,
|
|
347
|
+
correlation_id,
|
|
348
|
+
repo: submissionRepo,
|
|
349
|
+
has_scenario_results: !!(submissionIsObject && submission.scenario_results),
|
|
350
|
+
verify_only: true
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// 2. Load context (verify-only still needs policy to drive the verifier)
|
|
354
|
+
const globalPolicy = loadGlobalPolicy(repoRoot);
|
|
355
|
+
const repoPolicy = loadRepoPolicy(submissionIsObject ? (submission.repo || '') : '', repoRoot);
|
|
356
|
+
const policyVersion = repoPolicy?.policy_version || globalPolicy.policy_version || '1.0.0';
|
|
357
|
+
|
|
358
|
+
logStage('context_loaded', {
|
|
359
|
+
submission_id: submissionId,
|
|
360
|
+
correlation_id,
|
|
361
|
+
policy_version: policyVersion,
|
|
362
|
+
repo_policy_present: !!repoPolicy
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// 3. Load scenario definitions (non-fatal — becomes rejection reason)
|
|
366
|
+
let scenarioErrors = [];
|
|
367
|
+
if (scenarioFetcher && submissionIsObject && submission.scenario_results) {
|
|
368
|
+
const result = await loadScenarios(submission, scenarioFetcher);
|
|
369
|
+
scenarioErrors = result.errors;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// 4. Call verifier
|
|
373
|
+
const record = await verify(submission, {
|
|
374
|
+
globalPolicy,
|
|
375
|
+
repoPolicy,
|
|
376
|
+
provenance,
|
|
377
|
+
policyVersion
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
logStage('verify_complete', {
|
|
381
|
+
submission_id: submissionId,
|
|
382
|
+
correlation_id,
|
|
383
|
+
status: record.verification?.status ?? null,
|
|
384
|
+
rejection_reason_count: record.verification?.rejection_reasons?.length ?? 0,
|
|
385
|
+
verdict: record.overall_verdict?.verified ?? null
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// 4b. Mirror ingest's scenario-error verdict downgrade so verify-only and
|
|
389
|
+
// real ingest produce identical records for the same submission.
|
|
390
|
+
if (scenarioErrors.length > 0) {
|
|
391
|
+
record.verification.rejection_reasons.push(
|
|
392
|
+
...scenarioErrors.map(e => `scenario-load: ${e}`)
|
|
393
|
+
);
|
|
394
|
+
if (record.verification.status === 'accepted' && scenarioErrors.length > 0) {
|
|
395
|
+
record.verification.status = 'rejected';
|
|
396
|
+
record.verification.policy_valid = false;
|
|
397
|
+
if (record.overall_verdict.verified === 'pass') {
|
|
398
|
+
record.overall_verdict.verified = 'fail';
|
|
399
|
+
record.overall_verdict.downgraded = true;
|
|
400
|
+
if (!record.overall_verdict.downgrade_reasons) {
|
|
401
|
+
record.overall_verdict.downgrade_reasons = [];
|
|
402
|
+
}
|
|
403
|
+
record.overall_verdict.downgrade_reasons.push('scenario definitions could not be loaded');
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// 5. Compute would_persist_to without writing.
|
|
409
|
+
// `_skipPersist` records lack the fields needed by computeRecordPath()
|
|
410
|
+
// (repo, run_id, timing.finished_at). Surface null in that case — same
|
|
411
|
+
// semantic as the real-ingest `rejected_pre_persist` branch.
|
|
412
|
+
let would_persist_to = null;
|
|
413
|
+
if (record._skipPersist) {
|
|
414
|
+
delete record._skipPersist;
|
|
415
|
+
} else {
|
|
416
|
+
try {
|
|
417
|
+
would_persist_to = computeRecordPath(record, repoRoot);
|
|
418
|
+
} catch {
|
|
419
|
+
// Defensive: if a record passes verify() but still trips path
|
|
420
|
+
// computation (e.g., a future schema with looser constraints), keep
|
|
421
|
+
// verify-only side-effect-free. Real ingest would surface the throw
|
|
422
|
+
// via writeRecord; verify-only just returns null and lets the operator
|
|
423
|
+
// see the rejection in record.verification.rejection_reasons.
|
|
424
|
+
would_persist_to = null;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
logStage('verify_only_complete', {
|
|
429
|
+
submission_id: submissionId,
|
|
430
|
+
correlation_id,
|
|
431
|
+
status: record.verification?.status ?? null,
|
|
432
|
+
would_persist_to
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
return { record, would_persist_to, verify_only: true };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// --- CLI entrypoint ---
|
|
439
|
+
// When run directly, reads submission from stdin or file argument
|
|
440
|
+
|
|
441
|
+
const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(__dirname, 'run.js');
|
|
442
|
+
|
|
443
|
+
if (isMain) {
|
|
444
|
+
const args = process.argv.slice(2);
|
|
445
|
+
const repoRoot = resolve(__dirname, '../..');
|
|
446
|
+
|
|
447
|
+
// Parse CLI flags
|
|
448
|
+
let submissionJson;
|
|
449
|
+
let provenanceMode = null;
|
|
450
|
+
let verifyOnlyFlag = false;
|
|
451
|
+
const positionalArgs = [];
|
|
452
|
+
|
|
453
|
+
for (let i = 0; i < args.length; i++) {
|
|
454
|
+
if (args[i] === '--provenance' && args[i + 1]) {
|
|
455
|
+
provenanceMode = args[++i];
|
|
456
|
+
} else if (args[i] === '--file' && args[i + 1]) {
|
|
457
|
+
const { readFileSync } = await import('node:fs');
|
|
458
|
+
submissionJson = readFileSync(resolve(args[++i]), 'utf-8');
|
|
459
|
+
} else if (args[i] === '--payload' && args[i + 1]) {
|
|
460
|
+
submissionJson = args[++i];
|
|
461
|
+
} else if (args[i] === '--verify-only') {
|
|
462
|
+
// F-252714-058: dry-run the pipeline without writing or rebuilding
|
|
463
|
+
// indexes. CI / operators preview what WOULD have been persisted.
|
|
464
|
+
verifyOnlyFlag = true;
|
|
465
|
+
} else {
|
|
466
|
+
positionalArgs.push(args[i]);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (!submissionJson) {
|
|
471
|
+
// Read from stdin
|
|
472
|
+
const chunks = [];
|
|
473
|
+
for await (const chunk of process.stdin) {
|
|
474
|
+
chunks.push(chunk);
|
|
475
|
+
}
|
|
476
|
+
submissionJson = Buffer.concat(chunks).toString('utf-8');
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
let submission;
|
|
480
|
+
try {
|
|
481
|
+
submission = JSON.parse(submissionJson);
|
|
482
|
+
if (typeof submission === 'string') {
|
|
483
|
+
submission = JSON.parse(submission);
|
|
484
|
+
}
|
|
485
|
+
} catch (err) {
|
|
486
|
+
console.error(`ERROR: invalid JSON payload: ${err.message}`);
|
|
487
|
+
process.exit(2);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Resolve provenance adapter — explicit, never implicit
|
|
491
|
+
let provenance;
|
|
492
|
+
if (provenanceMode === 'stub') {
|
|
493
|
+
// Structural anti-misuse: stub only allowed outside CI
|
|
494
|
+
if (process.env.CI || process.env.GITHUB_ACTIONS) {
|
|
495
|
+
console.error('ERROR: --provenance=stub is not allowed in CI/production. Use --provenance=github.');
|
|
496
|
+
process.exit(2);
|
|
497
|
+
}
|
|
498
|
+
console.error('WARNING: Using stub provenance (test/dev only). Records will NOT have real provenance verification.');
|
|
499
|
+
provenance = stubProvenance;
|
|
500
|
+
} else if (provenanceMode === 'github') {
|
|
501
|
+
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
502
|
+
if (!token) {
|
|
503
|
+
console.error('ERROR: --provenance=github requires GITHUB_TOKEN or GH_TOKEN environment variable.');
|
|
504
|
+
process.exit(2);
|
|
505
|
+
}
|
|
506
|
+
provenance = githubProvenance(token);
|
|
507
|
+
} else if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') {
|
|
508
|
+
// In CI without explicit flag: default to github provenance, fail if no token
|
|
509
|
+
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
510
|
+
if (!token) {
|
|
511
|
+
console.error('ERROR: Running in CI without --provenance flag and no GITHUB_TOKEN. Cannot verify provenance.');
|
|
512
|
+
process.exit(2);
|
|
513
|
+
}
|
|
514
|
+
provenance = githubProvenance(token);
|
|
515
|
+
} else {
|
|
516
|
+
console.error('ERROR: --provenance flag is required. Use --provenance=github (production) or --provenance=stub (test/dev only).');
|
|
517
|
+
process.exit(2);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
try {
|
|
521
|
+
if (verifyOnlyFlag) {
|
|
522
|
+
const result = await verifyOnly(submission, { repoRoot, provenance });
|
|
523
|
+
|
|
524
|
+
console.log(JSON.stringify({
|
|
525
|
+
status: result.record.verification.status,
|
|
526
|
+
run_id: result.record.run_id ?? null,
|
|
527
|
+
verdict: result.record.overall_verdict?.verified ?? null,
|
|
528
|
+
would_persist_to: result.would_persist_to,
|
|
529
|
+
verify_only: true,
|
|
530
|
+
rejection_reasons: result.record.verification.rejection_reasons ?? []
|
|
531
|
+
}));
|
|
532
|
+
|
|
533
|
+
// Same accepted/rejected exit-code contract as a real ingest so CI
|
|
534
|
+
// wrappers can swap `--verify-only` in/out without changing their
|
|
535
|
+
// exit-code handling.
|
|
536
|
+
process.exit(result.record.verification.status === 'accepted' ? 0 : 1);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const result = await ingest(submission, { repoRoot, provenance });
|
|
540
|
+
|
|
541
|
+
if (result.duplicate) {
|
|
542
|
+
console.log(JSON.stringify({ status: 'duplicate', run_id: submission.run_id }));
|
|
543
|
+
process.exit(0);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
console.log(JSON.stringify({
|
|
547
|
+
status: result.record.verification.status,
|
|
548
|
+
run_id: result.record.run_id ?? null,
|
|
549
|
+
verdict: result.record.overall_verdict?.verified ?? null,
|
|
550
|
+
path: result.path,
|
|
551
|
+
written: result.written,
|
|
552
|
+
rejection_reasons: result.record.verification.rejection_reasons ?? []
|
|
553
|
+
}));
|
|
554
|
+
|
|
555
|
+
process.exit(result.record.verification.status === 'accepted' ? 0 : 1);
|
|
556
|
+
} catch (err) {
|
|
557
|
+
console.error(`ERROR: ingest failed: ${err.message}`);
|
|
558
|
+
process.exit(2);
|
|
559
|
+
}
|
|
560
|
+
}
|