@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/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/lib/unsafe-segment.js
CHANGED
|
@@ -1,36 +1,36 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* unsafe-segment.js — central path-segment safety helper.
|
|
3
|
-
*
|
|
4
|
-
* Three callsites previously defined or duplicated this regex (F-916867-005):
|
|
5
|
-
* - packages/ingest/persist.js (canonical instance)
|
|
6
|
-
* - packages/ingest/load-context.js (loadRepoPolicy + githubScenarioFetcher)
|
|
7
|
-
* - packages/findings/derive/load-records.js (the missing third callsite)
|
|
8
|
-
*
|
|
9
|
-
* The check rejects path-traversal substrings (`..`) and any path separator
|
|
10
|
-
* (`/`, `\`). Single dots remain legal because GitHub permits dotted org/repo
|
|
11
|
-
* names like `next.js`, `mcp-tool-shop.github.io`, `repo.io`. The submission
|
|
12
|
-
* schema's repo pattern `^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$` agrees.
|
|
13
|
-
*
|
|
14
|
-
* F-375053-006 regression — an earlier `/[.\/]/` was over-broad and crashed
|
|
15
|
-
* legitimate submissions inside writeRecord. The narrower `/\.\.|[/\\]/` has
|
|
16
|
-
* stood since wave 9; this helper is the productized form.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Regex matching unsafe substrings in a single path segment.
|
|
21
|
-
* Use `.test(segment)` — returns true if the segment is unsafe.
|
|
22
|
-
*
|
|
23
|
-
* @type {RegExp}
|
|
24
|
-
*/
|
|
25
|
-
export const UNSAFE_SEGMENT = /\.\.|[/\\]/;
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Predicate form: returns true when the given segment contains a path-traversal
|
|
29
|
-
* substring or a path separator.
|
|
30
|
-
*
|
|
31
|
-
* @param {string} segment - A single path-segment candidate (e.g. an org or repo name).
|
|
32
|
-
* @returns {boolean}
|
|
33
|
-
*/
|
|
34
|
-
export function isUnsafeSegment(segment) {
|
|
35
|
-
return UNSAFE_SEGMENT.test(segment);
|
|
36
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* unsafe-segment.js — central path-segment safety helper.
|
|
3
|
+
*
|
|
4
|
+
* Three callsites previously defined or duplicated this regex (F-916867-005):
|
|
5
|
+
* - packages/ingest/persist.js (canonical instance)
|
|
6
|
+
* - packages/ingest/load-context.js (loadRepoPolicy + githubScenarioFetcher)
|
|
7
|
+
* - packages/findings/derive/load-records.js (the missing third callsite)
|
|
8
|
+
*
|
|
9
|
+
* The check rejects path-traversal substrings (`..`) and any path separator
|
|
10
|
+
* (`/`, `\`). Single dots remain legal because GitHub permits dotted org/repo
|
|
11
|
+
* names like `next.js`, `mcp-tool-shop.github.io`, `repo.io`. The submission
|
|
12
|
+
* schema's repo pattern `^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$` agrees.
|
|
13
|
+
*
|
|
14
|
+
* F-375053-006 regression — an earlier `/[.\/]/` was over-broad and crashed
|
|
15
|
+
* legitimate submissions inside writeRecord. The narrower `/\.\.|[/\\]/` has
|
|
16
|
+
* stood since wave 9; this helper is the productized form.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Regex matching unsafe substrings in a single path segment.
|
|
21
|
+
* Use `.test(segment)` — returns true if the segment is unsafe.
|
|
22
|
+
*
|
|
23
|
+
* @type {RegExp}
|
|
24
|
+
*/
|
|
25
|
+
export const UNSAFE_SEGMENT = /\.\.|[/\\]/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Predicate form: returns true when the given segment contains a path-traversal
|
|
29
|
+
* substring or a path separator.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} segment - A single path-segment candidate (e.g. an org or repo name).
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
export function isUnsafeSegment(segment) {
|
|
35
|
+
return UNSAFE_SEGMENT.test(segment);
|
|
36
|
+
}
|
package/load-context.js
CHANGED
|
@@ -1,131 +1,345 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Context loader
|
|
3
|
-
*
|
|
4
|
-
* Gathers everything the verifier needs:
|
|
5
|
-
* - Global policy
|
|
6
|
-
* - Repo policy (optional, missing is valid)
|
|
7
|
-
* - Scenario definitions from source repo (optional, missing becomes rejection reason)
|
|
8
|
-
* - Payload normalization
|
|
9
|
-
*
|
|
10
|
-
* Scenario loading uses a fetch adapter so it can be stubbed in tests.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
14
|
-
import { join } from 'node:path';
|
|
15
|
-
import yaml from 'js-yaml';
|
|
16
|
-
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Context loader
|
|
3
|
+
*
|
|
4
|
+
* Gathers everything the verifier needs:
|
|
5
|
+
* - Global policy
|
|
6
|
+
* - Repo policy (optional, missing is valid)
|
|
7
|
+
* - Scenario definitions from source repo (optional, missing becomes rejection reason)
|
|
8
|
+
* - Payload normalization
|
|
9
|
+
*
|
|
10
|
+
* Scenario loading uses a fetch adapter so it can be stubbed in tests.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import yaml from 'js-yaml';
|
|
16
|
+
|
|
17
|
+
import { validatePayload } from '@dogfood-lab/schemas';
|
|
18
|
+
import { logStage as sharedLogStage } from '@dogfood-lab/dogfood-swarm/lib/log-stage.js';
|
|
19
|
+
import { isUnsafeSegment } from './lib/unsafe-segment.js';
|
|
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
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Load the global policy.
|
|
52
|
+
*
|
|
53
|
+
* The global policy is REQUIRED — unlike `loadRepoPolicy` which silently
|
|
54
|
+
* returns null when a repo-specific override is absent, a missing or malformed
|
|
55
|
+
* global policy throws with a structured, operator-actionable message naming
|
|
56
|
+
* the resolved path and the failure mode (missing vs unreadable vs invalid
|
|
57
|
+
* YAML, with line/column from `yaml.YAMLException.mark` when available).
|
|
58
|
+
* Receiver workflows would otherwise crash with a raw `ENOENT` or
|
|
59
|
+
* `YAMLException` stack trace, leaving the operator to guess which file
|
|
60
|
+
* to fix.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} repoRoot
|
|
63
|
+
* @returns {object}
|
|
64
|
+
*/
|
|
65
|
+
export function loadGlobalPolicy(repoRoot) {
|
|
66
|
+
const path = join(repoRoot, 'policies', 'global-policy.yaml');
|
|
67
|
+
let raw;
|
|
68
|
+
try {
|
|
69
|
+
raw = readFileSync(path, 'utf-8');
|
|
70
|
+
} catch (e) {
|
|
71
|
+
if (e.code === 'ENOENT') {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Global policy missing: ${path}\n` +
|
|
74
|
+
`The ingest pipeline requires a global policy file. ` +
|
|
75
|
+
`Create it from policies/global-policy.example.yaml or the project README.`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
throw new Error(`Global policy unreadable: ${path} — ${e.message}`);
|
|
79
|
+
}
|
|
80
|
+
let parsed;
|
|
81
|
+
try {
|
|
82
|
+
parsed = yaml.load(raw);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
const where = e.mark ? ` at line ${e.mark.line + 1}, column ${e.mark.column + 1}` : '';
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Global policy YAML invalid: ${path}${where} — ${e.message}\n` +
|
|
87
|
+
`Fix the YAML and re-run.`
|
|
88
|
+
);
|
|
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;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
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.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} repoSlug - e.g. "mcp-tool-shop-org/dogfood-labs"
|
|
128
|
+
* @param {string} repoRoot
|
|
129
|
+
* @returns {object|null|{ __torn: true, reason: string, path: string }}
|
|
130
|
+
*/
|
|
131
|
+
export function loadRepoPolicy(repoSlug, repoRoot) {
|
|
132
|
+
const [org, repo] = repoSlug.split('/');
|
|
133
|
+
if (!org || !repo || isUnsafeSegment(org) || isUnsafeSegment(repo)) return null;
|
|
134
|
+
const path = join(repoRoot, 'policies', 'repos', org, `${repo}.yaml`);
|
|
135
|
+
|
|
136
|
+
if (!existsSync(path)) return null;
|
|
137
|
+
let parsed;
|
|
138
|
+
try {
|
|
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 };
|
|
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;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Default scenario fetcher that reads from the local filesystem.
|
|
178
|
+
* Used when dogfood-labs is dogfooding itself.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} repoRoot - Root of the source repo
|
|
181
|
+
* @returns {object} Scenario fetch adapter
|
|
182
|
+
*/
|
|
183
|
+
export function localScenarioFetcher(repoRoot) {
|
|
184
|
+
return {
|
|
185
|
+
async fetch(scenarioId) {
|
|
186
|
+
if (!/^[\w-]+$/.test(scenarioId)) return null;
|
|
187
|
+
const path = join(repoRoot, 'dogfood', 'scenarios', `${scenarioId}.yaml`);
|
|
188
|
+
if (!existsSync(path)) return null;
|
|
189
|
+
return yaml.load(readFileSync(path, 'utf-8'));
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
}
|
|
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
|
+
|
|
204
|
+
/**
|
|
205
|
+
* GitHub scenario fetcher. Loads scenario definitions from a source repo
|
|
206
|
+
* via the GitHub API at a specific commit SHA.
|
|
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
|
+
*
|
|
222
|
+
* @param {string} token - GitHub PAT
|
|
223
|
+
* @param {string} repoSlug - e.g. "mcp-tool-shop-org/shipcheck"
|
|
224
|
+
* @param {string} commitSha - Commit to fetch scenarios from
|
|
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 }> }}
|
|
227
|
+
*/
|
|
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
|
+
|
|
232
|
+
const [org, repo] = repoSlug.split('/');
|
|
233
|
+
if (!org || !repo || isUnsafeSegment(org) || isUnsafeSegment(repo)) {
|
|
234
|
+
return {
|
|
235
|
+
async fetch() { return null; },
|
|
236
|
+
async fetchWithReason() { return { scenario: null, reason: 'invalid_id' }; }
|
|
237
|
+
};
|
|
238
|
+
}
|
|
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' };
|
|
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);
|
|
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
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Load all scenario definitions referenced by a submission's scenario_results.
|
|
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
|
+
*
|
|
312
|
+
* @param {object} submission
|
|
313
|
+
* @param {object} scenarioFetcher - { fetch(scenarioId), fetchWithReason?(scenarioId) }
|
|
314
|
+
* @returns {Promise<{ scenarios: Map<string, object>, errors: string[] }>}
|
|
315
|
+
*/
|
|
316
|
+
export async function loadScenarios(submission, scenarioFetcher) {
|
|
317
|
+
const scenarios = new Map();
|
|
318
|
+
const errors = [];
|
|
319
|
+
|
|
320
|
+
const supportsTypedReason = typeof scenarioFetcher.fetchWithReason === 'function';
|
|
321
|
+
|
|
322
|
+
for (const sr of submission.scenario_results || []) {
|
|
323
|
+
const id = sr.scenario_id;
|
|
324
|
+
if (scenarios.has(id)) continue;
|
|
325
|
+
|
|
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
|
+
}
|
|
334
|
+
} else {
|
|
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
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return { scenarios, errors };
|
|
345
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dogfood-lab/ingest",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
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,12 +30,10 @@
|
|
|
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": {
|
|
37
|
-
"node": ">=
|
|
36
|
+
"node": ">=22"
|
|
38
37
|
},
|
|
39
38
|
"author": "mcp-tool-shop",
|
|
40
39
|
"license": "MIT",
|