ruvnet-brain 4.3.9 → 4.3.11
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 +26 -9
- package/bin/install.mjs +646 -366
- package/bin/nightly-refresh.mjs +115 -0
- package/kb/brain-profile.mjs +118 -32
- package/kb/lifecycle-evidence-retention.mjs +237 -0
- package/kb/refresh-run.mjs +367 -0
- package/kb/retrieval-result.mjs +39 -0
- package/kb/update-storage-transaction.mjs +439 -0
- package/package.json +12 -3
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +2 -165
- package/plugin/hooks/hook-contracts.json +4 -65
- package/plugin/hooks/hooks.json +2 -208
- package/plugin/scripts/adr-currency-gate.mjs +29 -19
- package/plugin/scripts/capability-registry.mjs +10 -83
- package/plugin/scripts/codex-hook-adapter.mjs +62 -9
- package/plugin/scripts/codex-hook-wrapper.mjs +19 -1
- package/plugin/scripts/continuation-gate.mjs +30 -28
- package/plugin/scripts/continuation-objective.mjs +34 -0
- package/plugin/scripts/development-maintenance.mjs +49 -0
- package/plugin/scripts/hook-shim.mjs +3 -0
- package/plugin/scripts/learn-flush.mjs +6 -1
- package/plugin/scripts/lesson-gate.mjs +5 -2
- package/plugin/scripts/lesson-presentation.mjs +5 -0
- package/plugin/scripts/md-stamp.mjs +109 -6
- package/plugin/scripts/memory-doctor.mjs +3 -7
- package/plugin/scripts/nightly-controller.mjs +9 -27
- package/plugin/scripts/nightly-scheduler.mjs +494 -0
- package/plugin/scripts/project-progression-contract.mjs +4 -4
- package/plugin/scripts/project-progression-store.mjs +35 -6
- package/plugin/scripts/ruflo-bin.mjs +20 -0
- package/plugin/scripts/session-snapshot-contract.mjs +6 -1
- package/plugin/scripts/version-bump-gate.sh +14 -2
- package/plugin/skills/release-proof/SKILL.md +6 -2
- package/plugin/skills/release-proof/references/receipt-contract.md +31 -2
- package/plugin/skills/release-proof/scripts/release-proof.mjs +11 -2
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +15 -23
- package/scripts/build-bundle.mjs +8 -0
- package/scripts/build-primer.mjs +2 -3
- package/scripts/candidate-host-evidence.mjs +59 -41
- package/scripts/ci/build-fixture-kb.mjs +59 -2
- package/scripts/ci/mutate-hook-timeout.mjs +24 -47
- package/scripts/ci/stranger-scenario.mjs +13 -28
- package/scripts/claims-verify.mjs +219 -21
- package/scripts/console-engine.mjs +4 -18
- package/scripts/console-runtime-identity.mjs +6 -0
- package/scripts/development-maintenance.mjs +47 -0
- package/scripts/development-push-check.mjs +35 -0
- package/scripts/distill-project.mjs +24 -47
- package/scripts/doc-currency.mjs +91 -19
- package/scripts/git-hooks/pre-push +4 -162
- package/scripts/health-repair.mjs +22 -4
- package/scripts/hook-retirement-check.mjs +22 -0
- package/scripts/host-install-matrix.mjs +125 -61
- package/scripts/integration-evidence.mjs +11 -6
- package/scripts/learning-replay-fixture.mjs +23 -2
- package/scripts/nightly-package-observation.mjs +67 -0
- package/scripts/nightly-two-run-proof.mjs +489 -0
- package/scripts/nightly-watchdog.mjs +17 -2
- package/scripts/npm-invocation.mjs +20 -0
- package/scripts/prepublication-evidence.mjs +42 -6
- package/scripts/primer-grounding.mjs +20 -0
- package/scripts/product-integrity-contract.mjs +22 -2
- package/scripts/public-verification-abandon.mjs +101 -0
- package/scripts/public-verification-aggregate.mjs +61 -19
- package/scripts/public-verification-finalizer.mjs +8 -2
- package/scripts/public-verification-lane.mjs +64 -8
- package/scripts/publication-receipt.mjs +156 -50
- package/scripts/published-surface-probe.mjs +10 -2
- package/scripts/qa-contract.mjs +57 -0
- package/scripts/qa-lanes.mjs +40 -0
- package/scripts/qa-runner.mjs +54 -64
- package/scripts/qe/ux-suite.mjs +10 -34
- package/scripts/qualified-candidate-check.mjs +124 -0
- package/scripts/release-abort-stale.mjs +2 -2
- package/scripts/release-projection.mjs +20 -1
- package/scripts/release-qualification-contract.mjs +87 -0
- package/scripts/release-qualification.mjs +135 -0
- package/scripts/release-transaction.mjs +81 -1
- package/scripts/remedy-registry.mjs +6 -13
- package/scripts/retrieval-canary.mjs +91 -18
- package/scripts/selfcheck.mjs +12 -4
- package/scripts/snapshot-freshness.mjs +58 -0
- package/scripts/stack-sync.mjs +41 -43
- package/scripts/staged-host-verifier.mjs +63 -14
- package/scripts/wired-check.mjs +31 -2
|
@@ -3,11 +3,12 @@ import crypto from 'node:crypto';
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import osModule from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
7
8
|
import { canonicalJson, digest, validateCoverageLedger } from './coverage-integrity.mjs';
|
|
8
9
|
import { validateHostRegistry } from './host-registry.mjs';
|
|
9
10
|
import { RECEIPT_MODE_NAMES } from './host-install-matrix.mjs';
|
|
10
|
-
import { generatePublicationReceipt, livePublicationAdapter } from './publication-receipt.mjs';
|
|
11
|
+
import { generatePublicationReceipt, livePublicationAdapter, validateCandidateSource } from './publication-receipt.mjs';
|
|
11
12
|
import {
|
|
12
13
|
createPublicVerificationLeaf,
|
|
13
14
|
PUBLIC_VERIFICATION_MODES,
|
|
@@ -29,12 +30,13 @@ function failedCanaryCases(retrieval) {
|
|
|
29
30
|
status !== 'COMPLETED' || retrievalHit !== true || (cohort === 'delta' && citationResolved !== true));
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
function publicVerificationFailure({ os, identity, failures, completedLeaves }) {
|
|
33
|
+
function publicVerificationFailure({ os, identity, verifierSha, failures, completedLeaves }) {
|
|
33
34
|
const payload = {
|
|
34
35
|
schemaVersion: 1,
|
|
35
36
|
kind: 'ruvnet-brain-public-verification-failure',
|
|
36
37
|
os,
|
|
37
38
|
sourceSha: identity.candidateSha,
|
|
39
|
+
...(verifierSha === undefined ? {} : { verifierSha }),
|
|
38
40
|
artifactSha256: identity.packageSha256,
|
|
39
41
|
bundleSha256: identity.bundleSha256,
|
|
40
42
|
failures: failures.map(({ mode, reason, retrieval }) => ({
|
|
@@ -102,6 +104,9 @@ function coverageProjection(coverage) {
|
|
|
102
104
|
|
|
103
105
|
export async function createPublicVerificationLane({
|
|
104
106
|
os,
|
|
107
|
+
verifierSha,
|
|
108
|
+
workflowRunId,
|
|
109
|
+
publicKey,
|
|
105
110
|
candidate,
|
|
106
111
|
publication,
|
|
107
112
|
identity,
|
|
@@ -112,6 +117,8 @@ export async function createPublicVerificationLane({
|
|
|
112
117
|
adapter,
|
|
113
118
|
} = {}) {
|
|
114
119
|
if (!PUBLIC_VERIFICATION_OS.includes(os)) throw new Error(`unsupported public verification OS: ${os || '(missing)'}`);
|
|
120
|
+
if (verifierSha !== undefined && !HEX40.test(String(verifierSha))) throw new Error('verifier SHA must be a full commit identity');
|
|
121
|
+
if (!/^[1-9][0-9]*$/.test(String(workflowRunId || ''))) throw new Error('workflow run ID is required');
|
|
115
122
|
validateReleaseIdentity(identity);
|
|
116
123
|
validatePublicEvidence({ candidate, publication, identity });
|
|
117
124
|
const registry = validateHostRegistry(hostRegistry);
|
|
@@ -132,7 +139,9 @@ export async function createPublicVerificationLane({
|
|
|
132
139
|
throw new Error('public verification lane adapter is incomplete');
|
|
133
140
|
}
|
|
134
141
|
const common = {
|
|
142
|
+
workflowRunId: String(workflowRunId),
|
|
135
143
|
sourceSha: identity.candidateSha,
|
|
144
|
+
...(verifierSha === undefined ? {} : { verifierSha }),
|
|
136
145
|
version: identity.version,
|
|
137
146
|
tag: identity.tag,
|
|
138
147
|
artifactSha256: identity.packageSha256,
|
|
@@ -169,8 +178,15 @@ export async function createPublicVerificationLane({
|
|
|
169
178
|
failures.push({ mode, reason: error.message, retrieval });
|
|
170
179
|
continue;
|
|
171
180
|
}
|
|
181
|
+
let nativeNightly;
|
|
182
|
+
if (mode === 'dual') {
|
|
183
|
+
if (typeof adapter.runNativeNightly !== 'function') throw new Error('native nightly proof adapter is required');
|
|
184
|
+
nativeNightly = await adapter.runNativeNightly({ identity, workflowRunId: String(workflowRunId) });
|
|
185
|
+
}
|
|
172
186
|
leaves.push(createPublicVerificationLeaf({
|
|
173
187
|
...common,
|
|
188
|
+
...(publication.acceptancePolicy ? { acceptancePolicy: publication.acceptancePolicy, searchTiming: { firstSearchMs: installed.searchMs, broadMs: publication.brain.broadMs, deadlineMs: publication.brain.deadlineMs } } : {}),
|
|
189
|
+
...(nativeNightly === undefined ? {} : { nativeNightly }),
|
|
174
190
|
os,
|
|
175
191
|
mode,
|
|
176
192
|
status: verifiedRetrieval.receiptSha256 === retrieval.receiptSha256 ? 'completed' : 'failed',
|
|
@@ -183,11 +199,11 @@ export async function createPublicVerificationLane({
|
|
|
183
199
|
untested: [],
|
|
184
200
|
skipped: 0,
|
|
185
201
|
unknown: 0,
|
|
186
|
-
}));
|
|
202
|
+
}, { publicKey }));
|
|
187
203
|
}
|
|
188
204
|
if (failures.length) {
|
|
189
205
|
const error = new Error(`retrieval canary acceptance failed for ${failures.map(({ mode }) => mode).join(', ')}`);
|
|
190
|
-
error.publicVerificationFailure = publicVerificationFailure({ os, identity, failures, completedLeaves: leaves });
|
|
206
|
+
error.publicVerificationFailure = publicVerificationFailure({ os, identity, verifierSha, failures, completedLeaves: leaves });
|
|
191
207
|
throw error;
|
|
192
208
|
}
|
|
193
209
|
if (canonicalJson(leaves.map(({ mode }) => mode)) !== canonicalJson(PUBLIC_VERIFICATION_MODES)) {
|
|
@@ -206,8 +222,22 @@ function regularJson(file, label) {
|
|
|
206
222
|
return { absolute, bytes: fs.readFileSync(absolute), value: JSON.parse(fs.readFileSync(absolute, 'utf8')) };
|
|
207
223
|
}
|
|
208
224
|
|
|
225
|
+
export function resolveVerifierSha(root, expected) {
|
|
226
|
+
const options = { cwd: root, encoding: 'utf8' };
|
|
227
|
+
const actual = execFileSync('git', ['rev-parse', 'HEAD'], options).trim();
|
|
228
|
+
if (!HEX40.test(actual) || (expected !== undefined && expected !== actual)) {
|
|
229
|
+
throw new Error('verifier SHA differs from the executing checkout');
|
|
230
|
+
}
|
|
231
|
+
if (execFileSync('git', ['status', '--porcelain', '--untracked-files=no'], options).trim()) {
|
|
232
|
+
throw new Error('verifier checkout has tracked changes');
|
|
233
|
+
}
|
|
234
|
+
return actual;
|
|
235
|
+
}
|
|
236
|
+
|
|
209
237
|
export async function generatePublicVerificationLane({
|
|
210
238
|
os,
|
|
239
|
+
verifierSha: expectedVerifierSha,
|
|
240
|
+
workflowRunId,
|
|
211
241
|
candidatePath,
|
|
212
242
|
identityPath,
|
|
213
243
|
coveragePath,
|
|
@@ -215,10 +245,19 @@ export async function generatePublicVerificationLane({
|
|
|
215
245
|
registryPath,
|
|
216
246
|
outPath,
|
|
217
247
|
root = process.cwd(),
|
|
218
|
-
|
|
248
|
+
candidateRoot = root,
|
|
249
|
+
adapter = livePublicationAdapter({ root, candidateRoot }),
|
|
219
250
|
} = {}) {
|
|
220
251
|
if (os !== hostOperatingSystem()) throw new Error(`runner OS ${hostOperatingSystem() || process.platform} cannot produce ${os || '(missing)'}`);
|
|
252
|
+
const rootDirectory = fs.statSync(root, { bigint: true });
|
|
253
|
+
const scriptDirectory = fs.statSync(fileURLToPath(new URL('..', import.meta.url)), { bigint: true });
|
|
254
|
+
if (!rootDirectory.isDirectory() || rootDirectory.ino <= 0n
|
|
255
|
+
|| rootDirectory.dev !== scriptDirectory.dev || rootDirectory.ino !== scriptDirectory.ino) {
|
|
256
|
+
throw new Error('verifier root differs from the executing script checkout');
|
|
257
|
+
}
|
|
258
|
+
const verifierSha = resolveVerifierSha(root, expectedVerifierSha);
|
|
221
259
|
const candidate = regularJson(candidatePath, 'candidate receipt');
|
|
260
|
+
validateCandidateSource(candidateRoot, candidate.value);
|
|
222
261
|
const identity = regularJson(identityPath, 'release identity');
|
|
223
262
|
const releaseCoverage = regularJson(coveragePath, 'release coverage');
|
|
224
263
|
const retrievalPlan = regularJson(planPath, 'retrieval plan');
|
|
@@ -234,6 +273,8 @@ export async function generatePublicVerificationLane({
|
|
|
234
273
|
const publication = JSON.parse(fs.readFileSync(publicationPath, 'utf8'));
|
|
235
274
|
const leaves = await createPublicVerificationLane({
|
|
236
275
|
os,
|
|
276
|
+
verifierSha,
|
|
277
|
+
workflowRunId,
|
|
237
278
|
candidate: candidate.value,
|
|
238
279
|
publication,
|
|
239
280
|
identity: identity.value,
|
|
@@ -244,15 +285,27 @@ export async function generatePublicVerificationLane({
|
|
|
244
285
|
hostRegistry: hostRegistry.value,
|
|
245
286
|
adapter,
|
|
246
287
|
});
|
|
247
|
-
|
|
288
|
+
resolveVerifierSha(root, verifierSha);
|
|
289
|
+
validateCandidateSource(candidateRoot, candidate.value);
|
|
290
|
+
const payload = { schemaVersion: 1, kind: 'ruvnet-brain-public-verification-os-lane', os,
|
|
291
|
+
verifierSha, sourceSha: identity.value.candidateSha, leaves };
|
|
248
292
|
const receipt = { ...payload, laneSha256: digest(payload) };
|
|
249
293
|
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
250
294
|
fs.writeFileSync(output, `${JSON.stringify(receipt, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
251
295
|
return receipt;
|
|
252
296
|
} catch (error) {
|
|
253
|
-
if (
|
|
297
|
+
if (!fs.existsSync(output)) {
|
|
298
|
+
const failure = error.publicVerificationFailure || (() => {
|
|
299
|
+
const payload = { schemaVersion: 1, kind: 'ruvnet-brain-public-verification-failure',
|
|
300
|
+
os, verifierSha, sourceSha: identity.value.candidateSha,
|
|
301
|
+
artifactSha256: identity.value.packageSha256, bundleSha256: identity.value.bundleSha256,
|
|
302
|
+
workflowRunId: String(workflowRunId || ''),
|
|
303
|
+
failures: [{ stage: 'public-install-and-native-update', reason: String(error.message).slice(0, 2000) }],
|
|
304
|
+
completedLeaves: [] };
|
|
305
|
+
return { ...payload, failureSha256: digest(payload) };
|
|
306
|
+
})();
|
|
254
307
|
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
255
|
-
fs.writeFileSync(output, `${JSON.stringify(
|
|
308
|
+
fs.writeFileSync(output, `${JSON.stringify(failure, null, 2)}\n`,
|
|
256
309
|
{ flag: 'wx', mode: 0o600 });
|
|
257
310
|
}
|
|
258
311
|
throw error;
|
|
@@ -271,6 +324,9 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
271
324
|
try {
|
|
272
325
|
const receipt = await generatePublicVerificationLane({
|
|
273
326
|
os: argument(args, '--os'),
|
|
327
|
+
verifierSha: argument(args, '--verifier-sha') ?? undefined,
|
|
328
|
+
workflowRunId: argument(args, '--workflow-run-id'),
|
|
329
|
+
candidateRoot: argument(args, '--candidate-root') ?? undefined,
|
|
274
330
|
candidatePath: argument(args, '--candidate'),
|
|
275
331
|
identityPath: argument(args, '--identity'),
|
|
276
332
|
coveragePath: argument(args, '--coverage'),
|
|
@@ -7,7 +7,7 @@ import os from 'node:os';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { pipeline } from 'node:stream/promises';
|
|
9
9
|
import { Readable } from 'node:stream';
|
|
10
|
-
import
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
11
|
import { spawn, spawnSync } from 'node:child_process';
|
|
12
12
|
// ONE doctor rule and ONE mode vocabulary, shared with the staged-side check in
|
|
13
13
|
// scripts/staged-host-verifier.mjs. This file kept its own copies, spelled claudeOnly/
|
|
@@ -15,12 +15,13 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
15
15
|
// `fixtures.claude` described the same fixture and nothing could tell. The richer
|
|
16
16
|
// post-publication proofs below (payload assertions, MCP wiring, SOURCE.json, rpcSearch)
|
|
17
17
|
// stay here — they are this side's job, not duplication.
|
|
18
|
-
import { HOST_MODES, RECEIPT_MODE_NAMES, MODE_FROM_RECEIPT_NAME, classifyDoctor, VARIANTS } from './host-install-matrix.mjs';
|
|
19
|
-
import { pathToFileURL } from 'node:url';
|
|
18
|
+
import { HOST_MODES, RECEIPT_MODE_NAMES, MODE_FROM_RECEIPT_NAME, classifyDoctor, VARIANTS, createInstalledMcpSession } from './host-install-matrix.mjs';
|
|
19
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
20
20
|
import { evaluateCandidateReceipt, evaluatePublicationReceipt } from './release-proof.mjs';
|
|
21
21
|
import { verifyPayload } from './release-payload.mjs';
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
22
|
+
import { resolveInstalledCanaryCitation } from './retrieval-canary.mjs';
|
|
23
|
+
import { runNightlyTwoRunProof, validateNightlyProofReceipt } from './nightly-two-run-proof.mjs';
|
|
24
|
+
import { parseRetrievalResult } from '../kb/retrieval-result.mjs';
|
|
24
25
|
|
|
25
26
|
const REPO = 'stuinfla/ruvnet-brain';
|
|
26
27
|
const PACKAGE = 'ruvnet-brain';
|
|
@@ -30,11 +31,24 @@ const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file
|
|
|
30
31
|
const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
31
32
|
const receiptDigest = (candidate) => String(candidate?.artifact?.sha256 || '').replace(/^sha256:/, '');
|
|
32
33
|
export function commandInvocation(name, args, { platform = process.platform, env = process.env } = {}) {
|
|
34
|
+
// Git Bash's GNU tar cannot consume native drive-letter extraction destinations.
|
|
35
|
+
// Select the Windows system tar explicitly so PATH order cannot change path semantics.
|
|
36
|
+
if (platform === 'win32' && name === 'tar') return {
|
|
37
|
+
executable: path.win32.join(env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe'), args,
|
|
38
|
+
};
|
|
33
39
|
if (platform === 'win32' && /^(?:npm|npx)$/i.test(name)) return {
|
|
34
40
|
executable: env.ComSpec || 'cmd.exe', args: ['/d', '/c', `${name}.cmd`, ...args],
|
|
35
41
|
};
|
|
36
42
|
return { executable: name, args };
|
|
37
43
|
}
|
|
44
|
+
export function tarExtractionInvocation(artifactPath, destination, { platform = process.platform } = {}) {
|
|
45
|
+
const paths = platform === 'win32' ? path.win32 : path;
|
|
46
|
+
const archive = paths.resolve(artifactPath);
|
|
47
|
+
// A relative archive argument avoids GNU tar's drive-letter remote-host syntax and also
|
|
48
|
+
// works with Windows BSD tar, which does not support GNU's --force-local option.
|
|
49
|
+
return { args: ['-xzf', `./${paths.basename(archive)}`, '-C', paths.resolve(destination)],
|
|
50
|
+
cwd: paths.dirname(archive) };
|
|
51
|
+
}
|
|
38
52
|
function command(name, args, options = {}) {
|
|
39
53
|
const { platform = process.platform, ...spawnOptions } = options;
|
|
40
54
|
const invocation = commandInvocation(name, args, { platform, env: spawnOptions.env || process.env });
|
|
@@ -45,13 +59,50 @@ function command(name, args, options = {}) {
|
|
|
45
59
|
}
|
|
46
60
|
return String(result.stdout || '').trim();
|
|
47
61
|
}
|
|
62
|
+
export function validateCandidateSource(root, { sha, version }) {
|
|
63
|
+
const candidateRoot = fs.realpathSync(root);
|
|
64
|
+
const options = { cwd: candidateRoot };
|
|
65
|
+
const top = command('git', ['rev-parse', '--show-toplevel'], options);
|
|
66
|
+
const head = command('git', ['rev-parse', 'HEAD'], options);
|
|
67
|
+
// Git and Node can spell the same Windows directory with different casing/separators.
|
|
68
|
+
// Compare actual directory identity, never case-fold distinct source paths.
|
|
69
|
+
const candidateDirectory = fs.statSync(candidateRoot, { bigint: true });
|
|
70
|
+
const topDirectory = fs.statSync(top, { bigint: true });
|
|
71
|
+
if (!candidateDirectory.isDirectory() || !topDirectory.isDirectory()
|
|
72
|
+
|| candidateDirectory.ino <= 0n || topDirectory.ino <= 0n
|
|
73
|
+
|| candidateDirectory.dev !== topDirectory.dev || candidateDirectory.ino !== topDirectory.ino
|
|
74
|
+
|| !/^[a-f0-9]{40}$/.test(String(sha)) || head !== sha) {
|
|
75
|
+
throw new Error(`candidate checkout ${head} does not match candidate ${sha}`);
|
|
76
|
+
}
|
|
77
|
+
if (command('git', ['status', '--porcelain', '--untracked-files=no'], options)) {
|
|
78
|
+
throw new Error('candidate checkout has tracked changes');
|
|
79
|
+
}
|
|
80
|
+
const manifest = path.join(candidateRoot, 'package.json');
|
|
81
|
+
const stat = fs.lstatSync(manifest);
|
|
82
|
+
if (!stat.isFile() || stat.isSymbolicLink() || !version || readJson(manifest).version !== version) {
|
|
83
|
+
throw new Error('candidate checkout package version differs from the released candidate');
|
|
84
|
+
}
|
|
85
|
+
return candidateRoot;
|
|
86
|
+
}
|
|
48
87
|
function locate(name, { platform = process.platform } = {}) {
|
|
49
|
-
const query = platform === 'win32' ? (
|
|
88
|
+
const query = platform === 'win32' ? (['node', 'git'].includes(name) ? `${name}.exe` : `${name}.cmd`) : name;
|
|
50
89
|
try {
|
|
51
90
|
return command(platform === 'win32' ? 'where.exe' : 'which', [query], { platform })
|
|
52
91
|
.split(/\r?\n/).find(Boolean) || null;
|
|
53
92
|
} catch { return null; }
|
|
54
93
|
}
|
|
94
|
+
export function nativeCodexExecutable({ platform = process.platform, arch = process.arch,
|
|
95
|
+
wrapper = platform === 'win32' ? locate('codex') : null } = {}) {
|
|
96
|
+
if (platform !== 'win32') return null;
|
|
97
|
+
if (!wrapper || !['x64', 'arm64'].includes(arch)) throw new Error('native Windows Codex CLI unavailable');
|
|
98
|
+
const require = createRequire(import.meta.url);
|
|
99
|
+
const packageFile = require.resolve('@openai/codex/package.json', { paths: [path.dirname(wrapper)] });
|
|
100
|
+
const platformFile = require.resolve(`@openai/codex-win32-${arch}/package.json`, { paths: [path.dirname(packageFile)] });
|
|
101
|
+
const target = arch === 'x64' ? 'x86_64' : 'aarch64';
|
|
102
|
+
const executable = path.join(path.dirname(platformFile), 'vendor', `${target}-pc-windows-msvc`, 'bin', 'codex.exe');
|
|
103
|
+
if (!fs.statSync(executable).isFile()) throw new Error('native Windows Codex executable missing');
|
|
104
|
+
return executable;
|
|
105
|
+
}
|
|
55
106
|
export function createIsolatedPath(mode, temp, { platform = process.platform, env = process.env,
|
|
56
107
|
resolve = (name) => locate(name, { platform }) } = {}) {
|
|
57
108
|
const bin = path.join(temp, `bin-${mode}`);
|
|
@@ -70,7 +121,10 @@ export function createIsolatedPath(mode, temp, { platform = process.platform, en
|
|
|
70
121
|
if (platform !== 'win32') return `${bin}:/usr/bin:/bin`;
|
|
71
122
|
const node = resolve('node');
|
|
72
123
|
if (!node) throw new Error(`node CLI unavailable for ${mode} public host fixture`);
|
|
124
|
+
const git = resolve('git');
|
|
125
|
+
if (!git) throw new Error(`git CLI unavailable for ${mode} public host fixture`);
|
|
73
126
|
return [bin, path.dirname(node), path.join(env.SystemRoot || 'C:\\Windows', 'System32'),
|
|
127
|
+
path.dirname(git),
|
|
74
128
|
path.join(env.ProgramFiles || 'C:\\Program Files', 'Git', 'usr', 'bin')].join(path.win32.delimiter);
|
|
75
129
|
}
|
|
76
130
|
export function stageVerifiedBundle({ bundlePath, bundleSha256, packageRoot }) {
|
|
@@ -166,18 +220,35 @@ export function assertInstalledPayload(sourceRoot, installedRoot) {
|
|
|
166
220
|
return checked;
|
|
167
221
|
}
|
|
168
222
|
|
|
169
|
-
export function rpcSearch(server, env, query, k = 5, timeoutMs = DEADLINE_MS
|
|
223
|
+
export function rpcSearch(server, env, query, k = 5, timeoutMs = DEADLINE_MS, {
|
|
224
|
+
requiredRepo = 'ruvnet-brain',
|
|
225
|
+
} = {}) {
|
|
170
226
|
if (!Number.isSafeInteger(k) || k < 1 || k > 50) throw new Error(`invalid search result count: ${k}`);
|
|
171
227
|
return new Promise((resolve, reject) => {
|
|
172
228
|
const child = spawn(process.execPath, [server], { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
229
|
+
// Preserve multibyte characters across pipe chunks before checking source-content hashes.
|
|
230
|
+
child.stdout.setEncoding('utf8');
|
|
231
|
+
child.stderr.setEncoding('utf8');
|
|
173
232
|
let stdout = '';
|
|
174
233
|
let stderr = '';
|
|
175
234
|
let timer;
|
|
235
|
+
let killTimer;
|
|
236
|
+
let closeTimer;
|
|
237
|
+
let outcome;
|
|
176
238
|
const pending = new Map();
|
|
177
239
|
const finish = (error, value) => {
|
|
240
|
+
if (outcome) return;
|
|
241
|
+
outcome = { error, value };
|
|
178
242
|
clearTimeout(timer);
|
|
243
|
+
pending.clear();
|
|
244
|
+
// Do not let the next doctor/search probe contend with this process while it exits.
|
|
245
|
+
// A child that ignores graceful shutdown must not leave the verifier hanging either.
|
|
246
|
+
killTimer = setTimeout(() => {
|
|
247
|
+
outcome.error = new Error('installed Brain did not shut down within 5000ms');
|
|
248
|
+
child.kill('SIGKILL');
|
|
249
|
+
closeTimer = setTimeout(() => reject(new Error('installed Brain process did not close after SIGKILL')), 1000);
|
|
250
|
+
}, 5000);
|
|
179
251
|
child.kill('SIGTERM');
|
|
180
|
-
error ? reject(error) : resolve(value);
|
|
181
252
|
};
|
|
182
253
|
timer = setTimeout(() => finish(new Error(`installed Brain exceeded ${timeoutMs}ms deadline`)), timeoutMs);
|
|
183
254
|
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
@@ -195,8 +266,12 @@ export function rpcSearch(server, env, query, k = 5, timeoutMs = DEADLINE_MS) {
|
|
|
195
266
|
}
|
|
196
267
|
});
|
|
197
268
|
child.on('error', (error) => finish(error));
|
|
198
|
-
child.on('
|
|
199
|
-
|
|
269
|
+
child.on('close', (code) => {
|
|
270
|
+
clearTimeout(timer);
|
|
271
|
+
clearTimeout(killTimer);
|
|
272
|
+
clearTimeout(closeTimer);
|
|
273
|
+
const result = outcome || { error: new Error(`installed Brain exited ${code}: ${stderr.slice(0, 400)}`) };
|
|
274
|
+
result.error ? reject(result.error) : resolve(result.value);
|
|
200
275
|
});
|
|
201
276
|
const call = (id, method, params = {}) => new Promise((done) => {
|
|
202
277
|
pending.set(id, done);
|
|
@@ -212,14 +287,19 @@ export function rpcSearch(server, env, query, k = 5, timeoutMs = DEADLINE_MS) {
|
|
|
212
287
|
const broadMs = Math.round(performance.now() - started);
|
|
213
288
|
const text = (searched.result?.content || []).map((item) => item.text || '').join('\n');
|
|
214
289
|
if (searched.error || searched.result?.isError || /search_ruvnet error:/i.test(text)) throw new Error(`installed Brain search failed: ${text.slice(0, 400)}`);
|
|
215
|
-
if (!/repo
|
|
216
|
-
|
|
290
|
+
if (!/repo=/i.test(text) || !/path\s*:/i.test(text)) throw new Error('installed Brain search returned no source citation');
|
|
291
|
+
const repos = [...text.matchAll(/repo=([\w.-]+)/gi)].map((match) => match[1].toLowerCase());
|
|
292
|
+
if (requiredRepo && !repos.includes(String(requiredRepo).toLowerCase())) {
|
|
293
|
+
throw new Error(`installed Brain search returned no ${requiredRepo} source citation`);
|
|
294
|
+
}
|
|
295
|
+
finish(null, { broadMs, text, mcpResult: searched.result });
|
|
217
296
|
})().catch((error) => finish(error));
|
|
218
297
|
});
|
|
219
298
|
}
|
|
220
299
|
|
|
221
|
-
export function livePublicationAdapter({ root = process.cwd() } = {}) {
|
|
300
|
+
export function livePublicationAdapter({ root = process.cwd(), candidateRoot = root } = {}) {
|
|
222
301
|
const installContexts = new Map();
|
|
302
|
+
const mcpSessions = new Map();
|
|
223
303
|
const passageFileDigests = new Map();
|
|
224
304
|
let installTemp = null;
|
|
225
305
|
return {
|
|
@@ -246,11 +326,40 @@ export function livePublicationAdapter({ root = process.cwd() } = {}) {
|
|
|
246
326
|
return { path: destination, tag: release.tag_name, sha: tagCommit(root, tag) };
|
|
247
327
|
},
|
|
248
328
|
|
|
329
|
+
async runNativeNightly({ identity, workflowRunId }) {
|
|
330
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-public-nightly-'));
|
|
331
|
+
let completed = false;
|
|
332
|
+
try {
|
|
333
|
+
const npm = await this.downloadNpm({ version: identity.version,
|
|
334
|
+
destination: path.join(temp, 'package.tgz') });
|
|
335
|
+
const bundle = await this.downloadGithub({ tag: identity.tag, assetName: 'ruvnet-brain.zip',
|
|
336
|
+
destination: path.join(temp, 'ruvnet-brain.zip') });
|
|
337
|
+
const signature = await this.downloadGithub({ tag: identity.tag, assetName: 'ruvnet-brain.zip.sig',
|
|
338
|
+
destination: path.join(temp, 'ruvnet-brain.zip.sig') });
|
|
339
|
+
if (sha256(npm.path) !== identity.packageSha256 || sha256(bundle.path) !== identity.bundleSha256
|
|
340
|
+
|| npm.version !== identity.version || bundle.sha !== identity.candidateSha
|
|
341
|
+
|| signature.sha !== identity.candidateSha) throw new Error('nightly public artifact identity mismatch');
|
|
342
|
+
const proof = await runNightlyTwoRunProof({ packagePath: npm.path, bundlePath: bundle.path,
|
|
343
|
+
signaturePath: signature.path, sourceSha: identity.candidateSha, workflowRunId,
|
|
344
|
+
out: path.join(temp, 'nightly-proof.json') });
|
|
345
|
+
const validation = validateNightlyProofReceipt(proof, { platform: process.platform, version: identity.version,
|
|
346
|
+
packageSha256: identity.packageSha256, bundleSha256: identity.bundleSha256,
|
|
347
|
+
sourceSha: identity.candidateSha, workflowRunId });
|
|
348
|
+
if (!validation.ok) throw new Error(`public nightly proof failed: ${validation.failures.join('; ')}`);
|
|
349
|
+
completed = true;
|
|
350
|
+
return proof;
|
|
351
|
+
} finally {
|
|
352
|
+
if (completed) fs.rmSync(temp, { recursive: true, force: true });
|
|
353
|
+
else console.error(`Native public artifact diagnostics retained at ${temp}`);
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
|
|
249
357
|
async installHosts({ artifactPath, artifactSha256, bundlePath, bundleSha256, version }) {
|
|
250
|
-
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-public-install-'));
|
|
358
|
+
const temp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-public-install-')));
|
|
251
359
|
installTemp = temp;
|
|
252
360
|
const packageRoot = path.join(temp, 'package');
|
|
253
|
-
|
|
361
|
+
const extraction = tarExtractionInvocation(artifactPath, temp);
|
|
362
|
+
command('tar', extraction.args, { cwd: extraction.cwd });
|
|
254
363
|
stageVerifiedBundle({ bundlePath, bundleSha256, packageRoot });
|
|
255
364
|
const sealedPlugin = path.join(packageRoot, 'plugin');
|
|
256
365
|
const results = {};
|
|
@@ -260,6 +369,7 @@ export function livePublicationAdapter({ root = process.cwd() } = {}) {
|
|
|
260
369
|
// Derived from HOST_MODES, so a fourth host shape is added in ONE place and this loop
|
|
261
370
|
// cannot fall behind the staged-side check the way it did.
|
|
262
371
|
for (const mode of HOST_MODES.map((m) => RECEIPT_MODE_NAMES[m])) {
|
|
372
|
+
console.log(`Public verification: installing ${mode} on ${process.platform}`);
|
|
263
373
|
const home = path.join(temp, `home-${mode}`);
|
|
264
374
|
const codexHome = path.join(home, '.codex');
|
|
265
375
|
const brainHome = path.join(home, '.cache', 'ruvnet-brain');
|
|
@@ -283,13 +393,20 @@ export function livePublicationAdapter({ root = process.cwd() } = {}) {
|
|
|
283
393
|
RUVNET_STRICT_INSTALL: '0',
|
|
284
394
|
CI: 'true',
|
|
285
395
|
PATH: createIsolatedPath(mode, temp),
|
|
396
|
+
...(process.platform === 'win32' && mode !== 'claudeOnly'
|
|
397
|
+
? { CODEX_BIN: nativeCodexExecutable() } : {}),
|
|
286
398
|
};
|
|
399
|
+
if (mode !== 'claudeOnly') {
|
|
400
|
+
command(env.CODEX_BIN || 'codex', ['--version'], { env, timeout: 30_000, stdio: 'inherit' });
|
|
401
|
+
command(env.CODEX_BIN || 'codex', ['plugin', 'list', '--json'], { env, timeout: 30_000, stdio: 'inherit' });
|
|
402
|
+
}
|
|
403
|
+
if (process.platform === 'win32') command('git', ['--version'], { env, timeout: 30_000, stdio: 'inherit' });
|
|
287
404
|
const installer = path.join(packageRoot, 'bin', 'install.mjs');
|
|
288
405
|
command(process.execPath, [
|
|
289
406
|
installer, '--yes', '--force', '--version', `v${version}`,
|
|
290
407
|
'--no-nightly-prompt', '--no-telemetry', '--no-stack', '--no-enhance', '--no-statusline',
|
|
291
408
|
'--no-selfcheck', '--no-verify',
|
|
292
|
-
], { env, cwd: packageRoot, timeout: 1_200_000,
|
|
409
|
+
], { env, cwd: packageRoot, timeout: 1_200_000, stdio: 'inherit' });
|
|
293
410
|
|
|
294
411
|
const verified = {};
|
|
295
412
|
if (mode !== 'codexOnly') {
|
|
@@ -310,7 +427,7 @@ export function livePublicationAdapter({ root = process.cwd() } = {}) {
|
|
|
310
427
|
const searched = await rpcSearch(findMcpServer(home), env,
|
|
311
428
|
'How does RuvNet Brain prove a public release artifact?', 5, DEADLINE_MS);
|
|
312
429
|
command(process.execPath, [installer, '--doctor', '--hooks'], {
|
|
313
|
-
env, cwd: packageRoot, timeout: 300_000,
|
|
430
|
+
env, cwd: packageRoot, timeout: 300_000, stdio: 'inherit',
|
|
314
431
|
});
|
|
315
432
|
results[mode] = {
|
|
316
433
|
status: 'PASS', doctorExit: 0, version, artifactSha256,
|
|
@@ -342,46 +459,32 @@ export function livePublicationAdapter({ root = process.cwd() } = {}) {
|
|
|
342
459
|
async searchInstalled({ mode, query, k }) {
|
|
343
460
|
const context = installContexts.get(mode);
|
|
344
461
|
if (!context) throw new Error(`${mode} public host is not installed`);
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
462
|
+
// Keep one MCP worker per installed host. Starting a fresh worker for every canary reloads
|
|
463
|
+
// the local embedding model repeatedly and can exceed the fixed 30-second public deadline
|
|
464
|
+
// on macOS, even when the installed Brain itself is healthy.
|
|
465
|
+
let session = mcpSessions.get(mode);
|
|
466
|
+
if (!session) {
|
|
467
|
+
session = createInstalledMcpSession({ serverPath: findMcpServer(context.home), env: context.env, timeout: DEADLINE_MS });
|
|
468
|
+
mcpSessions.set(mode, session);
|
|
469
|
+
}
|
|
470
|
+
const result = await session.search({ query, k });
|
|
471
|
+
return parseRetrievalResult(result.mcpResult, { query, k });
|
|
350
472
|
},
|
|
351
473
|
|
|
352
474
|
async resolveInstalledCitation({ mode, matched, expected }) {
|
|
353
475
|
const context = installContexts.get(mode);
|
|
354
476
|
if (!context) throw new Error(`${mode} public host is not installed`);
|
|
355
|
-
|
|
356
|
-
return { resolved: false };
|
|
357
|
-
}
|
|
358
|
-
const files = [
|
|
359
|
-
path.join(context.kb, `${expected.repo}.passages.jsonl`),
|
|
360
|
-
path.join(context.kb, `${expected.repo}.big.passages.jsonl`),
|
|
361
|
-
].filter((file) => fs.existsSync(file));
|
|
362
|
-
for (const file of files) {
|
|
363
|
-
const rows = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
|
|
364
|
-
try {
|
|
365
|
-
for await (const line of rows) {
|
|
366
|
-
let record;
|
|
367
|
-
try { record = JSON.parse(line); } catch { continue; }
|
|
368
|
-
if (record?.path !== expected.path) continue;
|
|
369
|
-
const passageSha256 = digest(record);
|
|
370
|
-
if (passageSha256 !== expected.passageSha256) return { resolved: false };
|
|
371
|
-
if (!passageFileDigests.has(file)) passageFileDigests.set(file, sha256File(file));
|
|
372
|
-
return { resolved: true, evidence: { passageSha256, passageFileSha256: passageFileDigests.get(file) } };
|
|
373
|
-
}
|
|
374
|
-
} finally { rows.close(); }
|
|
375
|
-
}
|
|
376
|
-
return { resolved: false };
|
|
477
|
+
return resolveInstalledCanaryCitation({ kbDir: context.kb, matched, expected, passageFileDigests });
|
|
377
478
|
},
|
|
378
479
|
|
|
379
|
-
async probePublishedSurface({ sha }) {
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
const
|
|
383
|
-
|
|
480
|
+
async probePublishedSurface({ sha, version }) {
|
|
481
|
+
const source = validateCandidateSource(candidateRoot, { sha, version });
|
|
482
|
+
// The verifier supplies executable checks; the candidate supplies only the released source.
|
|
483
|
+
const script = fileURLToPath(new URL('./published-surface-probe.mjs', import.meta.url));
|
|
484
|
+
const result = spawnSync(process.execPath, [script, '--json'], {
|
|
485
|
+
cwd: source, env: process.env, encoding: 'utf8', timeout: 600_000,
|
|
384
486
|
});
|
|
487
|
+
validateCandidateSource(candidateRoot, { sha, version });
|
|
385
488
|
let parsed;
|
|
386
489
|
try { parsed = JSON.parse(String(result.stdout || '')); } catch { throw new Error(`published-surface-probe emitted invalid JSON: ${String(result.stderr || '').slice(0, 300)}`); }
|
|
387
490
|
if (result.status !== 0 || parsed.verdict !== 'PASS') throw new Error(`published-surface-probe is ${parsed.verdict || `exit ${result.status}`}`);
|
|
@@ -389,6 +492,8 @@ export function livePublicationAdapter({ root = process.cwd() } = {}) {
|
|
|
389
492
|
},
|
|
390
493
|
|
|
391
494
|
async dispose() {
|
|
495
|
+
await Promise.all([...mcpSessions.values()].map((session) => session.close()));
|
|
496
|
+
mcpSessions.clear();
|
|
392
497
|
if (installTemp) fs.rmSync(installTemp, { recursive: true, force: true });
|
|
393
498
|
installTemp = null;
|
|
394
499
|
installContexts.clear();
|
|
@@ -449,6 +554,7 @@ export async function generatePublicationReceipt({
|
|
|
449
554
|
const publication = {
|
|
450
555
|
schemaVersion: 2,
|
|
451
556
|
phase: 'publication',
|
|
557
|
+
acceptancePolicy: 'stabilization-public-deadline-v1',
|
|
452
558
|
sha: candidate.sha,
|
|
453
559
|
artifactSha256: digest,
|
|
454
560
|
version: candidate.version,
|
|
@@ -468,7 +574,7 @@ export async function generatePublicationReceipt({
|
|
|
468
574
|
postPublicationChecks: [surface],
|
|
469
575
|
};
|
|
470
576
|
const result = evaluatePublicationReceipt(candidate, publication);
|
|
471
|
-
if (result.verdict !== 'PASS') throw new Error(`publication seal failed: ${
|
|
577
|
+
if (result.verdict !== 'PASS') throw new Error(`publication seal failed: ${JSON.stringify({ failures: result.failures, installed: publication.installed, brain: publication.brain })}`);
|
|
472
578
|
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
473
579
|
fs.writeFileSync(outPath, `${JSON.stringify(publication, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
474
580
|
return result;
|
|
@@ -52,6 +52,7 @@ import fs from 'node:fs';
|
|
|
52
52
|
import os from 'node:os';
|
|
53
53
|
import path from 'node:path';
|
|
54
54
|
import { spawnSync } from 'node:child_process';
|
|
55
|
+
import { fileURLToPath } from 'node:url';
|
|
55
56
|
|
|
56
57
|
export const EXIT = Object.freeze({ PASS: 0, FAIL: 1, UNKNOWN: 4 });
|
|
57
58
|
|
|
@@ -127,6 +128,12 @@ async function probeRegistry() {
|
|
|
127
128
|
}
|
|
128
129
|
|
|
129
130
|
// ── B. EXECUTE the published package, from the registry ────────────────────────────────────────
|
|
131
|
+
export function npxInvocation(args, { platform = process.platform, env = process.env } = {}) {
|
|
132
|
+
if (platform === 'win32') return {
|
|
133
|
+
executable: env.ComSpec || 'cmd.exe', args: ['/d', '/c', 'npx.cmd', ...args],
|
|
134
|
+
};
|
|
135
|
+
return { executable: 'npx', args };
|
|
136
|
+
}
|
|
130
137
|
function probeExec() {
|
|
131
138
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-brain-probe-'));
|
|
132
139
|
try {
|
|
@@ -134,7 +141,8 @@ function probeExec() {
|
|
|
134
141
|
// (npm resolve → download → unpack → bin mapping → node parses every module it imports) while
|
|
135
142
|
// touching NOTHING on the machine. `--doctor`/`--plan` reach the network and the brain dir; this
|
|
136
143
|
// probe must never be the reason a surface changes.
|
|
137
|
-
const
|
|
144
|
+
const invocation = npxInvocation(['-y', `${PKG}@latest`, '--help']);
|
|
145
|
+
const r = spawnSync(invocation.executable, invocation.args, {
|
|
138
146
|
encoding: 'utf8',
|
|
139
147
|
timeout: 300_000,
|
|
140
148
|
cwd: home,
|
|
@@ -237,5 +245,5 @@ export async function main() {
|
|
|
237
245
|
return EXIT[verdict];
|
|
238
246
|
}
|
|
239
247
|
|
|
240
|
-
const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) ===
|
|
248
|
+
const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
241
249
|
if (invokedDirectly) process.exit(await main());
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
export function verdictOf(results) {
|
|
7
|
+
if (results.some(({ status }) => ['FAIL', 'TIMEOUT', 'BLOCKED'].includes(status))) return 'FAIL';
|
|
8
|
+
return results.length && results.every(({ status }) => status === 'PASS') ? 'PASS' : 'UNKNOWN';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// A source digest includes dirty tracked files and untracked source, never ignored build outputs.
|
|
12
|
+
export function sourceIdentity(root) {
|
|
13
|
+
const git = (args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
14
|
+
let sha = null;
|
|
15
|
+
try { sha = git(['rev-parse', 'HEAD']).trim(); } catch { /* unborn repository */ }
|
|
16
|
+
const files = [...new Set(git(['ls-files', '-z', '--cached', '--others', '--exclude-standard']).split('\0').filter(Boolean))].sort();
|
|
17
|
+
const hash = createHash('sha256');
|
|
18
|
+
for (const relative of files) {
|
|
19
|
+
const file = path.join(root, relative);
|
|
20
|
+
let bytes = Buffer.from('deleted');
|
|
21
|
+
try {
|
|
22
|
+
const stat = fs.lstatSync(file);
|
|
23
|
+
bytes = stat.isSymbolicLink() ? Buffer.from(`symlink:${fs.readlinkSync(file)}`)
|
|
24
|
+
: stat.isFile() ? Buffer.concat([Buffer.from(`${stat.mode & 0o777}:`), fs.readFileSync(file)]) : Buffer.from('directory');
|
|
25
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
26
|
+
hash.update(JSON.stringify([relative, createHash('sha256').update(bytes).digest('hex')]));
|
|
27
|
+
}
|
|
28
|
+
return { sha, dirty: Boolean(git(['status', '--porcelain']).trim()), digest: hash.digest('hex'), files: files.length, recipe: 'git-source-bytes-v1' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function runLanes(lanes, execute, concurrency = 2) {
|
|
32
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) throw new Error('invalid QA concurrency');
|
|
33
|
+
const names = new Set(lanes.map(({ name }) => name));
|
|
34
|
+
if (names.size !== lanes.length || lanes.some((lane) => (lane.dependsOn || []).some((name) => !names.has(name)))) throw new Error('invalid QA dependency');
|
|
35
|
+
const pending = [...lanes], completed = new Map();
|
|
36
|
+
while (pending.length) {
|
|
37
|
+
const resources = new Set(), batch = [];
|
|
38
|
+
for (const lane of pending) {
|
|
39
|
+
if (!(lane.dependsOn || []).every((name) => completed.has(name))) continue;
|
|
40
|
+
if ((lane.dependsOn || []).some((name) => completed.get(name).status !== 'PASS')) {
|
|
41
|
+
completed.set(lane.name, { name: lane.name, status: 'BLOCKED', reason: 'required prerequisite did not pass' });
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (batch.length >= concurrency || (lane.resource && resources.has(lane.resource))) continue;
|
|
45
|
+
batch.push(lane); resources.add(lane.resource);
|
|
46
|
+
}
|
|
47
|
+
if (!batch.length && !pending.some(({ name }) => completed.has(name))) throw new Error('QA dependency cycle');
|
|
48
|
+
await Promise.all(batch.map(async (lane) => {
|
|
49
|
+
let result;
|
|
50
|
+
try { result = await execute(lane); }
|
|
51
|
+
catch (error) { result = { name: lane.name, status: 'FAIL', reason: error.message }; }
|
|
52
|
+
completed.set(lane.name, result);
|
|
53
|
+
}));
|
|
54
|
+
for (let index = pending.length - 1; index >= 0; index--) if (completed.has(pending[index].name)) pending.splice(index, 1);
|
|
55
|
+
}
|
|
56
|
+
return lanes.map(({ name }) => completed.get(name));
|
|
57
|
+
}
|