ruvnet-brain 4.3.37 → 4.3.38
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/bin/install.mjs +32 -9
- package/kb/forge-update.mjs +1867 -0
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/hook-contracts.json +5 -5
- package/plugin/scripts/capability-claim-evidence.mjs +11 -2
- package/plugin/scripts/capacity-aware-parallel-work.mjs +71 -15
- package/plugin/scripts/completion-claim-evidence.mjs +262 -0
- package/plugin/scripts/continuation-gate.mjs +105 -21
- package/plugin/scripts/continuation-objective.mjs +15 -0
- package/plugin/scripts/decision-gate.mjs +22 -2
- package/plugin/scripts/duplicate-gate.mjs +503 -0
- package/plugin/scripts/grounding-turn-evidence.mjs +339 -0
- package/plugin/scripts/grounding-turn-gate.mjs +77 -25
- package/plugin/scripts/grounding-turn-mark.mjs +61 -14
- package/plugin/scripts/hook-input.mjs +15 -0
- package/plugin/scripts/host-update.mjs +45 -0
- package/plugin/scripts/nightly-scheduler.mjs +34 -0
- package/plugin/scripts/session-start-budget.mjs +1 -0
- package/plugin/scripts/session-start-core.mjs +15 -2
- package/plugin/scripts/session-start-health.mjs +101 -1
- package/plugin/scripts/session-start-update-plane.mjs +71 -1
- package/scripts/completion-claim-replay.mjs +114 -0
- package/scripts/corpus-canary.mjs +399 -0
- package/scripts/corpus-dispatch-decision.mjs +2 -2
- package/scripts/corpus-promotion.mjs +49 -0
- package/scripts/corpus-reconcile.mjs +27 -3
- package/scripts/corpus-watchdog.mjs +45 -6
- package/scripts/duplicate-gate-replay.mjs +98 -0
- package/scripts/grounding-turn-replay.mjs +131 -0
- package/scripts/nightly-watchdog.mjs +3 -3
- package/scripts/release.mjs +177 -74
- package/scripts/wired-check.mjs +2 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/corpus-canary.mjs — the CONSUMER must accept a corpus before the producer may promote it.
|
|
3
|
+
//
|
|
4
|
+
// THE FAILURE THIS EXISTS TO END (2026-08-21 .. 2026-09-30, measured on the owner's Mac). The nightly
|
|
5
|
+
// corpus pipeline published generations to releases/latest that no installed customer could apply:
|
|
6
|
+
// the archive carried no COVERAGE.json, and kb/forge-update.mjs refused it every night with
|
|
7
|
+
// "staged ReleaseCoverage failed integrity: COVERAGE.json is missing". Every producer-side gate was
|
|
8
|
+
// green — receipt verified, signature verified, runtime pin verified — because every one of them asked
|
|
9
|
+
// the PRODUCER's question ("is this archive what we built?"). Nobody asked the CONSUMER's question
|
|
10
|
+
// ("can a customer actually install it?"), so nobody's Brain moved for 40 days and nothing paged.
|
|
11
|
+
//
|
|
12
|
+
// So this canary asks exactly the consumer's question, the consumer's way, on a runner that holds no
|
|
13
|
+
// repository secret:
|
|
14
|
+
//
|
|
15
|
+
// 1. INSTALL the approved runtime as a stranger does: `npm install ruvnet-brain@<approved>` into a
|
|
16
|
+
// throwaway prefix, then that package's own bin/install.mjs --version v<approved> into an isolated
|
|
17
|
+
// HOME. That downloads, signature-checks and unpacks the approved release's own archive, places
|
|
18
|
+
// the trusted coverage validator and stamps RUNTIME-IDENTITY.json — the installer's real path.
|
|
19
|
+
// 2. POINT the installed updater at the candidate. A customer's SOURCE.json says
|
|
20
|
+
// `canonicalManifestUrl: https://api.github.com/repos/<repo>/releases/latest`. The candidate is
|
|
21
|
+
// staged as a PRERELEASE (never latest), so the one change made is a pure function of the
|
|
22
|
+
// candidate tag: `releases/latest` -> `releases/tags/<tag>`. GitHub serves the same release object
|
|
23
|
+
// shape from both endpoints. Nothing else is touched: the updater's embedded public key, its
|
|
24
|
+
// signature check, the installed coverage validator and the runtime-identity gate all run as
|
|
25
|
+
// shipped. The rewrite refuses any SOURCE.json that does not poll releases/latest.
|
|
26
|
+
// 3. APPLY with the updater INSIDE that install: `node <kb>/forge-update.mjs --apply --result-file`.
|
|
27
|
+
// Never `npx ruvnet-brain --update`: that command falls back to a fresh install of releases/latest
|
|
28
|
+
// when the updater fails, which would turn a refused candidate into a green canary.
|
|
29
|
+
// 4. JUDGE what landed (judgeCanary below) and write a verdict bound to this run and to the exact
|
|
30
|
+
// asset digests it tested. release.mjs --promote-staged refuses promotion without a PASS verdict
|
|
31
|
+
// whose assets still equal the release's (scripts/corpus-promotion.mjs evaluateCanaryVerdict).
|
|
32
|
+
//
|
|
33
|
+
// The child processes get a CONSTRUCTED environment, never process.env: GH_TOKEN / GITHUB_TOKEN never
|
|
34
|
+
// reach the customer install. PATH is node's own directory plus /usr/bin:/bin, so no `claude` or
|
|
35
|
+
// `codex` host is wired — this is the knowledge channel, not the host matrix.
|
|
36
|
+
//
|
|
37
|
+
// node scripts/corpus-canary.mjs --repo owner/name --tag corpus-sha256-<64hex> --approved-version X.Y.Z
|
|
38
|
+
// --work <dir> --verdict-out <file> [--api-base URL] [--installed-kb <dir> --home <dir>]
|
|
39
|
+
// exit 0 = PASS, 1 = FAIL (verdict written either way), 2 = usage.
|
|
40
|
+
|
|
41
|
+
import crypto from 'node:crypto';
|
|
42
|
+
import fs from 'node:fs';
|
|
43
|
+
import path from 'node:path';
|
|
44
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
45
|
+
import { pathToFileURL } from 'node:url';
|
|
46
|
+
import { CANARY_VERDICT_KIND, CORPUS_TAG_PATTERN, REQUIRED_CANARY_CHECKS } from './corpus-promotion.mjs';
|
|
47
|
+
|
|
48
|
+
export const PUBLIC_API = 'https://api.github.com';
|
|
49
|
+
export const FRESHNESS_LIMIT_MS = 48 * 3_600_000;
|
|
50
|
+
const SEMVER = /^\d+\.\d+\.\d+$/;
|
|
51
|
+
// Exit 2 is the updater's "network / manifest unreachable, nothing touched" (kb/forge-update.mjs:62-71).
|
|
52
|
+
// It is the only code worth retrying: the unauthenticated API a customer polls is shared per runner IP.
|
|
53
|
+
const NETWORK_EXIT = 2;
|
|
54
|
+
|
|
55
|
+
const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
56
|
+
const sha256File = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
57
|
+
|
|
58
|
+
export const customerManifestUrl = ({ apiBase = PUBLIC_API, repo }) => `${apiBase}/repos/${repo}/releases/latest`;
|
|
59
|
+
export const candidateManifestUrl = ({ apiBase = PUBLIC_API, repo, tag }) => `${apiBase}/repos/${repo}/releases/tags/${tag}`;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The ONE change the canary makes to a customer install: the manifest it polls. Refuses anything but
|
|
63
|
+
* a customer that polls releases/latest, so a config drift in the shipped SOURCE.json is a FAIL here
|
|
64
|
+
* rather than a canary that silently tests some other channel.
|
|
65
|
+
*/
|
|
66
|
+
export function pointUpdaterAtCandidate({ kbDir, apiBase = PUBLIC_API, repo, tag }) {
|
|
67
|
+
const file = path.join(kbDir, 'SOURCE.json');
|
|
68
|
+
const before = readJson(file);
|
|
69
|
+
const expected = customerManifestUrl({ apiBase, repo });
|
|
70
|
+
if (before.canonicalManifestUrl !== expected) {
|
|
71
|
+
throw new Error(`installed SOURCE.json polls ${JSON.stringify(before.canonicalManifestUrl)}, not the customer channel ${expected}`);
|
|
72
|
+
}
|
|
73
|
+
const manifestUrl = candidateManifestUrl({ apiBase, repo, tag });
|
|
74
|
+
fs.writeFileSync(file, `${JSON.stringify({ ...before, canonicalManifestUrl: manifestUrl }, null, 2)}\n`);
|
|
75
|
+
return { before, manifestUrl };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Top-level installed packages (scoped names expanded), e.g. ['@ruvector/rvf', '@xenova/transformers']. */
|
|
79
|
+
export function installedModules(kbDir) {
|
|
80
|
+
const root = path.join(kbDir, 'node_modules');
|
|
81
|
+
if (!fs.existsSync(root)) return [];
|
|
82
|
+
const names = [];
|
|
83
|
+
for (const entry of fs.readdirSync(root)) {
|
|
84
|
+
if (entry.startsWith('.')) continue;
|
|
85
|
+
if (entry.startsWith('@')) {
|
|
86
|
+
for (const scoped of fs.readdirSync(path.join(root, entry))) names.push(`${entry}/${scoped}`);
|
|
87
|
+
} else names.push(entry);
|
|
88
|
+
}
|
|
89
|
+
return names.sort();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Every directory under `home` that is a KB tree (SOURCE.json + forge-update.mjs), and every .big.rvf. */
|
|
93
|
+
function scanHome(home) {
|
|
94
|
+
const trees = [];
|
|
95
|
+
const rvfs = [];
|
|
96
|
+
const walk = (dir) => {
|
|
97
|
+
let entries;
|
|
98
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
99
|
+
const names = new Set(entries.map((entry) => entry.name));
|
|
100
|
+
if (names.has('SOURCE.json') && names.has('forge-update.mjs')) trees.push(dir);
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
if (entry.name === 'node_modules' || entry.isSymbolicLink()) continue;
|
|
103
|
+
const full = path.join(dir, entry.name);
|
|
104
|
+
if (entry.isDirectory()) walk(full);
|
|
105
|
+
else if (entry.name.endsWith('.big.rvf')) rvfs.push(full);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
walk(home);
|
|
109
|
+
return { trees: trees.sort(), rvfs: rvfs.sort() };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function coverageRows(file) {
|
|
113
|
+
if (!fs.existsSync(file)) return null;
|
|
114
|
+
const coverage = readJson(file);
|
|
115
|
+
return { observedAt: coverage.observedAt, rows: Array.isArray(coverage.rows) ? coverage.rows : [] };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Per-store freshness, judged from the generation's own sealed coverage (CORPUS-COVERAGE.json inside
|
|
120
|
+
* the installed tree): the upstream observation must be under 48h old, no store whose upstream moved
|
|
121
|
+
* may still carry the old commit, and every store whose upstream moved since the install's previous
|
|
122
|
+
* generation must have been ingested inside the window. MISSING/INELIGIBLE rows are the coverage
|
|
123
|
+
* validator's business (it already ran), not a freshness question.
|
|
124
|
+
*/
|
|
125
|
+
export function storeFreshness({ after, before = null, now }) {
|
|
126
|
+
if (!after) return { ok: false, detail: 'CORPUS-COVERAGE.json is missing from the installed tree' };
|
|
127
|
+
const observed = Date.parse(after.observedAt);
|
|
128
|
+
if (!Number.isFinite(observed)) return { ok: false, detail: `unreadable coverage observedAt ${JSON.stringify(after.observedAt)}` };
|
|
129
|
+
const age = now - observed;
|
|
130
|
+
const problems = [];
|
|
131
|
+
if (age > FRESHNESS_LIMIT_MS || age < 0) problems.push(`upstream observed ${(age / 3_600_000).toFixed(1)}h ago (limit 48h)`);
|
|
132
|
+
const priorCommit = new Map((before?.rows || []).map((row) => [row.key, row.artifact?.sourceCommit || null]));
|
|
133
|
+
const stale = [];
|
|
134
|
+
const late = [];
|
|
135
|
+
let moved = 0;
|
|
136
|
+
for (const row of after.rows) {
|
|
137
|
+
if (row.status === 'INELIGIBLE') continue;
|
|
138
|
+
const upstream = row.upstream?.sha || null;
|
|
139
|
+
const built = row.artifact?.sourceCommit || null;
|
|
140
|
+
if (!upstream || !built) continue;
|
|
141
|
+
if (upstream !== built) { stale.push(row.artifact?.store || row.key); continue; }
|
|
142
|
+
if (before && priorCommit.get(row.key) !== upstream) {
|
|
143
|
+
moved += 1;
|
|
144
|
+
const ingested = Date.parse(row.artifact?.ingestedAt);
|
|
145
|
+
if (!Number.isFinite(ingested) || now - ingested > FRESHNESS_LIMIT_MS) late.push(row.artifact?.store || row.key);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (stale.length) problems.push(`${stale.length} store(s) built from a commit older than their observed upstream: ${stale.slice(0, 10).join(', ')}`);
|
|
149
|
+
if (late.length) problems.push(`${late.length} moved store(s) ingested more than 48h ago: ${late.slice(0, 10).join(', ')}`);
|
|
150
|
+
return problems.length
|
|
151
|
+
? { ok: false, detail: problems.join('; ') }
|
|
152
|
+
: { ok: true, detail: `observed ${(age / 3_600_000).toFixed(1)}h ago; ${after.rows.length} row(s), 0 stale; ${before ? `${moved} moved since the installed generation, all ingested <48h` : 'no prior coverage to diff against'}` };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The verdict. Pure over what is on disk plus what the updater reported. Every check in
|
|
157
|
+
* REQUIRED_CANARY_CHECKS is always present, so a missing check can never read as a pass.
|
|
158
|
+
*/
|
|
159
|
+
export async function judgeCanary({
|
|
160
|
+
tag, approvedVersion, release, updater, before, beforeModules, beforeCoverage, kbDir, home, now = Date.now(),
|
|
161
|
+
}) {
|
|
162
|
+
const archiveSha256 = tag.slice('corpus-sha256-'.length);
|
|
163
|
+
const checks = [];
|
|
164
|
+
const check = async (name, fn) => {
|
|
165
|
+
try {
|
|
166
|
+
const { ok, detail } = await fn();
|
|
167
|
+
checks.push({ name, ok: ok === true, detail: String(detail) });
|
|
168
|
+
} catch (error) {
|
|
169
|
+
checks.push({ name, ok: false, detail: `check threw: ${error.message}` });
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
const after = fs.existsSync(path.join(kbDir, 'SOURCE.json')) ? readJson(path.join(kbDir, 'SOURCE.json')) : null;
|
|
173
|
+
const result = updater?.result || null;
|
|
174
|
+
|
|
175
|
+
await check('candidate-release', () => {
|
|
176
|
+
const assets = Array.isArray(release?.assets) ? release.assets : [];
|
|
177
|
+
const zip = assets.find((asset) => asset?.name === 'ruvnet-brain.zip');
|
|
178
|
+
const sig = assets.find((asset) => asset?.name === 'ruvnet-brain.zip.sig');
|
|
179
|
+
const problems = [];
|
|
180
|
+
if (release?.tag_name !== tag) problems.push(`payload names ${release?.tag_name}`);
|
|
181
|
+
if (release?.draft !== false) problems.push('release is a draft (customers cannot download it)');
|
|
182
|
+
if (zip?.digest !== `sha256:${archiveSha256}`) problems.push(`ruvnet-brain.zip digest ${zip?.digest || '(none)'} is not the tag digest`);
|
|
183
|
+
if (!sig) problems.push('no detached ruvnet-brain.zip.sig asset');
|
|
184
|
+
return problems.length ? { ok: false, detail: problems.join('; ') }
|
|
185
|
+
: { ok: true, detail: `${tag} serves ruvnet-brain.zip sha256:${archiveSha256.slice(0, 12)}… and its .sig` };
|
|
186
|
+
});
|
|
187
|
+
await check('updater-exit', () => ({
|
|
188
|
+
ok: updater?.exitCode === 0 && result?.terminalVerdict === 'applied',
|
|
189
|
+
detail: `exit ${updater?.exitCode}, terminal verdict ${result?.terminalVerdict || '(no result receipt)'}, ${updater?.attempts ?? 0} attempt(s)`,
|
|
190
|
+
}));
|
|
191
|
+
await check('signature-verified', () => {
|
|
192
|
+
const line = /✓ signature verified — signature valid \(sha256 ([0-9a-f]{12})…\)/.exec(String(updater?.output || ''));
|
|
193
|
+
const ok = Boolean(line) && line[1] === archiveSha256.slice(0, 12) && result?.bundleSha256 === archiveSha256;
|
|
194
|
+
return { ok, detail: ok ? `the shipped updater verified the candidate bytes against its embedded key (${line[1]}…)`
|
|
195
|
+
: `signature line ${line ? line[1] : '(absent)'}; applied bundle ${result?.bundleSha256 || '(none)'}; expected ${archiveSha256}` };
|
|
196
|
+
});
|
|
197
|
+
await check('staged-coverage', async () => {
|
|
198
|
+
const validatorFile = path.join(kbDir, 'coverage-integrity.mjs');
|
|
199
|
+
if (!fs.existsSync(validatorFile)) return { ok: false, detail: 'the installer-placed coverage validator is missing' };
|
|
200
|
+
const { validateCoverageDirectory } = await import(pathToFileURL(validatorFile).href);
|
|
201
|
+
const verdict = validateCoverageDirectory(kbDir, { expectedVersion: approvedVersion });
|
|
202
|
+
const coverageFile = path.join(kbDir, 'COVERAGE.json');
|
|
203
|
+
const bound = fs.existsSync(coverageFile) && result?.coverageSha256 === sha256File(coverageFile);
|
|
204
|
+
return { ok: verdict.valid === true && bound,
|
|
205
|
+
detail: verdict.valid ? (bound ? `valid for runtime ${approvedVersion}; COVERAGE.json is the one the updater applied`
|
|
206
|
+
: 'COVERAGE.json on disk is not the one the updater reported') : verdict.failures.join('; ') };
|
|
207
|
+
});
|
|
208
|
+
await check('source-advanced', () => {
|
|
209
|
+
const problems = [];
|
|
210
|
+
if (!after) return { ok: false, detail: 'no SOURCE.json after apply' };
|
|
211
|
+
if (after.corpusReleaseTag !== tag) problems.push(`corpusReleaseTag ${after.corpusReleaseTag || '(none)'}`);
|
|
212
|
+
if (!(Date.parse(after.builtUtc) > Date.parse(before?.builtUtc))) problems.push(`builtUtc ${after.builtUtc} did not advance past ${before?.builtUtc}`);
|
|
213
|
+
if (after.brainVersion !== approvedVersion) problems.push(`brainVersion ${after.brainVersion} is not the approved ${approvedVersion}`);
|
|
214
|
+
return problems.length ? { ok: false, detail: problems.join('; ') }
|
|
215
|
+
: { ok: true, detail: `${before?.builtUtc} -> ${after.builtUtc}; corpusReleaseTag ${tag.slice(0, 26)}…` };
|
|
216
|
+
});
|
|
217
|
+
await check('runtime-identity', () => {
|
|
218
|
+
const file = path.join(kbDir, 'RUNTIME-IDENTITY.json');
|
|
219
|
+
if (!fs.existsSync(file)) return { ok: false, detail: 'RUNTIME-IDENTITY.json is gone (the next corpus check would refuse)' };
|
|
220
|
+
const identity = readJson(file);
|
|
221
|
+
return { ok: identity.brainVersion === approvedVersion, detail: `installed runtime ${identity.brainVersion}` };
|
|
222
|
+
});
|
|
223
|
+
await check('node-modules', () => {
|
|
224
|
+
const manifest = path.join(kbDir, 'package.json');
|
|
225
|
+
const declared = fs.existsSync(manifest) ? Object.keys(readJson(manifest).dependencies || {}) : [];
|
|
226
|
+
const required = [...new Set([...declared, ...(beforeModules || [])])].sort();
|
|
227
|
+
const missing = required.filter((name) => !fs.existsSync(path.join(kbDir, 'node_modules', name, 'package.json')));
|
|
228
|
+
if (!required.length) return { ok: false, detail: 'the install had no node_modules to preserve (reader deps never installed)' };
|
|
229
|
+
return { ok: missing.length === 0, detail: missing.length ? `dropped by the update: ${missing.join(', ')}` : `${required.length} reader package(s) intact` };
|
|
230
|
+
});
|
|
231
|
+
await check('single-kb-tree', () => {
|
|
232
|
+
const { trees, rvfs } = scanHome(home);
|
|
233
|
+
const outside = rvfs.filter((file) => path.dirname(file) !== kbDir);
|
|
234
|
+
const ok = trees.length === 1 && trees[0] === kbDir && outside.length === 0 && rvfs.length > 0;
|
|
235
|
+
return { ok, detail: `${trees.length} KB tree(s) [${trees.map((tree) => path.relative(home, tree)).join(', ')}], `
|
|
236
|
+
+ `${rvfs.length} .big.rvf, ${outside.length} outside the live KB` };
|
|
237
|
+
});
|
|
238
|
+
await check('store-freshness', () => storeFreshness({
|
|
239
|
+
after: coverageRows(path.join(kbDir, 'CORPUS-COVERAGE.json')), before: beforeCoverage, now,
|
|
240
|
+
}));
|
|
241
|
+
|
|
242
|
+
for (const name of REQUIRED_CANARY_CHECKS) {
|
|
243
|
+
if (!checks.some((entry) => entry.name === name)) checks.push({ name, ok: false, detail: 'check never ran' });
|
|
244
|
+
}
|
|
245
|
+
return { verdict: checks.every((entry) => entry.ok) ? 'PASS' : 'FAIL', checks };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The environment a stranger has: no inherited tokens, an isolated HOME, only node/npm and the base system. */
|
|
249
|
+
export function customerEnv({ home, work }) {
|
|
250
|
+
const brainHome = path.join(home, '.cache', 'ruvnet-brain');
|
|
251
|
+
return {
|
|
252
|
+
PATH: [path.dirname(process.execPath), '/usr/bin', '/bin'].join(path.delimiter),
|
|
253
|
+
HOME: home,
|
|
254
|
+
USERPROFILE: home,
|
|
255
|
+
CODEX_HOME: path.join(home, '.codex'),
|
|
256
|
+
RUVNET_BRAIN_HOME: brainHome,
|
|
257
|
+
RUVNET_BRAIN_KB: path.join(brainHome, 'kb'),
|
|
258
|
+
TMPDIR: path.join(work, 'tmp'),
|
|
259
|
+
npm_config_cache: path.join(work, 'npm-cache'),
|
|
260
|
+
npm_config_update_notifier: 'false',
|
|
261
|
+
CI: 'true',
|
|
262
|
+
LANG: 'C.UTF-8',
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function runLogged(command, args, { env, cwd, timeoutMs, log }) {
|
|
267
|
+
const result = spawnSync(command, args, { env, cwd, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });
|
|
268
|
+
log(`$ ${path.basename(command)} ${args.join(' ')}\n${result.stdout || ''}${result.stderr || ''}`);
|
|
269
|
+
if (result.error || result.status !== 0) {
|
|
270
|
+
throw new Error(`${path.basename(command)} ${args[0]} failed (${result.error?.message || `exit ${result.status}`}): ${String(result.stderr || result.stdout || '').trim().slice(-2000)}`);
|
|
271
|
+
}
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Phase 1: the approved runtime, installed exactly as `npx ruvnet-brain@<version>` would. */
|
|
276
|
+
export function installCustomer({ approvedVersion, work, home, log = () => {} }) {
|
|
277
|
+
const env = customerEnv({ home, work });
|
|
278
|
+
fs.mkdirSync(env.TMPDIR, { recursive: true });
|
|
279
|
+
const prefix = path.join(work, 'npx');
|
|
280
|
+
fs.mkdirSync(prefix, { recursive: true });
|
|
281
|
+
const npm = path.join(path.dirname(process.execPath), process.platform === 'win32' ? 'npm.cmd' : 'npm');
|
|
282
|
+
runLogged(npm, ['install', '--prefix', prefix, '--no-audit', '--no-fund', '--no-save', `ruvnet-brain@${approvedVersion}`],
|
|
283
|
+
{ env, cwd: prefix, timeoutMs: 600_000, log });
|
|
284
|
+
const installer = path.join(prefix, 'node_modules', 'ruvnet-brain', 'bin', 'install.mjs');
|
|
285
|
+
const installed = readJson(path.join(prefix, 'node_modules', 'ruvnet-brain', 'package.json')).version;
|
|
286
|
+
if (installed !== approvedVersion) throw new Error(`npm served ruvnet-brain ${installed}, not the approved ${approvedVersion}`);
|
|
287
|
+
runLogged(process.execPath, [installer, '--yes', '--force', '--version', `v${approvedVersion}`, '--no-nightly-prompt',
|
|
288
|
+
'--no-telemetry', '--no-stack', '--no-enhance', '--no-statusline', '--no-selfcheck'],
|
|
289
|
+
{ env, cwd: home, timeoutMs: 1_800_000, log });
|
|
290
|
+
return { kbDir: env.RUVNET_BRAIN_KB };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function runUpdater({ kbDir, env, resultFile }) {
|
|
294
|
+
return new Promise((resolve) => {
|
|
295
|
+
const child = spawn(process.execPath, [path.join(kbDir, 'forge-update.mjs'), '--apply', '--result-file', resultFile],
|
|
296
|
+
{ cwd: kbDir, env });
|
|
297
|
+
let output = '';
|
|
298
|
+
child.stdout.on('data', (chunk) => { output += chunk; });
|
|
299
|
+
child.stderr.on('data', (chunk) => { output += chunk; });
|
|
300
|
+
child.on('error', (error) => resolve({ exitCode: null, output: `${output}\n${error.message}` }));
|
|
301
|
+
child.on('close', (exitCode) => resolve({ exitCode, output }));
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function fetchRelease({ apiBase, repo, tag }) {
|
|
306
|
+
const response = await fetch(candidateManifestUrl({ apiBase, repo, tag }), { headers: { accept: 'application/vnd.github+json' } });
|
|
307
|
+
if (!response.ok) throw new Error(`release ${tag} returned HTTP ${response.status}`);
|
|
308
|
+
return response.json();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The whole canary. `install` is the phase-1 seam: CI uses installCustomer (the real npm package + real
|
|
313
|
+
* installer); the unit suite hands in a customer tree built offline so the updater, the judge and the
|
|
314
|
+
* verdict run for real without a 555 MB download.
|
|
315
|
+
*/
|
|
316
|
+
export async function runCanary({
|
|
317
|
+
repo, tag, approvedVersion, work, apiBase = PUBLIC_API, install = installCustomer, home = path.join(work, 'home'),
|
|
318
|
+
env = process.env, now = () => Date.now(), retryDelayMs = 30_000, maxAttempts = 3, log = () => {},
|
|
319
|
+
}) {
|
|
320
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(String(repo || ''))) throw new Error('--repo must be owner/name');
|
|
321
|
+
if (!CORPUS_TAG_PATTERN.test(String(tag || ''))) throw new Error('--tag must be corpus-sha256-<64 hex>');
|
|
322
|
+
if (!SEMVER.test(String(approvedVersion || ''))) throw new Error('--approved-version must be X.Y.Z');
|
|
323
|
+
fs.mkdirSync(home, { recursive: true });
|
|
324
|
+
const started = now();
|
|
325
|
+
const { kbDir } = await install({ approvedVersion, work, home, log });
|
|
326
|
+
const release = await fetchRelease({ apiBase, repo, tag });
|
|
327
|
+
const beforeModules = installedModules(kbDir);
|
|
328
|
+
const beforeCoverage = coverageRows(path.join(kbDir, 'CORPUS-COVERAGE.json'));
|
|
329
|
+
const { before } = pointUpdaterAtCandidate({ kbDir, apiBase, repo, tag });
|
|
330
|
+
const childEnv = { ...customerEnv({ home, work }), RUVNET_BRAIN_KB: kbDir };
|
|
331
|
+
fs.mkdirSync(childEnv.TMPDIR, { recursive: true });
|
|
332
|
+
|
|
333
|
+
const resultFile = path.join(work, 'update-result.json');
|
|
334
|
+
let updater = { exitCode: null, output: '', attempts: 0 };
|
|
335
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
336
|
+
fs.rmSync(resultFile, { force: true });
|
|
337
|
+
const run = await runUpdater({ kbDir, env: childEnv, resultFile });
|
|
338
|
+
updater = { ...run, attempts: attempt };
|
|
339
|
+
log(`--- forge-update.mjs --apply (attempt ${attempt}) exit ${run.exitCode}\n${run.output}`);
|
|
340
|
+
if (run.exitCode !== NETWORK_EXIT || attempt === maxAttempts) break;
|
|
341
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs * attempt));
|
|
342
|
+
}
|
|
343
|
+
updater.result = fs.existsSync(resultFile) ? readJson(resultFile) : null;
|
|
344
|
+
const judged = await judgeCanary({ tag, approvedVersion, release, updater, before, beforeModules, beforeCoverage,
|
|
345
|
+
kbDir: fs.realpathSync(kbDir), home: fs.realpathSync(home), now: now() });
|
|
346
|
+
return {
|
|
347
|
+
schemaVersion: 1,
|
|
348
|
+
kind: CANARY_VERDICT_KIND,
|
|
349
|
+
verdict: judged.verdict,
|
|
350
|
+
repo,
|
|
351
|
+
tag,
|
|
352
|
+
archiveSha256: tag.slice('corpus-sha256-'.length),
|
|
353
|
+
approvedVersion,
|
|
354
|
+
runId: env.GITHUB_RUN_ID || null,
|
|
355
|
+
runAttempt: env.GITHUB_RUN_ATTEMPT || null,
|
|
356
|
+
checkedAt: new Date(now()).toISOString(),
|
|
357
|
+
elapsedMs: now() - started,
|
|
358
|
+
assets: (release.assets || []).map((asset) => ({ name: asset.name, size: asset.size, digest: asset.digest || null }))
|
|
359
|
+
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
360
|
+
updater: { exitCode: updater.exitCode, attempts: updater.attempts, terminalVerdict: updater.result?.terminalVerdict || null,
|
|
361
|
+
bundleSha256: updater.result?.bundleSha256 || null, coverageSha256: updater.result?.coverageSha256 || null },
|
|
362
|
+
checks: judged.checks,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function main(argv = process.argv.slice(2)) {
|
|
367
|
+
const opt = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
|
|
368
|
+
const work = opt('--work') && path.resolve(opt('--work'));
|
|
369
|
+
const verdictOut = opt('--verdict-out') && path.resolve(opt('--verdict-out'));
|
|
370
|
+
if (!work || !verdictOut) {
|
|
371
|
+
process.stderr.write('usage: corpus-canary.mjs --repo o/n --tag corpus-sha256-<hex> --approved-version X.Y.Z --work <dir> --verdict-out <file> [--api-base URL] [--installed-kb <dir> --home <dir>]\n');
|
|
372
|
+
return 2;
|
|
373
|
+
}
|
|
374
|
+
fs.mkdirSync(work, { recursive: true });
|
|
375
|
+
const logFile = path.join(work, 'canary.log');
|
|
376
|
+
const log = (text) => { fs.appendFileSync(logFile, `${text}\n`); };
|
|
377
|
+
const installedKb = opt('--installed-kb');
|
|
378
|
+
const install = installedKb ? () => ({ kbDir: path.resolve(installedKb) }) : installCustomer;
|
|
379
|
+
let record;
|
|
380
|
+
try {
|
|
381
|
+
record = await runCanary({ repo: opt('--repo'), tag: opt('--tag'), approvedVersion: opt('--approved-version'), work,
|
|
382
|
+
apiBase: opt('--api-base') || PUBLIC_API, install, home: path.resolve(opt('--home') || path.join(work, 'home')), log });
|
|
383
|
+
} catch (error) {
|
|
384
|
+
// Could not even reach a judgement = the consumer did not accept. Still a written verdict.
|
|
385
|
+
record = { schemaVersion: 1, kind: CANARY_VERDICT_KIND, verdict: 'FAIL', repo: opt('--repo') || null, tag: opt('--tag') || null,
|
|
386
|
+
approvedVersion: opt('--approved-version') || null, runId: process.env.GITHUB_RUN_ID || null,
|
|
387
|
+
runAttempt: process.env.GITHUB_RUN_ATTEMPT || null, checkedAt: new Date().toISOString(), assets: [],
|
|
388
|
+
checks: [{ name: 'canary-ran', ok: false, detail: error.message }] };
|
|
389
|
+
}
|
|
390
|
+
fs.mkdirSync(path.dirname(verdictOut), { recursive: true });
|
|
391
|
+
fs.writeFileSync(verdictOut, `${JSON.stringify(record, null, 2)}\n`);
|
|
392
|
+
process.stdout.write(`corpus-canary ${record.verdict} ${record.tag}\n`);
|
|
393
|
+
for (const entry of record.checks) process.stdout.write(` [${entry.ok ? 'ok' : 'FAIL'}] ${entry.name}: ${entry.detail}\n`);
|
|
394
|
+
return record.verdict === 'PASS' ? 0 : 1;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
|
|
398
|
+
main().then((code) => { process.exitCode = code; });
|
|
399
|
+
}
|
|
@@ -16,14 +16,14 @@
|
|
|
16
16
|
//
|
|
17
17
|
// minPipelineVersion: the dispatched workflow text is always main's, but the scripts it runs come from
|
|
18
18
|
// the approved release's checkout. A runtime older than MIN_PIPELINE_VERSION does not know the flags
|
|
19
|
-
// the new workflow passes (--approved-tag, --no-change-out), so the nightly stands down rather than
|
|
19
|
+
// the new workflow passes (--approved-tag, --no-change-out, --stage-candidate, --promote-staged), so the nightly stands down rather than
|
|
20
20
|
// let new workflow text drive old scripts.
|
|
21
21
|
|
|
22
22
|
import fs from 'node:fs';
|
|
23
23
|
import path from 'node:path';
|
|
24
24
|
import { pathToFileURL } from 'node:url';
|
|
25
25
|
|
|
26
|
-
export const MIN_PIPELINE_VERSION = '4.3.
|
|
26
|
+
export const MIN_PIPELINE_VERSION = '4.3.38'; // sync-version-ignore: a fixed pipeline floor, not the current version
|
|
27
27
|
const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
28
28
|
const HEX40 = /^[0-9a-f]{40}$/;
|
|
29
29
|
const CODE_TAG = /^v\d+\.\d+\.\d+$/;
|
|
@@ -56,3 +56,52 @@ export function evaluateCorpusPromotion({ tag, generation, currentLatest } = {})
|
|
|
56
56
|
}
|
|
57
57
|
return { allowed: true, reason: `newer than ${currentLatest.tagName} (${published.value})` };
|
|
58
58
|
}
|
|
59
|
+
|
|
60
|
+
// ── THE CONSUMER'S CONSENT (customer canary) ─────────────────────────────────────────────────────
|
|
61
|
+
// The producer cannot declare success unless the consumer accepted. A corpus generation is staged as
|
|
62
|
+
// a prerelease (never latest); scripts/corpus-canary.mjs installs the approved runtime on a clean,
|
|
63
|
+
// secret-free runner and applies the staged candidate through that install's own updater; and
|
|
64
|
+
// promotion to releases/latest requires the canary's PASS verdict for THIS run, over EXACTLY the
|
|
65
|
+
// asset bytes still on the release. Every check below is required: a verdict that omits one, or a
|
|
66
|
+
// release whose assets changed after the canary downloaded them, is not consent.
|
|
67
|
+
export const CANARY_VERDICT_KIND = 'ruvnet-brain-corpus-canary-verdict';
|
|
68
|
+
export const REQUIRED_CANARY_CHECKS = Object.freeze([
|
|
69
|
+
'candidate-release', 'updater-exit', 'signature-verified', 'staged-coverage', 'source-advanced',
|
|
70
|
+
'runtime-identity', 'node-modules', 'single-kb-tree', 'store-freshness',
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
const assetKey = (asset) => `${asset?.name}|${asset?.digest}`;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param verdict the canary verdict JSON (scripts/corpus-canary.mjs)
|
|
77
|
+
* @param tag the staged corpus tag about to be promoted
|
|
78
|
+
* @param runId GITHUB_RUN_ID of the promoting run — the verdict must come from this run
|
|
79
|
+
* @param runAttempt GITHUB_RUN_ATTEMPT of the promoting run
|
|
80
|
+
* @param approvedTag vX.Y.Z the corpus was built at
|
|
81
|
+
* @param releaseAssets the release's assets as they are NOW ([{ name, digest }])
|
|
82
|
+
*/
|
|
83
|
+
export function evaluateCanaryVerdict({ verdict, tag, runId, runAttempt, approvedTag, releaseAssets } = {}) {
|
|
84
|
+
const refuse = (reason) => ({ allowed: false, reason: `no customer consent: ${reason}` });
|
|
85
|
+
if (!verdict || verdict.kind !== CANARY_VERDICT_KIND || verdict.schemaVersion !== 1) return refuse('not a canary verdict');
|
|
86
|
+
if (verdict.verdict !== 'PASS') return refuse(`the customer canary reported ${verdict.verdict || '(no verdict)'}`);
|
|
87
|
+
if (!CORPUS_TAG_PATTERN.test(String(tag || '')) || verdict.tag !== tag) return refuse(`the verdict is for ${verdict.tag}, not ${tag}`);
|
|
88
|
+
if (verdict.archiveSha256 !== tag.slice('corpus-sha256-'.length)) return refuse('the verdict archive digest is not the tag digest');
|
|
89
|
+
if (String(verdict.runId ?? '') !== String(runId ?? '') || String(verdict.runAttempt ?? '') !== String(runAttempt ?? '')
|
|
90
|
+
|| !runId || !runAttempt) {
|
|
91
|
+
return refuse(`the verdict belongs to run ${verdict.runId}/${verdict.runAttempt}, not this run ${runId}/${runAttempt}`);
|
|
92
|
+
}
|
|
93
|
+
if (approvedTag !== `v${verdict.approvedVersion}`) return refuse(`the canary installed ${verdict.approvedVersion}, not ${approvedTag}`);
|
|
94
|
+
const checks = Array.isArray(verdict.checks) ? verdict.checks : [];
|
|
95
|
+
for (const name of REQUIRED_CANARY_CHECKS) {
|
|
96
|
+
const entry = checks.find((row) => row?.name === name);
|
|
97
|
+
if (!entry) return refuse(`required check ${name} is absent`);
|
|
98
|
+
if (entry.ok !== true) return refuse(`required check ${name} failed (${entry.detail || 'no detail'})`);
|
|
99
|
+
}
|
|
100
|
+
const tested = (verdict.assets || []).map(assetKey).sort();
|
|
101
|
+
const current = (releaseAssets || []).map(assetKey).sort();
|
|
102
|
+
if (!tested.length || tested.some((key) => key.endsWith('|null') || key.endsWith('|undefined'))
|
|
103
|
+
|| JSON.stringify(tested) !== JSON.stringify(current)) {
|
|
104
|
+
return refuse('the release assets are not byte-for-byte the ones the canary downloaded');
|
|
105
|
+
}
|
|
106
|
+
return { allowed: true, reason: `customer canary PASS in run ${runId}/${runAttempt} over ${tested.length} asset digest(s)` };
|
|
107
|
+
}
|
|
@@ -951,7 +951,7 @@ export async function reconcileAndPrepareCorpusCandidate({ assetsDir, workspaceD
|
|
|
951
951
|
reconcile = (options) => acquireCorpusGeneration(options),
|
|
952
952
|
normalizeUpdaters = normalizeUpdaterManifest,
|
|
953
953
|
accuracyOracleFile = null, accuracyStores = null, accuracySample = null, accuracySamplePerPartition = null,
|
|
954
|
-
accuracyTimeoutMs = null,
|
|
954
|
+
accuracyTimeoutMs = null, seedArchive = null,
|
|
955
955
|
prepare = prepareCorpusCandidate } = {}) {
|
|
956
956
|
const finalized = await reconcile({ owner, assetsDir, workspaceDir, root, maxAttempts });
|
|
957
957
|
// Nothing the corpus is built from changed since the seed: nothing to normalize, seal or measure.
|
|
@@ -976,7 +976,7 @@ export async function reconcileAndPrepareCorpusCandidate({ assetsDir, workspaceD
|
|
|
976
976
|
const candidate = await prepare({
|
|
977
977
|
root, assetsDir, builderSha, candidateDir, receiptFile, coverageFile, bootstrapIdentity,
|
|
978
978
|
coverage: finalized.coverage,
|
|
979
|
-
accuracyOracleFile, accuracyStores, accuracySample, accuracySamplePerPartition, accuracyTimeoutMs,
|
|
979
|
+
accuracyOracleFile, accuracyStores, accuracySample, accuracySamplePerPartition, accuracyTimeoutMs, seedArchive,
|
|
980
980
|
});
|
|
981
981
|
return { reconciliation: finalized, updaters, candidate };
|
|
982
982
|
}
|
|
@@ -998,6 +998,12 @@ export function prepareCorpusCandidate({
|
|
|
998
998
|
accuracySample = null,
|
|
999
999
|
accuracySamplePerPartition = null,
|
|
1000
1000
|
accuracyTimeoutMs = null,
|
|
1001
|
+
// { file, tag, sha256 } of the seed archive this generation was reconciled from. When present, the
|
|
1002
|
+
// archive is assembled WITH its release coverage projection (COVERAGE.json + CORPUS-COVERAGE.json +
|
|
1003
|
+
// PUBLIC-RVF-GENERATIONS.json), exactly as scripts/code-release-corpus.mjs's single-pass path does:
|
|
1004
|
+
// kb/forge-update.mjs refuses any staged tree without it, so a nightly archive lacking it can never
|
|
1005
|
+
// reach an installed customer (measured 2026-09-30: "COVERAGE.json is missing").
|
|
1006
|
+
seedArchive = null,
|
|
1001
1007
|
run = defaultRun,
|
|
1002
1008
|
}) {
|
|
1003
1009
|
const sourceRoot = path.resolve(root);
|
|
@@ -1053,8 +1059,25 @@ export function prepareCorpusCandidate({
|
|
|
1053
1059
|
const markdownPath = path.join(sourceRoot, 'docs', 'RUVNET-COVERAGE.md');
|
|
1054
1060
|
fs.mkdirSync(path.dirname(markdownPath), { recursive: true });
|
|
1055
1061
|
fs.writeFileSync(markdownPath, renderMarkdown(coverage));
|
|
1062
|
+
// The seed's baseline observation, then the SAME build-bundle seed flags the code release passes.
|
|
1063
|
+
// The release identity is the runtime this corpus is built at: package.json's version (build-bundle's
|
|
1064
|
+
// default) and the builder checkout's exact commit.
|
|
1065
|
+
let seedArgs = [];
|
|
1066
|
+
if (seedArchive) {
|
|
1067
|
+
const seedFile = path.resolve(seedArchive.file || '');
|
|
1068
|
+
if (!fs.existsSync(seedFile) || !HEX64.test(String(seedArchive.sha256 || '')) || !seedArchive.tag) {
|
|
1069
|
+
fail('seed archive identity is incomplete (file, tag and sha256 are required together)');
|
|
1070
|
+
}
|
|
1071
|
+
const seedBytes = fs.statSync(seedFile).size;
|
|
1072
|
+
const baselineReceipt = path.join(path.dirname(receipt), `baseline-observation-receipt-${seedArchive.sha256}.json`);
|
|
1073
|
+
checked(run, process.execPath, [path.join(sourceRoot, 'scripts', 'public-verification-inputs.mjs'), 'observe-baseline',
|
|
1074
|
+
'--baseline-bundle', seedFile, '--expected-tag', seedArchive.tag, '--expected-sha256', seedArchive.sha256,
|
|
1075
|
+
'--expected-bytes', String(seedBytes), '--out', baselineReceipt], { stdio: 'inherit' });
|
|
1076
|
+
seedArgs = ['--seed-tag', seedArchive.tag, '--seed-sha256', seedArchive.sha256, '--seed-bytes', String(seedBytes),
|
|
1077
|
+
'--baseline-receipt-sha256', sha256File(baselineReceipt), '--source-snapshot', String(builderSha).toLowerCase()];
|
|
1078
|
+
}
|
|
1056
1079
|
checked(run, process.execPath, [buildScript, '--assets', assets, '--out', candidate,
|
|
1057
|
-
'--coverage', policy], { stdio: 'inherit' });
|
|
1080
|
+
'--coverage', policy, ...seedArgs], { stdio: 'inherit' });
|
|
1058
1081
|
const bundleFile = path.join(path.dirname(candidate), `${path.basename(candidate)}.zip`);
|
|
1059
1082
|
// ADR-086 Step 15: the benchmark runs HERE — after single-pass assembly and before the seal —
|
|
1060
1083
|
// against the EXTRACTED final archive through the customer query path, never against `assets`.
|
|
@@ -1217,6 +1240,7 @@ export async function main(argv = process.argv.slice(2), {
|
|
|
1217
1240
|
const { reconciliation, candidate, noChange = false } = await reconcileAndPrepare({
|
|
1218
1241
|
assetsDir, workspaceDir, root, owner, builderSha, candidateDir, receiptFile, coverageFile, bootstrapIdentity,
|
|
1219
1242
|
accuracyOracleFile, accuracyStores, accuracySample, accuracySamplePerPartition, accuracyTimeoutMs,
|
|
1243
|
+
seedArchive: { file: archiveFile, tag: bootstrap.tag, sha256: bootstrap.sha256 },
|
|
1220
1244
|
});
|
|
1221
1245
|
if (noChangeOut) {
|
|
1222
1246
|
fs.mkdirSync(path.dirname(path.resolve(noChangeOut)), { recursive: true });
|