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
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
export const NIGHTLY_LABEL = 'com.ruvnet.brain-update';
|
|
8
|
+
export const NIGHTLY_HOUR = 3;
|
|
9
|
+
export const NIGHTLY_MINUTE = 47;
|
|
10
|
+
export const NIGHTLY_PROOF_LABEL = /^com\.ruvnet\.brain-update\.proof-[A-Za-z0-9._-]+$/;
|
|
11
|
+
export const NIGHTLY_ENV_ALLOWLIST = Object.freeze([
|
|
12
|
+
'PATH', 'HOME', 'USERPROFILE', 'RUVNET_BRAIN_HOME', 'RUVNET_BRAIN_KB', 'npm_config_cache', 'NO_COLOR', 'SystemRoot', 'SYSTEMROOT', 'ComSpec', 'COMSPEC', 'PATHEXT', 'TEMP', 'TMP',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
16
|
+
const xmlEscape = (value) => String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
17
|
+
const shellQuote = (value) => `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
18
|
+
const homeOf = (env) => env.HOME || env.USERPROFILE || os.homedir();
|
|
19
|
+
const SAFE_PHASE = /^[A-Za-z0-9._-]+$/;
|
|
20
|
+
|
|
21
|
+
function validateIdentity(identity) {
|
|
22
|
+
if (identity !== NIGHTLY_LABEL && !NIGHTLY_PROOF_LABEL.test(identity)) {
|
|
23
|
+
throw new Error(`unsafe nightly scheduler identity: ${identity || '(unset)'}`);
|
|
24
|
+
}
|
|
25
|
+
return identity;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function registrationName(identity) {
|
|
29
|
+
return identity === NIGHTLY_LABEL ? 'registration.json' : `registration-${identity}.json`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizePackageTarget(target = { spec: 'ruvnet-brain@latest', sha256: null }) {
|
|
33
|
+
const spec = String(target?.spec || '');
|
|
34
|
+
const digest = target?.sha256 ?? null;
|
|
35
|
+
if (spec === 'ruvnet-brain@latest' && digest === null) return { spec, sha256: null };
|
|
36
|
+
if (!path.isAbsolute(spec) || !spec.endsWith('.tgz') || !/^[a-f0-9]{64}$/.test(String(digest))) {
|
|
37
|
+
throw new Error('nightly proof package target must be an absolute tarball with a SHA-256 digest');
|
|
38
|
+
}
|
|
39
|
+
const actual = sha256(fs.readFileSync(spec));
|
|
40
|
+
if (actual !== digest) throw new Error('nightly proof package target digest mismatch');
|
|
41
|
+
return { spec: path.resolve(spec), sha256: digest };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeBundleTarget(target, identity) {
|
|
45
|
+
if (identity === NIGHTLY_LABEL) {
|
|
46
|
+
if (target !== null && target !== undefined) throw new Error('production nightly registration cannot pin a local bundle');
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const spec = String(target?.spec || '');
|
|
50
|
+
const digest = String(target?.sha256 || '');
|
|
51
|
+
let stat;
|
|
52
|
+
try { stat = fs.lstatSync(spec); } catch { /* handled below */ }
|
|
53
|
+
if (!path.isAbsolute(spec) || !spec.endsWith('.zip') || !stat?.isFile() || stat.isSymbolicLink()
|
|
54
|
+
|| !/^[a-f0-9]{64}$/.test(digest)) {
|
|
55
|
+
throw new Error('nightly proof bundle target must be a regular absolute ZIP with a SHA-256 digest');
|
|
56
|
+
}
|
|
57
|
+
const actual = sha256(fs.readFileSync(spec));
|
|
58
|
+
if (actual !== digest) throw new Error('nightly proof bundle target digest mismatch');
|
|
59
|
+
return { spec: path.resolve(spec), sha256: digest };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The scheduler is a self-contained installed read model. The producer owns the phase vocabulary
|
|
63
|
+
// and carries it in each receipt; this adapter validates that declared contract without redefining
|
|
64
|
+
// the semantic phase list inside the plugin payload.
|
|
65
|
+
export function validateRefreshReceiptEnvelope(receipt) {
|
|
66
|
+
if (!receipt || receipt.schemaVersion !== 3 || receipt.kind !== 'ruvnet-brain-refresh-run') {
|
|
67
|
+
return { ok: false, why: 'receipt schema is invalid' };
|
|
68
|
+
}
|
|
69
|
+
const declared = receipt.requiredPhaseOrder;
|
|
70
|
+
const phases = receipt.phases;
|
|
71
|
+
if (!Array.isArray(declared) || declared.length === 0 || !Array.isArray(phases)
|
|
72
|
+
|| declared.some((phase) => typeof phase !== 'string' || !SAFE_PHASE.test(phase))
|
|
73
|
+
|| new Set(declared).size !== declared.length) {
|
|
74
|
+
return { ok: false, why: 'declared phase contract is invalid' };
|
|
75
|
+
}
|
|
76
|
+
if (JSON.stringify(phases.map(({ phase }) => phase)) !== JSON.stringify(declared)) {
|
|
77
|
+
return { ok: false, why: 'phase ledger differs from its declared contract' };
|
|
78
|
+
}
|
|
79
|
+
const requiredFailure = phases.find((phase) => phase.required !== false && phase.status !== 'PASS');
|
|
80
|
+
if (receipt.status !== 'SUCCEEDED' || !['applied', 'noop'].includes(receipt.terminalVerdict) || requiredFailure) {
|
|
81
|
+
return { ok: false, why: requiredFailure
|
|
82
|
+
? `required phase ${requiredFailure.phase}=${requiredFailure.status}`
|
|
83
|
+
: `terminal state is ${receipt.status}/${receipt.terminalVerdict || 'unknown'}` };
|
|
84
|
+
}
|
|
85
|
+
return { ok: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function sameExecutable(left, right) {
|
|
89
|
+
try { return fs.realpathSync(left) === fs.realpathSync(right); }
|
|
90
|
+
catch { return path.resolve(left || '') === path.resolve(right || ''); }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function inspectRefreshOwner(owner) {
|
|
94
|
+
if (!owner || owner.host !== os.hostname() || !Number.isSafeInteger(owner.pid)
|
|
95
|
+
|| owner.pid < 1 || !owner.processStart || !owner.executable) return 'unknown';
|
|
96
|
+
try { process.kill(owner.pid, 0); } catch (error) { return error?.code === 'EPERM' ? 'unknown' : 'dead'; }
|
|
97
|
+
try {
|
|
98
|
+
if (process.platform === 'win32') {
|
|
99
|
+
const result = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command',
|
|
100
|
+
`$p=Get-Process -Id ${owner.pid} -ErrorAction Stop; `
|
|
101
|
+
+ `Write-Output ($p.StartTime.ToUniversalTime().ToString('o') + '|' + $p.Path)`], { encoding: 'utf8' });
|
|
102
|
+
if (result.status !== 0) return 'unknown';
|
|
103
|
+
const [processStart, ...rest] = String(result.stdout || '').trim().split('|');
|
|
104
|
+
return processStart === owner.processStart && sameExecutable(rest.join('|'), owner.executable) ? 'live' : 'dead';
|
|
105
|
+
}
|
|
106
|
+
const started = spawnSync('ps', ['-p', String(owner.pid), '-o', 'lstart='], {
|
|
107
|
+
encoding: 'utf8', env: { ...process.env, TZ: 'UTC', LC_ALL: 'C', LANG: 'C' },
|
|
108
|
+
});
|
|
109
|
+
const command = spawnSync('ps', ['-p', String(owner.pid), '-o', 'comm='], { encoding: 'utf8' });
|
|
110
|
+
if (started.status !== 0 || command.status !== 0) return 'unknown';
|
|
111
|
+
return String(started.stdout || '').trim() === owner.processStart
|
|
112
|
+
&& sameExecutable(String(command.stdout || '').trim(), owner.executable) ? 'live' : 'dead';
|
|
113
|
+
} catch { return 'unknown'; }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function installNightlyRunner({ brainHome, source, nodePath = process.execPath,
|
|
117
|
+
identity = NIGHTLY_LABEL, packageTarget, bundleTarget, env = {} } = {}) {
|
|
118
|
+
validateIdentity(identity);
|
|
119
|
+
if (!source || !fs.existsSync(source)) throw new Error(`nightly runner source is missing: ${source || '(unset)'}`);
|
|
120
|
+
const bytes = fs.readFileSync(source);
|
|
121
|
+
const digest = sha256(bytes);
|
|
122
|
+
const dir = path.join(brainHome, 'scheduler');
|
|
123
|
+
const runnerPath = path.join(dir, `nightly-refresh-${digest}.mjs`);
|
|
124
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
125
|
+
if (fs.existsSync(runnerPath) && sha256(fs.readFileSync(runnerPath)) !== digest) {
|
|
126
|
+
throw new Error(`nightly runner at ${runnerPath} does not match its content-addressed identity`);
|
|
127
|
+
}
|
|
128
|
+
if (!fs.existsSync(runnerPath)) fs.writeFileSync(runnerPath, bytes, { mode: 0o755 });
|
|
129
|
+
const record = { schemaVersion: 2, kind: 'ruvnet-brain-nightly-scheduler', identity,
|
|
130
|
+
nodePath: path.resolve(nodePath), runnerPath, runnerSha256: digest, argv: [],
|
|
131
|
+
environment: Object.fromEntries(NIGHTLY_ENV_ALLOWLIST.filter(key => env[key] !== undefined).map(key => [key, String(env[key])])),
|
|
132
|
+
packageTarget: normalizePackageTarget(packageTarget), bundleTarget: normalizeBundleTarget(bundleTarget, identity) };
|
|
133
|
+
const recordPath = path.join(dir, registrationName(identity));
|
|
134
|
+
const tmp = `${recordPath}.tmp-${process.pid}`;
|
|
135
|
+
fs.writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`);
|
|
136
|
+
fs.renameSync(tmp, recordPath);
|
|
137
|
+
return { ...record, recordPath };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function readNightlyRegistration({ brainHome, identity = NIGHTLY_LABEL,
|
|
141
|
+
recordPath = path.join(brainHome, 'scheduler', registrationName(identity)) }) {
|
|
142
|
+
try {
|
|
143
|
+
validateIdentity(identity);
|
|
144
|
+
const record = JSON.parse(fs.readFileSync(recordPath, 'utf8'));
|
|
145
|
+
if (record.schemaVersion !== 2 || record.kind !== 'ruvnet-brain-nightly-scheduler' || record.identity !== identity
|
|
146
|
+
|| !path.isAbsolute(record.nodePath) || !path.isAbsolute(record.runnerPath)
|
|
147
|
+
|| !Array.isArray(record.argv) || record.argv.length !== 0) throw new Error('invalid registration schema');
|
|
148
|
+
if (record.environment !== undefined && (!record.environment || Array.isArray(record.environment) || Object.entries(record.environment).some(([key, value]) => !NIGHTLY_ENV_ALLOWLIST.includes(key) || typeof value !== 'string'))) throw new Error('invalid registered environment');
|
|
149
|
+
const actual = sha256(fs.readFileSync(record.runnerPath));
|
|
150
|
+
if (actual !== record.runnerSha256) throw new Error('runner digest mismatch');
|
|
151
|
+
const packageTarget = normalizePackageTarget(record.packageTarget);
|
|
152
|
+
const bundleTarget = normalizeBundleTarget(record.bundleTarget, identity);
|
|
153
|
+
return { ok: true, record: { ...record, packageTarget, bundleTarget, recordPath } };
|
|
154
|
+
} catch (error) {
|
|
155
|
+
return { ok: false, recordPath, why: error.message };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function verifyNightlyExecutionIdentity({ brainHome, env = process.env } = {}) {
|
|
160
|
+
const recordPath = String(env.RUVNET_NIGHTLY_REGISTRATION || '');
|
|
161
|
+
if (!path.isAbsolute(recordPath)) return { ok: false, why: 'nightly registration path is missing or not absolute' };
|
|
162
|
+
const identity = String(env.RUVNET_NIGHTLY_IDENTITY || '');
|
|
163
|
+
const registration = readNightlyRegistration({ brainHome, identity, recordPath });
|
|
164
|
+
if (!registration.ok) return registration;
|
|
165
|
+
const record = registration.record;
|
|
166
|
+
const observed = {
|
|
167
|
+
schedulerIdentity: String(env.RUVNET_NIGHTLY_IDENTITY || ''),
|
|
168
|
+
registrationPath: recordPath,
|
|
169
|
+
nodePath: String(env.RUVNET_NIGHTLY_NODE_PATH || ''),
|
|
170
|
+
runnerPath: String(env.RUVNET_NIGHTLY_RUNNER_PATH || ''),
|
|
171
|
+
runnerSha256: String(env.RUVNET_NIGHTLY_RUNNER_SHA256 || ''),
|
|
172
|
+
argv: [],
|
|
173
|
+
};
|
|
174
|
+
if (observed.schedulerIdentity !== record.identity
|
|
175
|
+
|| path.resolve(observed.nodePath || '') !== record.nodePath
|
|
176
|
+
|| path.resolve(observed.runnerPath || '') !== record.runnerPath
|
|
177
|
+
|| observed.runnerSha256 !== record.runnerSha256) {
|
|
178
|
+
return { ok: false, why: 'nightly execution identity differs from the verified registration', record };
|
|
179
|
+
}
|
|
180
|
+
return { ok: true, identity: observed, record };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function resolveNightlyProofBundle({ brainHome, env = process.env } = {}) {
|
|
184
|
+
if (env.RUVNET_NIGHTLY !== '1') return null;
|
|
185
|
+
const execution = verifyNightlyExecutionIdentity({ brainHome, env });
|
|
186
|
+
if (!execution.ok) throw new Error(execution.why);
|
|
187
|
+
return execution.record.bundleTarget;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function refreshRunHealth({ brainHome, identity = NIGHTLY_LABEL, now = Date.now(), maxAgeHours = 30,
|
|
191
|
+
inspectOwner = inspectRefreshOwner } = {}) {
|
|
192
|
+
const dir = path.join(brainHome, 'refresh-runs');
|
|
193
|
+
let files = [];
|
|
194
|
+
try {
|
|
195
|
+
files = fs.readdirSync(dir).filter((name) => name.endsWith('.json')).sort();
|
|
196
|
+
} catch { return { state: 'never-ran', evidence: 'No nightly refresh receipt exists yet.', receipt: null }; }
|
|
197
|
+
let receipt = null;
|
|
198
|
+
let unreadable = null;
|
|
199
|
+
for (const name of files) {
|
|
200
|
+
try {
|
|
201
|
+
const candidate = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
|
|
202
|
+
if (candidate.kind === 'ruvnet-brain-refresh-run' && candidate.action === 'nightly'
|
|
203
|
+
&& candidate.schedulerIdentity === identity) {
|
|
204
|
+
const candidateStamp = Date.parse(candidate.finishedAt || candidate.startedAt || '');
|
|
205
|
+
const receiptStamp = Date.parse(receipt?.finishedAt || receipt?.startedAt || '');
|
|
206
|
+
if (!receipt || (Number.isFinite(candidateStamp) && (!Number.isFinite(receiptStamp) || candidateStamp > receiptStamp))) {
|
|
207
|
+
receipt = candidate;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} catch (error) { unreadable ||= `${name}: ${error.message}`; }
|
|
211
|
+
}
|
|
212
|
+
if (!receipt) return unreadable
|
|
213
|
+
? { state: 'failed', evidence: `Nightly receipt is unreadable (${unreadable}).`, receipt: null }
|
|
214
|
+
: { state: 'never-ran', evidence: 'No nightly refresh receipt exists yet.', receipt: null };
|
|
215
|
+
const stamp = Date.parse(receipt.finishedAt || receipt.startedAt || '');
|
|
216
|
+
if (!Number.isFinite(stamp)) return { state: 'failed', evidence: 'Latest nightly receipt has no valid timestamp.', receipt };
|
|
217
|
+
const ageHours = (now - stamp) / 3_600_000;
|
|
218
|
+
const registration = readNightlyRegistration({ brainHome, identity });
|
|
219
|
+
if (!registration.ok) return { state: 'failed', ageHours,
|
|
220
|
+
evidence: `Nightly registration is invalid: ${registration.why}`, receipt };
|
|
221
|
+
const expectedIdentity = { schedulerIdentity: registration.record.identity,
|
|
222
|
+
registrationPath: registration.record.recordPath, nodePath: registration.record.nodePath,
|
|
223
|
+
runnerPath: registration.record.runnerPath, runnerSha256: registration.record.runnerSha256, argv: [] };
|
|
224
|
+
if (receipt.schedulerIdentity !== registration.record.identity
|
|
225
|
+
|| JSON.stringify(receipt.executableIdentity) !== JSON.stringify(expectedIdentity)) {
|
|
226
|
+
return { state: 'failed', ageHours, evidence: 'Nightly receipt is not bound to the registered runner bytes.', receipt };
|
|
227
|
+
}
|
|
228
|
+
if (['RUNNING', 'SETTLING'].includes(receipt.status)) {
|
|
229
|
+
const owner = inspectOwner(receipt.ownerToken);
|
|
230
|
+
if (owner === 'live') return { state: 'running', ageHours, evidence: `Nightly refresh ${receipt.runId} has a live exact owner.`, receipt };
|
|
231
|
+
return { state: owner === 'dead' ? 'failed' : 'unknown', ageHours,
|
|
232
|
+
evidence: owner === 'dead'
|
|
233
|
+
? `Nightly refresh ${receipt.runId} has a dead exact owner.`
|
|
234
|
+
: `Nightly refresh ${receipt.runId} owner cannot be established exactly.`, receipt };
|
|
235
|
+
}
|
|
236
|
+
const envelope = validateRefreshReceiptEnvelope(receipt);
|
|
237
|
+
if (!envelope.ok) return { state: 'failed', ageHours,
|
|
238
|
+
evidence: `Nightly refresh ${receipt.runId} is invalid: ${envelope.why}.`, receipt };
|
|
239
|
+
if (ageHours > maxAgeHours) {
|
|
240
|
+
return { state: 'stale', ageHours, evidence: `Last verified nightly refresh is ${ageHours.toFixed(1)}h old.`, receipt };
|
|
241
|
+
}
|
|
242
|
+
return { state: receipt.status === 'SUCCEEDED' ? 'ok' : 'failed', ageHours,
|
|
243
|
+
evidence: `Last nightly refresh ${receipt.terminalVerdict} successfully ${ageHours.toFixed(1)}h ago.`, receipt };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function nightlyCommand(record) {
|
|
247
|
+
return `${shellQuote(record.nodePath)} ${shellQuote(record.runnerPath)} --registration ${shellQuote(record.recordPath)}`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function nightlyArtifact({ platform = process.platform, env = process.env, brainHome,
|
|
251
|
+
identity = NIGHTLY_LABEL } = {}) {
|
|
252
|
+
validateIdentity(identity);
|
|
253
|
+
const home = homeOf(env);
|
|
254
|
+
if (platform === 'darwin') return { supported: true, platform, kind: 'launchd', label: identity,
|
|
255
|
+
path: path.join(home, 'Library', 'LaunchAgents', `${identity}.plist`) };
|
|
256
|
+
if (platform === 'linux') return { supported: true, platform, kind: 'cron', label: identity,
|
|
257
|
+
path: `crontab:${identity}` };
|
|
258
|
+
if (platform === 'win32') return { supported: true, platform, kind: 'task-scheduler', label: identity,
|
|
259
|
+
path: `Task Scheduler:${identity}` };
|
|
260
|
+
return { supported: false, platform, kind: 'unsupported', label: identity,
|
|
261
|
+
path: path.join(brainHome || home, 'scheduler', registrationName(identity)) };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function schedulerEnvironment(record, env = {}) {
|
|
265
|
+
const result = {};
|
|
266
|
+
for (const key of NIGHTLY_ENV_ALLOWLIST) if (env[key] !== undefined) result[key] = String(env[key]);
|
|
267
|
+
return { ...result,
|
|
268
|
+
RUVNET_NIGHTLY_REGISTRATION: record.recordPath,
|
|
269
|
+
RUVNET_NIGHTLY_IDENTITY: record.identity,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function launchdPlist(record, { kbDir, logPath, pathValue, env = {} }) {
|
|
274
|
+
const home = homeOf(env);
|
|
275
|
+
const schedulerPath = [...new Set([
|
|
276
|
+
path.dirname(record.nodePath), path.join(home, '.npm-global', 'bin'), path.join(home, '.local', 'bin'),
|
|
277
|
+
...String(pathValue || '').split(':').filter(Boolean), '/usr/bin', '/bin', '/usr/sbin', '/sbin',
|
|
278
|
+
])].join(':');
|
|
279
|
+
const environment = schedulerEnvironment(record, { ...env, ...record.environment, PATH: schedulerPath });
|
|
280
|
+
const environmentXml = Object.entries(environment).map(([key, value]) =>
|
|
281
|
+
`<key>${xmlEscape(key)}</key><string>${xmlEscape(value)}</string>`).join('');
|
|
282
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
283
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
284
|
+
<plist version="1.0"><dict>
|
|
285
|
+
<key>Label</key><string>${xmlEscape(record.identity)}</string>
|
|
286
|
+
<key>ProgramArguments</key><array>
|
|
287
|
+
<string>${xmlEscape(record.nodePath)}</string>
|
|
288
|
+
<string>${xmlEscape(record.runnerPath)}</string>
|
|
289
|
+
</array>
|
|
290
|
+
<key>EnvironmentVariables</key><dict>${environmentXml}</dict>
|
|
291
|
+
<key>WorkingDirectory</key><string>${xmlEscape(kbDir)}</string>
|
|
292
|
+
<key>StandardOutPath</key><string>${xmlEscape(logPath)}</string>
|
|
293
|
+
<key>StandardErrorPath</key><string>${xmlEscape(logPath)}</string>
|
|
294
|
+
<key>StartCalendarInterval</key><dict><key>Hour</key><integer>${NIGHTLY_HOUR}</integer><key>Minute</key><integer>${NIGHTLY_MINUTE}</integer></dict>
|
|
295
|
+
<key>RunAtLoad</key><false/>
|
|
296
|
+
</dict></plist>
|
|
297
|
+
`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function cronLine(record, logPath, { proofTick = false, proofAt } = {}) {
|
|
301
|
+
if (proofTick && !NIGHTLY_PROOF_LABEL.test(record.identity)) throw new Error('proof cadence requires proof identity');
|
|
302
|
+
const at = new Date(proofAt);
|
|
303
|
+
if (proofTick && !Number.isFinite(at.getTime())) throw new Error('proof cadence requires explicit proofAt timestamp');
|
|
304
|
+
const schedule = proofTick ? `${at.getMinutes()} ${at.getHours()} ${at.getDate()} ${at.getMonth() + 1} *` : `${NIGHTLY_MINUTE} ${NIGHTLY_HOUR} * * *`;
|
|
305
|
+
return `${schedule} ${nightlyCommand(record)} >> ${shellQuote(logPath)} 2>&1 # ${record.identity}`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const xmlDecode = value => value.replace(/&#(?:x([a-f0-9]+)|([0-9]+));|&(amp|lt|gt|quot|apos);/gi,
|
|
309
|
+
(_match, hex, dec, named) => hex || dec ? String.fromCodePoint(parseInt(hex || dec, hex ? 16 : 10))
|
|
310
|
+
: ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" })[named.toLowerCase()]);
|
|
311
|
+
const onlyXmlMatch = (xml, pattern) => { const hits = [...xml.matchAll(pattern)]; return hits.length === 1 ? hits[0][1] : null; };
|
|
312
|
+
const windowsArguments = record => `"${record.runnerPath}" --registration "${record.recordPath}"`;
|
|
313
|
+
function windowsTaskXml(record) {
|
|
314
|
+
const boundary = new Date(Date.now() + 60_000).toISOString();
|
|
315
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
316
|
+
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
317
|
+
<RegistrationInfo><Author>RuvNet Brain</Author></RegistrationInfo>
|
|
318
|
+
<Triggers><CalendarTrigger><StartBoundary>${boundary}</StartBoundary><Enabled>true</Enabled><ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay></CalendarTrigger></Triggers>
|
|
319
|
+
<Principals><Principal id="Author"><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
|
|
320
|
+
<Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><StartWhenAvailable>true</StartWhenAvailable><ExecutionTimeLimit>PT1H</ExecutionTimeLimit></Settings>
|
|
321
|
+
<Actions Context="Author"><Exec><Command>${xmlEscape(record.nodePath)}</Command><Arguments>${xmlEscape(windowsArguments(record))}</Arguments></Exec></Actions>
|
|
322
|
+
</Task>`;
|
|
323
|
+
}
|
|
324
|
+
function exactWindowsCommand(xml, record) {
|
|
325
|
+
const actions = onlyXmlMatch(xml, /<Actions(?:\s[^>]*)?>([\s\S]*?)<\/Actions>/g);
|
|
326
|
+
if (actions === null || actions.replace(/<Exec(?:\s[^>]*)?>[\s\S]*?<\/Exec>/g, '').trim()) return false;
|
|
327
|
+
const exec = onlyXmlMatch(actions, /<Exec(?:\s[^>]*)?>([\s\S]*?)<\/Exec>/g);
|
|
328
|
+
if (exec === null) return false;
|
|
329
|
+
const command = onlyXmlMatch(exec, /<Command>([^<]*)<\/Command>/g);
|
|
330
|
+
const args = onlyXmlMatch(exec, /<Arguments>([^<]*)<\/Arguments>/g);
|
|
331
|
+
return command !== null && args !== null && [record.nodePath, `"${record.nodePath}"`].includes(xmlDecode(command))
|
|
332
|
+
&& xmlDecode(args) === windowsArguments(record);
|
|
333
|
+
}
|
|
334
|
+
function exactLaunchdCommand(plist, record) {
|
|
335
|
+
const array = onlyXmlMatch(plist, /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/g);
|
|
336
|
+
const environment = onlyXmlMatch(plist, /<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/g);
|
|
337
|
+
if (/<key>Program<\/key>/.test(plist) || array === null || environment === null || array.replace(/<string>[^<]*<\/string>/g, '').trim()) return false;
|
|
338
|
+
const argv = [...array.matchAll(/<string>([^<]*)<\/string>/g)].map(match => xmlDecode(match[1]));
|
|
339
|
+
if (JSON.stringify(argv) !== JSON.stringify([record.nodePath, record.runnerPath])) return false;
|
|
340
|
+
const entries = [...environment.matchAll(/<key>([^<]*)<\/key>\s*<string>([^<]*)<\/string>/g)];
|
|
341
|
+
if (environment.replace(/<key>[^<]*<\/key>\s*<string>[^<]*<\/string>/g, '').trim()) return false;
|
|
342
|
+
const env = Object.fromEntries(entries.map(([, key, value]) => [xmlDecode(key), xmlDecode(value)]));
|
|
343
|
+
if (Object.keys(env).length !== entries.length || Object.keys(env).some(key => ![...NIGHTLY_ENV_ALLOWLIST, 'RUVNET_NIGHTLY_REGISTRATION', 'RUVNET_NIGHTLY_IDENTITY'].includes(key))) return false;
|
|
344
|
+
return env.RUVNET_NIGHTLY_REGISTRATION === record.recordPath && env.RUVNET_NIGHTLY_IDENTITY === record.identity
|
|
345
|
+
&& Object.entries(record.environment || {}).every(([key, value]) => key === 'PATH' || env[key] === value);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Test mode persists simulated state only below the explicitly isolated fixture home.
|
|
349
|
+
function fixtureSchedulerRun(env, platform, identity) {
|
|
350
|
+
const home = env.HOME || env.USERPROFILE;
|
|
351
|
+
if (!home || !path.isAbsolute(home)) throw new Error('scheduler test mode requires an explicit absolute fixture home');
|
|
352
|
+
const file = path.join(home, '.ruvnet-scheduler-test', `${platform}-${identity}.json`);
|
|
353
|
+
return (_cmd, args, options = {}) => {
|
|
354
|
+
if (args[0] === '-l') return { status: 0, stdout: fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '' };
|
|
355
|
+
if (args[0] === '/Query') return fs.existsSync(file)
|
|
356
|
+
? { status: 0, stdout: fs.readFileSync(file, 'utf8') }
|
|
357
|
+
: { status: 1, stderr: 'The system cannot find the file specified.' };
|
|
358
|
+
if (args[0] === '/Delete') { fs.rmSync(file, { force: true }); return { status: 0 }; }
|
|
359
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
360
|
+
const command = args[args.indexOf('/TR') + 1];
|
|
361
|
+
const task = command?.match(/^"([^"]+)" (.*)$/);
|
|
362
|
+
fs.writeFileSync(file, args[0] === '-' ? options.input
|
|
363
|
+
: `<Task><Actions><Exec><Command>${xmlEscape(task[1])}</Command><Arguments>${xmlEscape(task[2])}</Arguments></Exec></Actions></Task>`);
|
|
364
|
+
return { status: 0 };
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const ownsCronRow = (row, identity) => row.trimEnd().endsWith(`# ${identity}`);
|
|
369
|
+
|
|
370
|
+
function cronRows(run) {
|
|
371
|
+
const listed = run('crontab', ['-l'], { encoding: 'utf8' });
|
|
372
|
+
if (listed.error) return { ok: false, why: 'no crontab command was found on this system' };
|
|
373
|
+
if (listed.status !== 0 && !/^(?:[^\n]*: )?no crontab for [^\n]+$/i.test(String(listed.stderr || '').trim())) return { ok: false, why: String(listed.stderr || listed.stdout || `crontab exited ${listed.status}`).trim() };
|
|
374
|
+
return { ok: true, rows: String(listed.stdout || '').split('\n').filter(Boolean) };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function installScheduler(record, { platform = process.platform, env = process.env, kbDir,
|
|
378
|
+
run = spawnSync, testMode = false, proofTick = false, proofAt, pathValue = process.env.PATH || '' } = {}) {
|
|
379
|
+
validateIdentity(record.identity);
|
|
380
|
+
if (proofTick && !NIGHTLY_PROOF_LABEL.test(record.identity)) throw new Error('proof cadence requires proof identity');
|
|
381
|
+
const artifact = nightlyArtifact({ platform, env, brainHome: path.dirname(kbDir), identity: record.identity });
|
|
382
|
+
if (!artifact.supported) return { ok: false, artifact, why: `unsupported platform: ${platform}` };
|
|
383
|
+
if (testMode && platform !== 'darwin') run = fixtureSchedulerRun(env, platform, record.identity);
|
|
384
|
+
const logPath = path.join(kbDir, 'update.log');
|
|
385
|
+
if (platform === 'darwin') {
|
|
386
|
+
fs.mkdirSync(path.dirname(artifact.path), { recursive: true });
|
|
387
|
+
fs.writeFileSync(artifact.path, launchdPlist(record, { kbDir, logPath, pathValue, env }));
|
|
388
|
+
if (!testMode) {
|
|
389
|
+
const uid = process.getuid();
|
|
390
|
+
run('launchctl', ['bootout', `gui/${uid}/${record.identity}`], { stdio: 'ignore' });
|
|
391
|
+
const boot = run('launchctl', ['bootstrap', `gui/${uid}`, artifact.path], { encoding: 'utf8' });
|
|
392
|
+
if (boot.error || boot.status !== 0) return { ok: false, artifact, why: boot.error?.message || String(boot.stderr || `exit ${boot.status}`).trim() };
|
|
393
|
+
}
|
|
394
|
+
return { ok: true, artifact, already: false };
|
|
395
|
+
}
|
|
396
|
+
if (platform === 'linux') {
|
|
397
|
+
const current = cronRows(run);
|
|
398
|
+
if (!current.ok) return { ...current, artifact };
|
|
399
|
+
const line = cronLine(record, logPath, { proofTick, proofAt });
|
|
400
|
+
const retained = current.rows.filter((row) => !ownsCronRow(row, record.identity));
|
|
401
|
+
if (current.rows.includes(line)) return { ok: true, artifact, already: true };
|
|
402
|
+
const write = run('crontab', ['-'], { input: `${[...retained, line].join('\n')}\n`, encoding: 'utf8' });
|
|
403
|
+
if (write.error || write.status !== 0) return { ok: false, artifact, why: write.error?.message || String(write.stderr || `exit ${write.status}`).trim() };
|
|
404
|
+
return { ok: true, artifact, already: false };
|
|
405
|
+
}
|
|
406
|
+
const taskCommand = `"${record.nodePath}" ${windowsArguments(record)}`;
|
|
407
|
+
let created;
|
|
408
|
+
if (testMode) {
|
|
409
|
+
// The fixture adapter models the legacy /TR call; keep it isolated from the machine path.
|
|
410
|
+
created = run('schtasks', ['/Create', '/SC', 'DAILY', '/TN', record.identity, '/TR', taskCommand,
|
|
411
|
+
'/ST', `${String(NIGHTLY_HOUR).padStart(2, '0')}:${String(NIGHTLY_MINUTE).padStart(2, '0')}`, '/F'], { encoding: 'utf8' });
|
|
412
|
+
} else {
|
|
413
|
+
// schtasks /TR rejects commands longer than 261 characters. Importing equivalent XML keeps the
|
|
414
|
+
// exact Node executable and argument identity while allowing long temporary/user paths.
|
|
415
|
+
const xmlPath = path.join(path.dirname(record.recordPath), `${record.identity}.task.xml`);
|
|
416
|
+
fs.writeFileSync(xmlPath, windowsTaskXml(record), { flag: 'wx', encoding: 'utf16le' });
|
|
417
|
+
try { created = run('schtasks', ['/Create', '/XML', xmlPath, '/TN', record.identity, '/F'], { encoding: 'utf8' }); }
|
|
418
|
+
finally { fs.rmSync(xmlPath, { force: true }); }
|
|
419
|
+
}
|
|
420
|
+
if (created.error || created.status !== 0) return { ok: false, artifact, why: created.error?.message || String(created.stderr || `exit ${created.status}`).trim() };
|
|
421
|
+
return { ok: true, artifact, already: false };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function removeScheduler({ platform = process.platform, env = process.env, run = spawnSync, testMode = false,
|
|
425
|
+
identity = NIGHTLY_LABEL } = {}) {
|
|
426
|
+
const artifact = nightlyArtifact({ platform, env, identity });
|
|
427
|
+
if (!artifact.supported) return { ok: false, artifact, why: `unsupported platform: ${platform}` };
|
|
428
|
+
if (testMode && platform !== 'darwin') run = fixtureSchedulerRun(env, platform, identity);
|
|
429
|
+
if (platform === 'darwin') {
|
|
430
|
+
const existed = fs.existsSync(artifact.path);
|
|
431
|
+
if (!testMode) {
|
|
432
|
+
run('launchctl', ['bootout', `gui/${process.getuid()}/${identity}`], { encoding: 'utf8' });
|
|
433
|
+
const remaining = run('launchctl', ['print', `gui/${process.getuid()}/${identity}`], { encoding: 'utf8' });
|
|
434
|
+
if (remaining.error || remaining.status === 0 || !/could not find|not found|no such process/i.test(String(remaining.stderr || remaining.stdout))) {
|
|
435
|
+
return { ok: false, artifact, why: 'LaunchAgent absence could not be confirmed; local files retained' };
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
fs.rmSync(artifact.path, { force: true });
|
|
439
|
+
return { ok: true, artifact, already: !existed };
|
|
440
|
+
}
|
|
441
|
+
if (platform === 'linux') {
|
|
442
|
+
const current = cronRows(run);
|
|
443
|
+
if (!current.ok) return { ...current, artifact };
|
|
444
|
+
const retained = current.rows.filter((row) => !ownsCronRow(row, identity));
|
|
445
|
+
if (retained.length === current.rows.length) return { ok: true, artifact, already: true };
|
|
446
|
+
const write = run('crontab', ['-'], { input: retained.length ? `${retained.join('\n')}\n` : '', encoding: 'utf8' });
|
|
447
|
+
if (write.error || write.status !== 0) return { ok: false, artifact, why: write.error?.message || String(write.stderr || `exit ${write.status}`).trim() };
|
|
448
|
+
return { ok: true, artifact };
|
|
449
|
+
}
|
|
450
|
+
const removed = run('schtasks', ['/Delete', '/TN', identity, '/F'], { encoding: 'utf8' });
|
|
451
|
+
const absent = removed.status === 1 && /cannot find|does not exist/i.test(String(removed.stderr || removed.stdout));
|
|
452
|
+
if (removed.error || (removed.status !== 0 && !absent)) return { ok: false, artifact, why: removed.error?.message || String(removed.stderr || `exit ${removed.status}`).trim() };
|
|
453
|
+
return { ok: true, artifact, already: absent };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function schedulerStatus({ platform = process.platform, env = process.env, brainHome,
|
|
457
|
+
kbDir = path.join(brainHome, 'kb'), run = spawnSync, testMode = false, proofTick = false, proofAt, identity = NIGHTLY_LABEL } = {}) {
|
|
458
|
+
const finish = (status) => ({ ...status, runHealth: refreshRunHealth({ brainHome, identity }) });
|
|
459
|
+
const artifact = nightlyArtifact({ platform, env, brainHome, identity });
|
|
460
|
+
if (!artifact.supported) return finish({ state: 'unsupported', evidence: `No scheduler adapter for ${platform}.`, artifact });
|
|
461
|
+
const registration = readNightlyRegistration({ brainHome, identity });
|
|
462
|
+
if (!registration.ok) return finish({ state: fs.existsSync(registration.recordPath) ? 'degraded' : 'off',
|
|
463
|
+
evidence: `Registration invalid: ${registration.why}`, artifact, registration });
|
|
464
|
+
const record = registration.record;
|
|
465
|
+
if (testMode && platform !== 'darwin') run = fixtureSchedulerRun(env, platform, identity);
|
|
466
|
+
if (platform === 'darwin') {
|
|
467
|
+
if (!fs.existsSync(artifact.path)) return finish({ state: 'off', evidence: `No LaunchAgent plist at ${artifact.path}`, artifact, registration });
|
|
468
|
+
const plist = fs.readFileSync(artifact.path, 'utf8');
|
|
469
|
+
if (!exactLaunchdCommand(plist, record)) {
|
|
470
|
+
return finish({ state: 'degraded', evidence: 'LaunchAgent command does not match the registered runner', artifact, registration });
|
|
471
|
+
}
|
|
472
|
+
if (testMode) return finish({ state: 'on', evidence: `LaunchAgent plist and runner digest verified at ${artifact.path}`, artifact, registration });
|
|
473
|
+
const listed = run('launchctl', ['print', `gui/${process.getuid()}/${identity}`], { encoding: 'utf8' });
|
|
474
|
+
const loaded = !listed.error && listed.status === 0;
|
|
475
|
+
if (!loaded) return finish({ state: 'degraded', evidence: 'LaunchAgent plist exists but job is not loaded', artifact, registration });
|
|
476
|
+
const exit = String(listed.stdout || '').match(/last exit code\s*=\s*(-?\d+)/i);
|
|
477
|
+
if (exit && Number(exit[1]) !== 0) {
|
|
478
|
+
return finish({ state: 'degraded', evidence: `LaunchAgent is loaded and runner digest verified, but last exited ${exit[1]}`, artifact, registration });
|
|
479
|
+
}
|
|
480
|
+
return finish({ state: 'on', evidence: exit
|
|
481
|
+
? 'LaunchAgent is loaded, runner digest verified, and last exited cleanly'
|
|
482
|
+
: 'LaunchAgent is loaded and runner digest verified; no completed run is recorded yet', artifact, registration });
|
|
483
|
+
}
|
|
484
|
+
if (platform === 'linux') {
|
|
485
|
+
const current = cronRows(run);
|
|
486
|
+
const expected = cronLine(record, path.join(kbDir, 'update.log'), { proofTick, proofAt });
|
|
487
|
+
const on = current.ok && current.rows.includes(expected);
|
|
488
|
+
return finish({ state: on ? 'on' : !current.ok || current.rows.some((row) => ownsCronRow(row, identity)) ? 'degraded' : 'off', evidence: on ? 'Managed crontab entry and runner digest verified' : current.why || 'Exact managed crontab entry is absent', artifact, registration });
|
|
489
|
+
}
|
|
490
|
+
const queried = run('schtasks', ['/Query', '/TN', identity, '/XML'], { encoding: 'utf8' });
|
|
491
|
+
const xml = String(queried.stdout || '');
|
|
492
|
+
const on = !queried.error && queried.status === 0 && exactWindowsCommand(xml, record);
|
|
493
|
+
return finish({ state: on ? 'on' : !queried.error && queried.status === 1 && /cannot find|does not exist/i.test(String(queried.stderr || queried.stdout)) ? 'off' : 'degraded', evidence: on ? 'Task Scheduler entry and runner digest verified' : 'Exact Task Scheduler entry is absent', artifact, registration });
|
|
494
|
+
}
|
|
@@ -108,10 +108,10 @@ export function redactProgression(value) {
|
|
|
108
108
|
let output = current;
|
|
109
109
|
for (const rule of INLINE_SECRETS) {
|
|
110
110
|
rule.pattern.lastIndex = 0;
|
|
111
|
-
|
|
111
|
+
const replaced = output.replace(rule.pattern, rule.replace);
|
|
112
|
+
if (replaced !== output) {
|
|
112
113
|
mark(currentPath, rule.kind);
|
|
113
|
-
|
|
114
|
-
output = output.replace(rule.pattern, rule.replace);
|
|
114
|
+
output = replaced;
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
return output;
|
|
@@ -122,7 +122,7 @@ export function redactProgression(value) {
|
|
|
122
122
|
const path = `${currentPath}.${key}`;
|
|
123
123
|
const kind = secretKind(key);
|
|
124
124
|
if (kind && current[key] !== null && current[key] !== undefined) {
|
|
125
|
-
mark(path, kind);
|
|
125
|
+
if (current[key] !== `[REDACTED:${kind}]`) mark(path, kind);
|
|
126
126
|
return [key, `[REDACTED:${kind}]`];
|
|
127
127
|
}
|
|
128
128
|
return [key, walk(current[key], path)];
|
|
@@ -7,14 +7,15 @@ import {
|
|
|
7
7
|
validateProgressionSnapshot,
|
|
8
8
|
} from './project-progression-contract.mjs';
|
|
9
9
|
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
10
|
-
import { resolveRuflo, RUFLO_MISSING } from './ruflo-bin.mjs';
|
|
10
|
+
import { resolveRuflo, rufloInvocation, RUFLO_MISSING } from './ruflo-bin.mjs';
|
|
11
11
|
|
|
12
12
|
const PROGRESSION_NAMESPACE = 'project-progression';
|
|
13
13
|
const RESUME_SCHEMA = 'ruvnet-brain.project-resume';
|
|
14
14
|
const RESUME_VERSION = 1;
|
|
15
15
|
|
|
16
16
|
function defaultRunner(binary, args, options) {
|
|
17
|
-
|
|
17
|
+
const invocation = rufloInvocation(binary, args);
|
|
18
|
+
return spawnSync(invocation.executable, invocation.args, { ...options, shell: false });
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
function resultStatus(result) {
|
|
@@ -153,23 +154,51 @@ export class ProjectProgressionStore {
|
|
|
153
154
|
return receipts;
|
|
154
155
|
}
|
|
155
156
|
|
|
156
|
-
listSnapshotKeys({ pageSize = 100 } = {}) {
|
|
157
|
+
listSnapshotKeys({ pageSize = 100, maxEntries = 10_000 } = {}) {
|
|
157
158
|
requirePositiveInteger(pageSize, 'pageSize');
|
|
159
|
+
requirePositiveInteger(maxEntries, 'maxEntries');
|
|
160
|
+
if (pageSize > maxEntries) throw new Error('pageSize exceeds the enumeration bound');
|
|
158
161
|
const keys = [];
|
|
159
162
|
const seen = new Set();
|
|
160
163
|
let offset = 0;
|
|
161
164
|
let total = null;
|
|
165
|
+
let protocol = null;
|
|
166
|
+
let limit = pageSize;
|
|
162
167
|
do {
|
|
163
168
|
const listed = this.run([
|
|
164
169
|
'memory', 'list', '--namespace', PROGRESSION_NAMESPACE,
|
|
165
|
-
'--limit', String(
|
|
170
|
+
'--limit', String(limit), '--offset', String(offset), '--page-info', '--format', 'json',
|
|
166
171
|
'--path', this.resolution.canonicalAgentDbPath,
|
|
167
172
|
]);
|
|
168
173
|
if (resultStatus(listed) !== 0) {
|
|
169
174
|
throw new Error(`progression structural pagination failed: ${resultText(listed, 'stderr').trim() || 'unknown error'}`);
|
|
170
175
|
}
|
|
171
176
|
const page = parseJson(resultText(listed, 'stdout'), 'progression pagination page');
|
|
177
|
+
const nextProtocol = Array.isArray(page) ? 'array' : 'page';
|
|
178
|
+
if (protocol && protocol !== nextProtocol) throw new Error('enumeration protocol changed during restoration');
|
|
179
|
+
protocol = nextProtocol;
|
|
180
|
+
if (protocol === 'array') {
|
|
181
|
+
// Global Ruflo's CLI returns the first `limit` entries and ignores offset/page-info.
|
|
182
|
+
// Grow from zero until its result is shorter than the requested limit. The current
|
|
183
|
+
// CLI honors limit (memory.js -> listEntries); a full response at our cap is ambiguous
|
|
184
|
+
// and must never become a partial successful restore.
|
|
185
|
+
if (page.length > limit) throw new Error('malformed pagination array');
|
|
186
|
+
const current = new Set();
|
|
187
|
+
for (const entry of page) {
|
|
188
|
+
if (!plainRecord(entry) || typeof entry.key !== 'string' || !entry.key
|
|
189
|
+
|| entry.namespace !== PROGRESSION_NAMESPACE) throw new Error('malformed pagination entry');
|
|
190
|
+
if (current.has(entry.key)) throw new Error(`duplicate progression key in array: ${entry.key}`);
|
|
191
|
+
current.add(entry.key);
|
|
192
|
+
}
|
|
193
|
+
if ([...seen].some((key) => !current.has(key))) throw new Error('enumeration keys changed during restoration');
|
|
194
|
+
if (page.length < limit) return [...current].sort();
|
|
195
|
+
if (limit === maxEntries) throw new Error('progression enumeration reached its bound without proving completeness');
|
|
196
|
+
for (const key of current) seen.add(key);
|
|
197
|
+
limit = Math.min(maxEntries, limit * 2);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
172
200
|
total = validatePage(page, { offset, pageSize, total });
|
|
201
|
+
if (total > maxEntries) throw new Error('progression enumeration exceeds its bound');
|
|
173
202
|
for (const entry of page.entries) {
|
|
174
203
|
if (!plainRecord(entry) || typeof entry.key !== 'string' || !entry.key
|
|
175
204
|
|| entry.namespace !== PROGRESSION_NAMESPACE) throw new Error('malformed pagination entry');
|
|
@@ -209,10 +238,10 @@ export class ProjectProgressionStore {
|
|
|
209
238
|
return { snapshots, rejected: sortRejected(rejected) };
|
|
210
239
|
}
|
|
211
240
|
|
|
212
|
-
restoreLatest({ pageSize = 100, maxOutputBytes = 64 * 1024 } = {}) {
|
|
241
|
+
restoreLatest({ pageSize = 100, maxEntries = 10_000, maxOutputBytes = 64 * 1024 } = {}) {
|
|
213
242
|
requirePositiveInteger(maxOutputBytes, 'maxOutputBytes');
|
|
214
243
|
this.replay();
|
|
215
|
-
const keys = this.listSnapshotKeys({ pageSize });
|
|
244
|
+
const keys = this.listSnapshotKeys({ pageSize, maxEntries });
|
|
216
245
|
const exact = this.retrieveSnapshots(keys);
|
|
217
246
|
const restored = restoreProjectProgression(exact.snapshots, {
|
|
218
247
|
expectedProjectIdentity: this.resolution.projectIdentity,
|
|
@@ -40,6 +40,26 @@ import fs from 'node:fs';
|
|
|
40
40
|
import os from 'node:os';
|
|
41
41
|
import path from 'node:path';
|
|
42
42
|
|
|
43
|
+
// npm's Windows .cmd shim cannot be spawned without a shell. Resolve the declared entry
|
|
44
|
+
// inside that same managed installation and pass argv directly to Node instead.
|
|
45
|
+
export function rufloInvocation(binary, args, { platform = process.platform } = {}) {
|
|
46
|
+
if (platform !== 'win32' || !/\.cmd$/i.test(binary)) return { executable: binary, args };
|
|
47
|
+
if (path.basename(binary).toLowerCase() !== 'ruflo.cmd') throw new Error('unsupported managed Ruflo shim');
|
|
48
|
+
const packageRoot = fs.realpathSync(path.join(path.dirname(fs.realpathSync(binary)), 'node_modules', 'ruflo'));
|
|
49
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
|
|
50
|
+
const declared = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.ruflo;
|
|
51
|
+
if (manifest.name !== 'ruflo' || typeof declared !== 'string' || !declared
|
|
52
|
+
|| path.isAbsolute(declared) || path.win32.isAbsolute(declared) || declared.split(/[\\/]/).includes('..')) {
|
|
53
|
+
throw new Error('managed Ruflo package has no safe declared CLI entry');
|
|
54
|
+
}
|
|
55
|
+
const entry = fs.realpathSync(path.resolve(packageRoot, declared));
|
|
56
|
+
const relative = path.relative(packageRoot, entry);
|
|
57
|
+
if (!relative || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative) || !fs.statSync(entry).isFile()) {
|
|
58
|
+
throw new Error('managed Ruflo CLI entry escapes its package');
|
|
59
|
+
}
|
|
60
|
+
return { executable: process.execPath, args: [entry, ...args] };
|
|
61
|
+
}
|
|
62
|
+
|
|
43
63
|
/**
|
|
44
64
|
* Locate the global ruflo. LOCATES, NEVER EXECUTES — no shell, no daemon, no side effects.
|
|
45
65
|
*
|
|
@@ -67,7 +67,12 @@ function legacy(projectDir, now) {
|
|
|
67
67
|
let names;
|
|
68
68
|
try {
|
|
69
69
|
const stat = fs.lstatSync(directory);
|
|
70
|
-
|
|
70
|
+
// A malformed ROOT (a stray file or symlink where `sessions/` should be a directory) must not
|
|
71
|
+
// discard every OTHER root's evidence. This used to `return` here, so a bad `.claude/sessions`
|
|
72
|
+
// entry made a genuinely fresh `.claude-flow/sessions` legacy session invisible to the whole
|
|
73
|
+
// scan — `continue` so the loop still inspects the remaining roots, same as the readdirSync
|
|
74
|
+
// catch below already does for an unreadable directory.
|
|
75
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) { malformed = true; continue; }
|
|
71
76
|
names = fs.readdirSync(directory).filter((name) => /^session-.*\.json$/.test(name)).slice(-64);
|
|
72
77
|
} catch { malformed = true; continue; }
|
|
73
78
|
for (const name of names) {
|