ruvnet-brain 4.3.35 → 4.3.37
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/README.md +2 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +6 -1
- package/plugin/hooks/hook-contracts.json +6 -4
- package/plugin/scripts/continuity-hook-policy.mjs +10 -3
- package/plugin/scripts/coverage-integrity.mjs +58 -2
- package/plugin/scripts/hook-shim.mjs +3 -1
- package/plugin/scripts/session-snapshot-hook.mjs +11 -1
- package/plugin/scripts/turn-outcome-capture.mjs +292 -0
- package/scripts/build-bundle.mjs +27 -1
- package/scripts/code-release-corpus.mjs +276 -0
- package/scripts/corpus-candidate.mjs +19 -3
- package/scripts/corpus-coverage-sidecar.mjs +202 -0
- package/scripts/corpus-currency.mjs +71 -0
- package/scripts/corpus-dispatch-decision.mjs +138 -0
- package/scripts/corpus-next-seed.mjs +69 -12
- package/scripts/corpus-reconcile.mjs +340 -61
- package/scripts/corpus-store-failure.mjs +82 -0
- package/scripts/corpus-watchdog.mjs +334 -0
- package/scripts/derive-passage-content-map.mjs +71 -0
- package/scripts/fixture-denominator.mjs +72 -0
- package/scripts/knowledge-input-digest.mjs +163 -0
- package/scripts/oracle/repo-recall.mjs +142 -21
- package/scripts/public-verification-inputs.mjs +42 -4
- package/scripts/rehearse-corpus-pipeline.mjs +350 -20
- package/scripts/release-transaction-provider.mjs +29 -6
- package/scripts/release.mjs +156 -18
- package/scripts/retrieval-canary.mjs +83 -24
- package/scripts/retrieval-passage-identity.mjs +58 -0
- package/scripts/source-coverage.mjs +19 -3
- package/scripts/sync-census.mjs +0 -0
- package/scripts/wired-check.mjs +4 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// ADR-0091 D5 — per-store failure isolation for corpus reconciliation.
|
|
2
|
+
//
|
|
3
|
+
// One store failing to refresh must not abort the whole generation. This module owns the three
|
|
4
|
+
// pure decisions that make that safe, so the reconciler, the rehearsal and the tests share them:
|
|
5
|
+
//
|
|
6
|
+
// 1. WHICH failures are retried. Only transient ones (clone, fetch, runner I/O), exactly once, in a
|
|
7
|
+
// fresh worker directory. A corpus-qa failure is never retried: the round-trip sample is
|
|
8
|
+
// deterministic (FNV-1a, corpus-qa.mjs), so a retry re-embeds the whole store to reproduce it.
|
|
9
|
+
// The class comes from WHERE the worker failed and from forge-refresh's exit status, never
|
|
10
|
+
// from log text.
|
|
11
|
+
// 2. HOW MANY stores may be carried or missing before the failure is systemic and the generation
|
|
12
|
+
// must fail loudly: carried + missing <= max(3, 5% of eligible).
|
|
13
|
+
// 3. WHETHER a degraded generation may be published at all. Installed clients judge every bundle
|
|
14
|
+
// with their INSTALLED validator, and every validator shipped before this change rejects any
|
|
15
|
+
// non-CURRENT eligible row. ADR-0091 D10 therefore gates degraded publication behind a
|
|
16
|
+
// transition release plus a 14-day soak; until D10 records that transition, a degraded
|
|
17
|
+
// generation is built and sealed but never published.
|
|
18
|
+
|
|
19
|
+
/** kb/forge-refresh.mjs exits with this status when corpus-qa rejected its candidate. */
|
|
20
|
+
export const CORPUS_QA_FAILED_EXIT = 3;
|
|
21
|
+
|
|
22
|
+
export const FAILURE_CLASS = Object.freeze({
|
|
23
|
+
TRANSIENT: 'transient',
|
|
24
|
+
QA: 'qa',
|
|
25
|
+
BUILD: 'build',
|
|
26
|
+
INTEGRITY: 'integrity',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/** A worker failure carrying its structured class. `detail` is for logs only, never for shipped rows. */
|
|
30
|
+
export class StoreWorkerError extends Error {
|
|
31
|
+
constructor({ store, stage, failureClass, detail = '' }) {
|
|
32
|
+
super(`[corpus-reconcile] ${store}: ${stage} failed (${failureClass})${detail ? ` (${detail})` : ''}`);
|
|
33
|
+
this.name = 'StoreWorkerError';
|
|
34
|
+
this.store = store;
|
|
35
|
+
this.stage = stage;
|
|
36
|
+
this.failureClass = failureClass;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isRetryable(error) {
|
|
41
|
+
return error instanceof StoreWorkerError && error.failureClass === FAILURE_CLASS.TRANSIENT;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The shipped, machine-readable reason. Stage and class only: stderr can quote runner paths, and a
|
|
46
|
+
* coverage row travels to every user.
|
|
47
|
+
*/
|
|
48
|
+
export function failureReason(error) {
|
|
49
|
+
if (error instanceof StoreWorkerError) return `${error.failureClass}: ${error.stage} failed`;
|
|
50
|
+
return `${FAILURE_CLASS.INTEGRITY}: worker failed`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Fixed rule: the failure is systemic above max(3, 5% of eligible). */
|
|
54
|
+
export function degradedBound(eligibleCount) {
|
|
55
|
+
if (!Number.isSafeInteger(eligibleCount) || eligibleCount < 0) throw new Error('eligible count must be a non-negative integer');
|
|
56
|
+
return Math.max(3, Math.floor(eligibleCount * 0.05));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* ADR-0091 D10 owns this value. It stays null until the transition release N (the first release
|
|
61
|
+
* shipping the tolerant validator while its own coverage is all-CURRENT) has become releases/latest;
|
|
62
|
+
* D10 then records { version, latestSince } here. Null means no transition: never publish degraded.
|
|
63
|
+
*/
|
|
64
|
+
export const VALIDATOR_TRANSITION = null;
|
|
65
|
+
export const TRANSITION_SOAK_DAYS = 14;
|
|
66
|
+
|
|
67
|
+
export function degradedPublication({ transition = VALIDATOR_TRANSITION, now = new Date() } = {}) {
|
|
68
|
+
if (!transition) {
|
|
69
|
+
return { allowed: false,
|
|
70
|
+
reason: 'no tolerant-validator transition release is recorded (ADR-0091 D10); installed clients would reject a non-CURRENT row' };
|
|
71
|
+
}
|
|
72
|
+
const since = Date.parse(transition.latestSince);
|
|
73
|
+
if (typeof transition.version !== 'string' || !transition.version || !Number.isFinite(since)) {
|
|
74
|
+
return { allowed: false, reason: 'the recorded validator transition is malformed' };
|
|
75
|
+
}
|
|
76
|
+
const soakEnds = since + TRANSITION_SOAK_DAYS * 24 * 60 * 60 * 1000;
|
|
77
|
+
if (now.getTime() < soakEnds) {
|
|
78
|
+
return { allowed: false,
|
|
79
|
+
reason: `validator transition ${transition.version} soaks until ${new Date(soakEnds).toISOString()} (ADR-0091 D10)` };
|
|
80
|
+
}
|
|
81
|
+
return { allowed: true, reason: `validator transition ${transition.version} has soaked ${TRANSITION_SOAK_DAYS} days` };
|
|
82
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/corpus-watchdog.mjs — is the customer corpus ACTUALLY being refreshed every night?
|
|
3
|
+
//
|
|
4
|
+
// THE FAILURE THIS EXISTS TO END (2026-09-28/29, measured on live run history). The nightly
|
|
5
|
+
// dispatcher `corpus-nightly-dispatch.yml` stood down in ~13 seconds and finished GREEN, night after
|
|
6
|
+
// night, because repository variable CORPUS_NIGHTLY was unset. A green run is not a refreshed corpus:
|
|
7
|
+
// nothing was dispatched, nothing was published, and ntfy-alerts only pages on red. Before that, nine
|
|
8
|
+
// corpus-mode protected-release failures in a row never paged at all (a GITHUB_TOKEN dispatch cannot
|
|
9
|
+
// fire workflow_run). Silence was read as health — the exact failure config/scheduled-jobs.json names:
|
|
10
|
+
// ABSENCE OF EVIDENCE IS FAILURE.
|
|
11
|
+
//
|
|
12
|
+
// So this watchdog does not ask "did a workflow go green?". It asks for EVIDENCE OF AN OUTCOME:
|
|
13
|
+
// RED (a) no night ended `published` or `no-change` in the last 48h (catches silent stand-downs)
|
|
14
|
+
// (b) the newest corpus run of the last 24h failed (or was cancelled / timed out)
|
|
15
|
+
// (c) the newest vX.Y.Z code release has no VERIFIED public-verification aggregate 24h after
|
|
16
|
+
// publish, or carries one that does not verify (the nightly cannot arm without it)
|
|
17
|
+
// (d) any store has been deferred (carried STALE or MISSING) for more than 7 days
|
|
18
|
+
// WARNING a superseded or degraded night, a stand-down tonight, an unknown outcome, a long run
|
|
19
|
+
// GREEN none of the above
|
|
20
|
+
//
|
|
21
|
+
// Exit code is the contract (same as scripts/github-health-watch.mjs): 0 = GREEN or WARNING,
|
|
22
|
+
// 1 = RED or the evidence could not be gathered. corpus-watchdog.yml runs on a genuine `schedule:`,
|
|
23
|
+
// so its red conclusion fires ntfy-alerts.yml's workflow_run listener — no secret needed here.
|
|
24
|
+
//
|
|
25
|
+
// READ-ONLY. Every gh call below is a list/view/download. It never dispatches, edits, or publishes.
|
|
26
|
+
// Every `--json` field used is one gh 2.101.0 lists for that subcommand (the redesign that created
|
|
27
|
+
// this file exists because release.mjs asked `gh release view` for `isLatest`, which only
|
|
28
|
+
// `gh release list` has); tests/unit/corpus-watchdog.test.mjs pins the lists.
|
|
29
|
+
//
|
|
30
|
+
// node scripts/corpus-watchdog.mjs [--repo owner/name] [--json] [--now ISO]
|
|
31
|
+
|
|
32
|
+
import crypto from 'node:crypto';
|
|
33
|
+
import fs from 'node:fs';
|
|
34
|
+
import os from 'node:os';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import { spawnSync } from 'node:child_process';
|
|
37
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
38
|
+
import { CODE_TAG_PATTERN, isCorpusReleaseTag } from './release-channel-kind.mjs';
|
|
39
|
+
import { AGGREGATE_ASSET, SIGNING_PUBLIC_KEY_FILE } from './approved-runtime.mjs';
|
|
40
|
+
import { COVERAGE_ASSET, COVERAGE_RECEIPT_ASSET, verifyCoverageSidecar } from './corpus-coverage-sidecar.mjs';
|
|
41
|
+
|
|
42
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
43
|
+
export const RED = 'RED';
|
|
44
|
+
export const WARNING = 'WARNING';
|
|
45
|
+
export const GREEN = 'GREEN';
|
|
46
|
+
export const INFO = 'INFO';
|
|
47
|
+
const HOUR = 3_600_000;
|
|
48
|
+
export const FRESHNESS_WINDOW_MS = 48 * HOUR;
|
|
49
|
+
export const TONIGHT_WINDOW_MS = 24 * HOUR;
|
|
50
|
+
export const AGGREGATE_GRACE_MS = 24 * HOUR;
|
|
51
|
+
export const DEFERRAL_LIMIT_MS = 7 * 24 * HOUR;
|
|
52
|
+
export const LONG_RUN_MS = 8 * HOUR;
|
|
53
|
+
/** protected-release.yml's run-name for a corpus run: `protected-release corpus <dispatch id>`. */
|
|
54
|
+
export const CORPUS_RUN_TITLE = /^protected-release corpus (\S+)$/;
|
|
55
|
+
/** The dispatch id corpus-nightly-dispatch.yml passes: `corpus-<its run id>-<its attempt>`. */
|
|
56
|
+
const DISPATCH_ID = /^corpus-(\d+)-(\d+)$/;
|
|
57
|
+
/** Terminal outcomes a corpus-release-outcome.json may declare in an `outcome` field (design D2). */
|
|
58
|
+
const DECLARED_OUTCOMES = new Set(['published', 'no-change', 'superseded', 'degraded', 'failed']);
|
|
59
|
+
const GOOD_NIGHT = new Set(['published', 'no-change']);
|
|
60
|
+
|
|
61
|
+
const ms = (iso) => {
|
|
62
|
+
const t = Date.parse(String(iso ?? ''));
|
|
63
|
+
return Number.isFinite(t) ? t : null;
|
|
64
|
+
};
|
|
65
|
+
const hours = (delta) => `${(delta / HOUR).toFixed(1)}h`;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What ONE protected-release corpus run proved. Preference order: an explicit `outcome` in the run's
|
|
69
|
+
* corpus-release-outcome.json, then that record's per-job results, then the run's own job list.
|
|
70
|
+
* A non-success run conclusion is `failed` unless the record says otherwise (a typed superseded exit
|
|
71
|
+
* is a warning, not red). A success that shows neither a publish nor a no-change round is `unknown`
|
|
72
|
+
* — never counted as a good night.
|
|
73
|
+
*/
|
|
74
|
+
export function classifyCorpusRun(run) {
|
|
75
|
+
if (run?.status !== 'completed') return 'in-progress';
|
|
76
|
+
const declared = String(run.outcomeRecord?.outcome ?? '').replace('no_change', 'no-change');
|
|
77
|
+
if (DECLARED_OUTCOMES.has(declared)) return declared;
|
|
78
|
+
if (run.conclusion !== 'success') return 'failed';
|
|
79
|
+
const recorded = run.outcomeRecord?.jobs;
|
|
80
|
+
const byName = new Map((run.jobs || []).map((job) => [job.name, job.conclusion]));
|
|
81
|
+
const noChange = recorded?.no_change ?? byName.get('corpus-no-change-round');
|
|
82
|
+
const publish = recorded?.publish ?? byName.get('protected-corpus-publisher');
|
|
83
|
+
if (noChange === 'success') return 'no-change';
|
|
84
|
+
if (publish === 'success') return 'published';
|
|
85
|
+
return 'unknown';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* How long each store in the NEWEST generation has been deferred: walk generations newest-first while
|
|
90
|
+
* the store stays carried/missing; it has been deferred since the oldest generation in that unbroken
|
|
91
|
+
* run. A generation whose coverage is unknown (published before the D6.2 sidecar, or not fetched)
|
|
92
|
+
* ends the walk with `lowerBound: true` — the true deferral is at least that long.
|
|
93
|
+
* `generations`: [{ tag, publishedAt, degraded: { carried, missing } | null }], newest first.
|
|
94
|
+
*/
|
|
95
|
+
export function deferralSince(generations) {
|
|
96
|
+
const [newest, ...older] = generations || [];
|
|
97
|
+
if (!newest?.degraded) return [];
|
|
98
|
+
const stores = [...new Set([...(newest.degraded.carried || []), ...(newest.degraded.missing || [])])].sort();
|
|
99
|
+
return stores.map((store) => {
|
|
100
|
+
let since = newest.publishedAt;
|
|
101
|
+
let lowerBound = true; // until a generation proves the store was NOT deferred, history ran out first
|
|
102
|
+
for (const generation of older) {
|
|
103
|
+
if (!generation.degraded) break;
|
|
104
|
+
if (![...(generation.degraded.carried || []), ...(generation.degraded.missing || [])].includes(store)) { lowerBound = false; break; }
|
|
105
|
+
since = generation.publishedAt;
|
|
106
|
+
}
|
|
107
|
+
return { store, since, lowerBound };
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The pure verdict. `input`:
|
|
113
|
+
* corpusReleases [{ tag, publishedAt }] non-draft corpus-sha256-* releases
|
|
114
|
+
* corpusRuns [{ id, title, status, conclusion, createdAt, updatedAt, outcome }] protected-release corpus runs
|
|
115
|
+
* dispatcherRuns [{ id, attempt, status, conclusion, createdAt }] corpus-nightly-dispatch runs
|
|
116
|
+
* codeRelease { tag, publishedAt, aggregate: { state: verified|missing|invalid, reason } } | null
|
|
117
|
+
* deferral { observable, reason, newestTag, degraded: {carried, missing}, stores: [{store, since, lowerBound}] }
|
|
118
|
+
*/
|
|
119
|
+
export function judgeCorpusHealth(input, now) {
|
|
120
|
+
const at = now instanceof Date ? now.getTime() : Number(now);
|
|
121
|
+
if (!Number.isFinite(at)) throw new Error('judgeCorpusHealth needs a valid `now`');
|
|
122
|
+
const findings = [];
|
|
123
|
+
const add = (level, check, detail) => findings.push({ level, check, detail });
|
|
124
|
+
const age = (iso) => { const t = ms(iso); return t === null || t > at ? null : at - t; };
|
|
125
|
+
const runs = (input?.corpusRuns || []).map((run) => ({ ...run, outcome: run.outcome ?? classifyCorpusRun(run) }));
|
|
126
|
+
const dispatchers = input?.dispatcherRuns || [];
|
|
127
|
+
|
|
128
|
+
// Dispatcher runs that finished without creating a corpus run: stand-downs (the silent failure).
|
|
129
|
+
const dispatchedIds = new Set(runs.map((run) => CORPUS_RUN_TITLE.exec(String(run.title || ''))?.[1]).filter(Boolean));
|
|
130
|
+
const standDowns = dispatchers.filter((d) => d.status === 'completed' && d.conclusion === 'success'
|
|
131
|
+
&& !dispatchedIds.has(`corpus-${d.id}-${d.attempt ?? 1}`));
|
|
132
|
+
|
|
133
|
+
// (a) a good night inside the freshness window. Inclusive: exactly 48h old still counts.
|
|
134
|
+
const evidence = [
|
|
135
|
+
...(input?.corpusReleases || []).map((release) => ({ kind: 'published', at: release.publishedAt, ref: release.tag })),
|
|
136
|
+
...runs.filter((run) => GOOD_NIGHT.has(run.outcome)).map((run) => ({ kind: run.outcome, at: run.updatedAt, ref: `run ${run.id}` })),
|
|
137
|
+
].filter((item) => age(item.at) !== null).sort((a, b) => ms(b.at) - ms(a.at));
|
|
138
|
+
const lastGoodNight = evidence[0] || null;
|
|
139
|
+
if (!lastGoodNight || age(lastGoodNight.at) > FRESHNESS_WINDOW_MS) {
|
|
140
|
+
const recentStandDowns = standDowns.filter((d) => (age(d.createdAt) ?? Infinity) <= FRESHNESS_WINDOW_MS).length;
|
|
141
|
+
add(RED, 'freshness', `no night ended published or no-change in the last 48h `
|
|
142
|
+
+ `(last: ${lastGoodNight ? `${lastGoodNight.kind} ${lastGoodNight.ref} ${hours(age(lastGoodNight.at))} ago` : 'never observed'}; `
|
|
143
|
+
+ `${recentStandDowns} dispatcher stand-down(s) in the window — a green dispatcher that dispatched nothing is not a refreshed corpus)`);
|
|
144
|
+
} else {
|
|
145
|
+
add(GREEN, 'freshness', `${lastGoodNight.kind} ${lastGoodNight.ref} ${hours(age(lastGoodNight.at))} ago`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// (b) tonight: the newest corpus run created in the last 24h.
|
|
149
|
+
const tonight = runs.filter((run) => (age(run.createdAt) ?? Infinity) <= TONIGHT_WINDOW_MS)
|
|
150
|
+
.sort((a, b) => ms(b.createdAt) - ms(a.createdAt))[0];
|
|
151
|
+
const lastDispatcher = dispatchers.filter((d) => (age(d.createdAt) ?? Infinity) <= TONIGHT_WINDOW_MS)
|
|
152
|
+
.sort((a, b) => ms(b.createdAt) - ms(a.createdAt))[0];
|
|
153
|
+
if (tonight) {
|
|
154
|
+
const ref = `corpus run ${tonight.id} (${tonight.title})`;
|
|
155
|
+
if (tonight.outcome === 'failed') add(RED, 'tonight', `${ref} failed (conclusion ${tonight.conclusion})`);
|
|
156
|
+
else if (tonight.outcome === 'superseded') add(WARNING, 'tonight', `${ref} was superseded by a newer code release before publish`);
|
|
157
|
+
else if (tonight.outcome === 'degraded') add(WARNING, 'tonight', `${ref} published a degraded generation`);
|
|
158
|
+
else if (tonight.outcome === 'unknown') add(WARNING, 'tonight', `${ref} succeeded but shows neither a publish nor a no-change round`);
|
|
159
|
+
else if (tonight.outcome === 'in-progress') {
|
|
160
|
+
add((age(tonight.createdAt) ?? 0) > LONG_RUN_MS ? WARNING : INFO, 'tonight', `${ref} still running after ${hours(age(tonight.createdAt) ?? 0)}`);
|
|
161
|
+
} else add(GREEN, 'tonight', `${ref} ended ${tonight.outcome}`);
|
|
162
|
+
} else if (!lastDispatcher) {
|
|
163
|
+
add(WARNING, 'tonight', 'no corpus-nightly-dispatch run in the last 24h (schedule dropped or disabled?)');
|
|
164
|
+
} else if (lastDispatcher.status === 'completed' && lastDispatcher.conclusion !== 'success') {
|
|
165
|
+
add(RED, 'tonight', `corpus-nightly-dispatch run ${lastDispatcher.id} concluded ${lastDispatcher.conclusion}`);
|
|
166
|
+
} else if (standDowns.includes(lastDispatcher)) {
|
|
167
|
+
add(WARNING, 'tonight', `corpus-nightly-dispatch run ${lastDispatcher.id} stood down: nothing was dispatched `
|
|
168
|
+
+ '(CORPUS_NIGHTLY off, or no install-verified runtime yet)');
|
|
169
|
+
} else {
|
|
170
|
+
add(GREEN, 'tonight', `corpus-nightly-dispatch run ${lastDispatcher.id} is ${lastDispatcher.status}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// (c) the newest code release must reach a verified aggregate within 24h of publish.
|
|
174
|
+
const code = input?.codeRelease;
|
|
175
|
+
if (!code) add(RED, 'code-aggregate', 'no published vX.Y.Z code release was found');
|
|
176
|
+
else if (code.aggregate?.state === 'verified') add(GREEN, 'code-aggregate', `${code.tag} carries a verified PASS aggregate`);
|
|
177
|
+
else if (code.aggregate?.state === 'invalid') add(RED, 'code-aggregate', `${code.tag} carries an aggregate that does not verify `
|
|
178
|
+
+ `under this checkout's verifier (the one approved-runtime --resolve runs, so the nightly will refuse it too): ${code.aggregate.reason}`);
|
|
179
|
+
else {
|
|
180
|
+
const since = age(code.publishedAt);
|
|
181
|
+
if (since === null || since >= AGGREGATE_GRACE_MS) {
|
|
182
|
+
add(RED, 'code-aggregate', `${code.tag} was published ${since === null ? 'at an unknown time' : `${hours(since)} ago`} `
|
|
183
|
+
+ 'and still has no public-verification aggregate — the nightly cannot arm');
|
|
184
|
+
} else add(INFO, 'code-aggregate', `${code.tag} is ${hours(since)} old; aggregate pending (grace 24h)`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// (d) deferred stores. Only observable from a generation that carries the D6.2 coverage sidecar.
|
|
188
|
+
const deferral = input?.deferral;
|
|
189
|
+
if (!deferral?.observable) {
|
|
190
|
+
add(INFO, 'deferral', `not observable: ${deferral?.reason || 'no coverage evidence gathered'}`);
|
|
191
|
+
} else {
|
|
192
|
+
const overdue = (deferral.stores || []).filter((row) => (age(row.since) ?? 0) > DEFERRAL_LIMIT_MS);
|
|
193
|
+
if (overdue.length) {
|
|
194
|
+
add(RED, 'deferral', `${overdue.length} store(s) deferred more than 7 days: ${overdue.slice(0, 10)
|
|
195
|
+
.map((row) => `${row.store} (${row.lowerBound ? '≥' : ''}${hours(age(row.since))})`).join(', ')}`);
|
|
196
|
+
} else add(GREEN, 'deferral', `${(deferral.stores || []).length} deferred store(s), none older than 7 days`);
|
|
197
|
+
const degraded = [...(deferral.degraded?.carried || []), ...(deferral.degraded?.missing || [])];
|
|
198
|
+
if (degraded.length) add(WARNING, 'degraded', `${deferral.newestTag} is degraded: ${deferral.degraded.carried.length} carried, ${deferral.degraded.missing.length} missing`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const verdict = findings.some((f) => f.level === RED) ? RED : findings.some((f) => f.level === WARNING) ? WARNING : GREEN;
|
|
202
|
+
return { verdict, findings, lastGoodNight };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── Gathering (read-only gh). Everything above is pure; everything below talks to GitHub. ──────────
|
|
206
|
+
|
|
207
|
+
function defaultGh(args) {
|
|
208
|
+
const result = spawnSync(process.env.RUVNET_GH_COMMAND || 'gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: 5 * 60_000 });
|
|
209
|
+
if (result.error || result.status !== 0) {
|
|
210
|
+
throw new Error(`gh ${args.slice(0, 3).join(' ')} failed: ${String(result.error?.message || result.stderr || `exit ${result.status}`).trim().slice(0, 300)}`);
|
|
211
|
+
}
|
|
212
|
+
return result.stdout;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const semver = (tag) => CODE_TAG_PATTERN.test(tag) ? tag.slice(1).split('.').map(Number) : null;
|
|
216
|
+
/** Same selection as scripts/approved-runtime.mjs --resolve: newest by semver, no drafts, no prereleases. */
|
|
217
|
+
function newestCodeRelease(rows) {
|
|
218
|
+
return rows.filter((row) => !row.isDraft && !row.isPrerelease && semver(row.tagName))
|
|
219
|
+
.sort((a, b) => { const x = semver(a.tagName); const y = semver(b.tagName); return y[0] - x[0] || y[1] - x[1] || y[2] - x[2]; })[0] || null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function assetNames(gh, repo, tag) {
|
|
223
|
+
return new Set((JSON.parse(gh(['release', 'view', tag, '--repo', repo, '--json', 'assets'])).assets || []).map((asset) => asset.name));
|
|
224
|
+
}
|
|
225
|
+
function download(gh, repo, tag, name, dir) {
|
|
226
|
+
gh(['release', 'download', tag, '--repo', repo, '--pattern', name, '--dir', dir, '--clobber']);
|
|
227
|
+
return fs.readFileSync(path.join(dir, name));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function aggregateState({ gh, repo, tag, root, scratch }) {
|
|
231
|
+
if (!assetNames(gh, repo, tag).has(AGGREGATE_ASSET)) return { state: 'missing' };
|
|
232
|
+
try {
|
|
233
|
+
const aggregate = JSON.parse(download(gh, repo, tag, AGGREGATE_ASSET, scratch).toString('utf8'));
|
|
234
|
+
const { verifyPublicVerificationAggregate } = await import('./public-verification-aggregate.mjs');
|
|
235
|
+
verifyPublicVerificationAggregate(aggregate, crypto.createPublicKey(fs.readFileSync(path.join(root, SIGNING_PUBLIC_KEY_FILE), 'utf8')));
|
|
236
|
+
if (aggregate.identity?.tag !== tag) throw new Error(`aggregate describes ${aggregate.identity?.tag}, not ${tag}`);
|
|
237
|
+
return { state: 'verified' };
|
|
238
|
+
} catch (error) {
|
|
239
|
+
return { state: 'invalid', reason: error.message.slice(0, 200) };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function generationCoverage({ gh, repo, tag, scratch }) {
|
|
244
|
+
const names = assetNames(gh, repo, tag);
|
|
245
|
+
if (!names.has(COVERAGE_ASSET) || !names.has(COVERAGE_RECEIPT_ASSET)) return null;
|
|
246
|
+
const dir = fs.mkdtempSync(path.join(scratch, 'coverage-'));
|
|
247
|
+
const { degraded } = verifyCoverageSidecar({
|
|
248
|
+
sidecar: JSON.parse(download(gh, repo, tag, COVERAGE_RECEIPT_ASSET, dir).toString('utf8')),
|
|
249
|
+
coverageBytes: download(gh, repo, tag, COVERAGE_ASSET, dir),
|
|
250
|
+
generationTag: tag,
|
|
251
|
+
archiveSha256: tag.slice('corpus-sha256-'.length),
|
|
252
|
+
});
|
|
253
|
+
return degraded;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function corpusRunEvidence({ gh, repo, run, scratch }) {
|
|
257
|
+
if (run.status !== 'completed') return {};
|
|
258
|
+
const dir = fs.mkdtempSync(path.join(scratch, 'outcome-'));
|
|
259
|
+
try {
|
|
260
|
+
gh(['run', 'download', String(run.databaseId), '--repo', repo, '--name', `corpus-release-outcome-${run.databaseId}-${run.attempt ?? 1}`, '--dir', dir]);
|
|
261
|
+
return { outcomeRecord: JSON.parse(fs.readFileSync(path.join(dir, 'corpus-release-outcome.json'), 'utf8')) };
|
|
262
|
+
} catch {
|
|
263
|
+
// Runs that never reached corpus-terminal-outcome have no record; their job list still says what happened.
|
|
264
|
+
return { jobs: JSON.parse(gh(['run', 'view', String(run.databaseId), '--repo', repo, '--json', 'jobs'])).jobs || [] };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function gatherCorpusHealthInput({ repo, now, gh = defaultGh, root = ROOT, scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'corpus-watchdog-')) }) {
|
|
269
|
+
const at = now.getTime();
|
|
270
|
+
const recent = (iso) => (ms(iso) ?? 0) >= at - 3 * 24 * HOUR;
|
|
271
|
+
const dispatcherRuns = JSON.parse(gh(['run', 'list', '--repo', repo, '--workflow', 'corpus-nightly-dispatch.yml', '--limit', '30',
|
|
272
|
+
'--json', 'databaseId,attempt,event,status,conclusion,createdAt,updatedAt']))
|
|
273
|
+
.map((run) => ({ id: run.databaseId, attempt: run.attempt, event: run.event, status: run.status, conclusion: run.conclusion, createdAt: run.createdAt }));
|
|
274
|
+
const corpusRuns = JSON.parse(gh(['run', 'list', '--repo', repo, '--workflow', 'protected-release.yml', '--limit', '60',
|
|
275
|
+
'--json', 'databaseId,attempt,displayTitle,status,conclusion,createdAt,updatedAt']))
|
|
276
|
+
.filter((run) => CORPUS_RUN_TITLE.test(String(run.displayTitle || '')) && recent(run.createdAt))
|
|
277
|
+
.map((run) => ({ id: run.databaseId, title: run.displayTitle, status: run.status, conclusion: run.conclusion,
|
|
278
|
+
createdAt: run.createdAt, updatedAt: run.updatedAt, ...corpusRunEvidence({ gh, repo, run, scratch }) }));
|
|
279
|
+
const releases = JSON.parse(gh(['release', 'list', '--repo', repo, '--limit', '100', '--json', 'tagName,publishedAt,isDraft,isPrerelease']));
|
|
280
|
+
const corpusReleases = releases.filter((row) => !row.isDraft && isCorpusReleaseTag(row.tagName))
|
|
281
|
+
.map((row) => ({ tag: row.tagName, publishedAt: row.publishedAt }))
|
|
282
|
+
.sort((a, b) => (ms(b.publishedAt) ?? 0) - (ms(a.publishedAt) ?? 0));
|
|
283
|
+
const newestCode = newestCodeRelease(releases);
|
|
284
|
+
const codeRelease = newestCode && { tag: newestCode.tagName, publishedAt: newestCode.publishedAt,
|
|
285
|
+
aggregate: await aggregateState({ gh, repo, tag: newestCode.tagName, root, scratch }) };
|
|
286
|
+
|
|
287
|
+
// Deferral: newest generation's verified coverage, then older ones only while it can still matter.
|
|
288
|
+
let deferral = { observable: false, reason: 'no corpus release is published' };
|
|
289
|
+
if (corpusReleases.length) {
|
|
290
|
+
const generations = [];
|
|
291
|
+
for (const release of corpusReleases.slice(0, 15)) {
|
|
292
|
+
const degraded = generationCoverage({ gh, repo, tag: release.tag, scratch });
|
|
293
|
+
generations.push({ ...release, degraded });
|
|
294
|
+
const pending = generations[0].degraded && [...generations[0].degraded.carried, ...generations[0].degraded.missing];
|
|
295
|
+
if (!degraded || !pending?.length || (ms(release.publishedAt) ?? 0) < at - DEFERRAL_LIMIT_MS - 24 * HOUR) break;
|
|
296
|
+
}
|
|
297
|
+
deferral = generations[0].degraded
|
|
298
|
+
? { observable: true, newestTag: generations[0].tag, degraded: generations[0].degraded, stores: deferralSince(generations) }
|
|
299
|
+
// Generations published before ADR-0091 D6.2 carry no CORPUS-COVERAGE.json / coverage-receipt.json,
|
|
300
|
+
// so which stores were carried, and since when, is simply not recorded anywhere a reader can verify.
|
|
301
|
+
: { observable: false, reason: `${generations[0].tag} predates the D6.2 coverage sidecar (${COVERAGE_ASSET} + ${COVERAGE_RECEIPT_ASSET})` };
|
|
302
|
+
}
|
|
303
|
+
return { corpusReleases, corpusRuns, dispatcherRuns, codeRelease, deferral };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function renderReport(result, now) {
|
|
307
|
+
const lines = [`corpus-watchdog ${result.verdict} at ${now.toISOString()}`];
|
|
308
|
+
for (const f of result.findings) lines.push(` [${f.level}] ${f.check}: ${f.detail}`);
|
|
309
|
+
return `${lines.join('\n')}\n`;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function main(argv = process.argv.slice(2)) {
|
|
313
|
+
const opt = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
|
|
314
|
+
const repo = opt('--repo') || process.env.GITHUB_REPOSITORY || 'stuinfla/ruvnet-brain';
|
|
315
|
+
const now = opt('--now') ? new Date(opt('--now')) : new Date();
|
|
316
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(repo) || !Number.isFinite(now.getTime())) {
|
|
317
|
+
process.stderr.write('usage: corpus-watchdog.mjs [--repo owner/name] [--json] [--now ISO]\n');
|
|
318
|
+
return 2;
|
|
319
|
+
}
|
|
320
|
+
let result;
|
|
321
|
+
try {
|
|
322
|
+
result = judgeCorpusHealth(await gatherCorpusHealthInput({ repo, now }), now);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
// Could not observe = could not prove health. Absence of evidence is failure.
|
|
325
|
+
process.stderr.write(`corpus-watchdog RED: evidence could not be gathered: ${error.message}\n`);
|
|
326
|
+
return 1;
|
|
327
|
+
}
|
|
328
|
+
process.stdout.write(argv.includes('--json') ? `${JSON.stringify(result, null, 2)}\n` : renderReport(result, now));
|
|
329
|
+
return result.verdict === RED ? 1 : 0;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
|
|
333
|
+
main().then((code) => { process.exitCode = code; });
|
|
334
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Derive data/retrieval-passage-content-digests.json from a corpus archive built with ordinal passage
|
|
3
|
+
// ids. See scripts/retrieval-passage-identity.mjs for why the map exists.
|
|
4
|
+
//
|
|
5
|
+
// node scripts/derive-passage-content-map.mjs --zip <old-format ruvnet-brain.zip> \
|
|
6
|
+
// --source-tag v4.3.36 [--fixture data/retrieval-query-evidence.json] [--out <file>]
|
|
7
|
+
//
|
|
8
|
+
// For every expected passage the frozen fixture pins (primary + alternatives), find the ONE row in the
|
|
9
|
+
// archive's store whose path matches and whose digest equals the pin, and record that row's id-less
|
|
10
|
+
// content digest. A pin that is not found exactly once is listed as `unresolved` (never guessed): it
|
|
11
|
+
// keeps exact-digest matching only. The output is deterministic, so re-running proves the committed map.
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import crypto from 'node:crypto';
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { digest } from './coverage-integrity.mjs';
|
|
18
|
+
import { CONTENT_MAP_FILE, CONTENT_MAP_KIND, passageContentDigest } from './retrieval-passage-identity.mjs';
|
|
19
|
+
|
|
20
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
21
|
+
const sha256File = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
22
|
+
|
|
23
|
+
/** Every pin in the fixture: [{ store, path, pinned }]. */
|
|
24
|
+
export function fixturePins(fixture) {
|
|
25
|
+
const pins = [];
|
|
26
|
+
for (const [store, row] of Object.entries(fixture.queries || {})) {
|
|
27
|
+
const sources = [{ path: row.expected.path, passageSha256: row.expected.passageSha256 }, ...(row.expected.alternatives || [])];
|
|
28
|
+
for (const source of sources) pins.push({ store, path: source.path, pinned: source.passageSha256 });
|
|
29
|
+
}
|
|
30
|
+
return pins;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function deriveContentMap({ fixture, fixtureSha256, readRows, sourceTag, archiveSha256 }) {
|
|
34
|
+
const entries = {};
|
|
35
|
+
const unresolved = [];
|
|
36
|
+
for (const { store, path: expectedPath, pinned } of fixturePins(fixture)) {
|
|
37
|
+
const rows = readRows(store);
|
|
38
|
+
const found = (rows || []).filter((row) => row.path === expectedPath && digest(row) === pinned);
|
|
39
|
+
if (found.length === 1) entries[pinned] = passageContentDigest(found[0]);
|
|
40
|
+
else unresolved.push({ store, path: expectedPath, pinned, reason: rows ? `${found.length} matching rows` : 'store absent from archive' });
|
|
41
|
+
}
|
|
42
|
+
const sorted = Object.fromEntries(Object.entries(entries).sort(([a], [b]) => a.localeCompare(b)));
|
|
43
|
+
return { schemaVersion: 1, kind: CONTENT_MAP_KIND, fixtureSha256,
|
|
44
|
+
derivedFrom: { tag: sourceTag, archiveSha256 },
|
|
45
|
+
entries: sorted,
|
|
46
|
+
unresolved: unresolved.sort((a, b) => a.store.localeCompare(b.store) || a.path.localeCompare(b.path)) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function zipRows(zipFile, store) {
|
|
50
|
+
const result = spawnSync('unzip', ['-p', zipFile, `${store}.passages.jsonl`], { maxBuffer: 1 << 30 });
|
|
51
|
+
if (result.status !== 0) return null;
|
|
52
|
+
// Split on \n only: rows may contain U+2028/U+2029, which are not JSONL separators.
|
|
53
|
+
return result.stdout.toString('utf8').split('\n').filter((line) => line.trim()).map((line) => JSON.parse(line));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function main(argv) {
|
|
57
|
+
const arg = (name, fallback = null) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : fallback; };
|
|
58
|
+
const zip = arg('--zip');
|
|
59
|
+
const sourceTag = arg('--source-tag');
|
|
60
|
+
if (!zip || !sourceTag) { console.error('usage: derive-passage-content-map.mjs --zip <archive> --source-tag <tag> [--fixture f] [--out f]'); process.exit(2); }
|
|
61
|
+
const fixtureFile = path.resolve(arg('--fixture', path.join(ROOT, 'data', 'retrieval-query-evidence.json')));
|
|
62
|
+
const out = path.resolve(arg('--out', CONTENT_MAP_FILE));
|
|
63
|
+
const cache = new Map();
|
|
64
|
+
const readRows = (store) => { if (!cache.has(store)) cache.set(store, zipRows(zip, store)); return cache.get(store); };
|
|
65
|
+
const map = deriveContentMap({ fixture: JSON.parse(fs.readFileSync(fixtureFile, 'utf8')), fixtureSha256: sha256File(fixtureFile),
|
|
66
|
+
readRows, sourceTag, archiveSha256: sha256File(zip) });
|
|
67
|
+
fs.writeFileSync(out, `${JSON.stringify(map, null, 2)}\n`);
|
|
68
|
+
console.log(JSON.stringify({ out, resolved: Object.keys(map.entries).length, unresolved: map.unresolved.length }));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) main(process.argv.slice(2));
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// scripts/fixture-denominator.mjs — ADR-0091 D6.4 (and the one retirement function D7.7 requires).
|
|
2
|
+
//
|
|
3
|
+
// The retrieval fixture is FROZEN (182 human questions, one per repository, digest-bound). Live
|
|
4
|
+
// coverage is not: 194+ eligible repositories and growing. The release canary used to demand that
|
|
5
|
+
// the two sets be EQUAL, which a living corpus can never satisfy. The reconciled decision:
|
|
6
|
+
//
|
|
7
|
+
// available = eligible repository rows that ship a store (CURRENT, or STALE with a verified carry)
|
|
8
|
+
// retired = fixture repositories with NO row at all in a complete coverage observation (D7.2)
|
|
9
|
+
// questioned = fixture ∩ available -- every one is asked its frozen question
|
|
10
|
+
// blocking = fixture − available − retired -- the release refuses (e.g. MISSING, INELIGIBLE)
|
|
11
|
+
// unfixturedEligible = available − fixture -- RECORDED, never blocking; the fixture stays frozen
|
|
12
|
+
//
|
|
13
|
+
// Retirement only ever REMOVES a question from the denominator; it never adds a passing answer. The
|
|
14
|
+
// signal is "absent from a sealed, complete enumeration" -- it cannot tell deleted from privatized
|
|
15
|
+
// from renamed, which is acceptable for exactly that reason (ADR-0091 D7.2).
|
|
16
|
+
|
|
17
|
+
import { eligibleRepositoryStanding } from '../plugin/scripts/coverage-integrity.mjs';
|
|
18
|
+
|
|
19
|
+
const lower = (value) => String(value || '').toLowerCase();
|
|
20
|
+
// localeCompare, exactly as scripts/retrieval-canary.mjs orders and set-checks these lists. Default
|
|
21
|
+
// code-unit sort disagrees on real store names ('chatgpt-…' vs 'chatgpt_…'), which the canary's
|
|
22
|
+
// checkedSet rejects as 'eligible denominator set is invalid'.
|
|
23
|
+
const ordered = (values) => [...new Set(values)].sort((a, b) => a.localeCompare(b));
|
|
24
|
+
|
|
25
|
+
/** True only for a repository enumeration the coverage itself proves complete and terminal. */
|
|
26
|
+
export function repositoryEnumerationComplete(coverage) {
|
|
27
|
+
const repositories = (coverage?.rows || []).filter((row) => row?.kind === 'repository');
|
|
28
|
+
const enumeration = coverage?.enumerationReceipt;
|
|
29
|
+
return enumeration?.terminal === true && enumeration?.duplicateKeys === 0
|
|
30
|
+
&& enumeration?.repositories?.expected === repositories.length;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** D7.2: fixture stores with no repository row at all, under a complete enumeration; else none. */
|
|
34
|
+
export function retiredFixtureStores({ coverage, fixtureStores }) {
|
|
35
|
+
if (!repositoryEnumerationComplete(coverage)) return [];
|
|
36
|
+
const present = new Set();
|
|
37
|
+
for (const row of (coverage.rows || []).filter((entry) => entry?.kind === 'repository')) {
|
|
38
|
+
present.add(lower(row.name));
|
|
39
|
+
if (row.artifact?.store) present.add(lower(row.artifact.store));
|
|
40
|
+
}
|
|
41
|
+
return ordered(fixtureStores.map(lower).filter((store) => !present.has(store)));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function fixtureDenominator({ coverage, fixtureStores }) {
|
|
45
|
+
const fixture = ordered((fixtureStores || []).map(lower));
|
|
46
|
+
if (!fixture.length || fixture.some((store) => !store)) throw new Error('fixture store set is empty or malformed');
|
|
47
|
+
const eligible = (coverage?.rows || []).filter((row) => row?.kind === 'repository' && row.disposition === 'eligible');
|
|
48
|
+
const available = new Map();
|
|
49
|
+
for (const row of eligible) {
|
|
50
|
+
if (eligibleRepositoryStanding(row) !== 'shipped') continue;
|
|
51
|
+
const store = lower(row.artifact?.store);
|
|
52
|
+
if (!store) throw new Error(`eligible repository row ${row.key} names no store`);
|
|
53
|
+
if (available.has(store)) throw new Error(`eligible repository store ${store} is duplicated`);
|
|
54
|
+
available.set(store, row);
|
|
55
|
+
}
|
|
56
|
+
const retired = new Set(retiredFixtureStores({ coverage, fixtureStores: fixture }));
|
|
57
|
+
const questioned = fixture.filter((store) => available.has(store));
|
|
58
|
+
const blocking = fixture.filter((store) => !available.has(store) && !retired.has(store)).map((store) => {
|
|
59
|
+
const row = (coverage?.rows || []).find((entry) => entry?.kind === 'repository'
|
|
60
|
+
&& (lower(entry.artifact?.store) === store || lower(entry.name) === store));
|
|
61
|
+
return { store, status: row?.status ?? null, disposition: row?.disposition ?? null };
|
|
62
|
+
});
|
|
63
|
+
const fixtureSet = new Set(fixture);
|
|
64
|
+
return {
|
|
65
|
+
fixture,
|
|
66
|
+
questioned,
|
|
67
|
+
questionedRows: questioned.map((store) => available.get(store)),
|
|
68
|
+
retired: [...retired],
|
|
69
|
+
blocking,
|
|
70
|
+
unfixturedEligible: ordered([...available.keys()].filter((store) => !fixtureSet.has(store))),
|
|
71
|
+
};
|
|
72
|
+
}
|