@redue/cli 0.1.0-alpha.3 → 0.1.0-alpha.30
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/CHANGELOG.md +806 -0
- package/README.md +157 -99
- package/bin/redue.mjs +267 -61
- package/docs/README.md +3 -1
- package/docs/user/agents.md +333 -15
- package/docs/user/configuration.md +28 -2
- package/docs/user/direct-typescript.md +219 -0
- package/docs/user/getting-started.md +49 -7
- package/docs/user/json.md +4 -2
- package/docs/user/node-script.md +177 -0
- package/docs/user/npm-launcher-notice.md +42 -0
- package/docs/user/pilot.md +146 -0
- package/docs/user/sandbox.md +312 -0
- package/docs/user/shell.md +3 -3
- package/docs/user/supported-projects.md +69 -23
- package/docs/user/tests-direct.md +143 -0
- package/package.json +4 -3
- package/src/agent-integration.mjs +467 -0
- package/src/agent-policy.md +28 -0
- package/src/cached-state.mjs +24 -1
- package/src/claude-hook.mjs +616 -0
- package/src/cli.mjs +449 -54
- package/src/daemon.mjs +442 -93
- package/src/decision-profile.mjs +15 -1
- package/src/decision-validation.mjs +12 -1
- package/src/declared-plan.mjs +38 -6
- package/src/direct-run-certificate.mjs +254 -0
- package/src/direct-typescript-probe.mjs +27 -0
- package/src/direct-typescript.mjs +205 -0
- package/src/glob.mjs +8 -0
- package/src/history-worker.mjs +21 -13
- package/src/index.mjs +108 -21
- package/src/installed-inputs.mjs +7 -1
- package/src/installed-observation.mjs +23 -0
- package/src/landlock.py +527 -0
- package/src/linux-inotify.py +39 -10
- package/src/node-onboarding.mjs +203 -54
- package/src/node-script.mjs +406 -0
- package/src/npm-integrity.mjs +124 -0
- package/src/npm-typecheck-environment.mjs +36 -0
- package/src/npmrc.mjs +16 -0
- package/src/observation.mjs +34 -12
- package/src/path-identity.mjs +8 -0
- package/src/plan-worker.mjs +17 -17
- package/src/pnpm-inputs.mjs +122 -57
- package/src/pnpm-shim-templates.json +180 -0
- package/src/pnpm-shims.mjs +54 -0
- package/src/pnpm-typecheck-probe.mjs +11 -6
- package/src/presentation.mjs +13 -4
- package/src/probe-deadline.mjs +37 -0
- package/src/probe-result.mjs +7 -3
- package/src/project-location.mjs +34 -0
- package/src/python-onboarding.mjs +89 -0
- package/src/run-recorder.cjs +91 -0
- package/src/sandbox.mjs +653 -0
- package/src/setup.mjs +205 -0
- package/src/stats.mjs +72 -0
- package/src/test-direct.mjs +608 -0
- package/src/test-recorder.cjs +435 -0
- package/src/typescript-contract-probe.mjs +4 -187
- package/src/typescript-input-facts.mjs +92 -0
- package/src/typescript-invocation.mjs +92 -0
- package/src/typescript-list.mjs +118 -13
- package/src/typescript-qualification.mjs +29 -49
- package/src/workspace-manifests.mjs +24 -0
- package/src/yarn-workspace-inputs.mjs +3 -0
- package/src/yarn-workspace-typecheck-probe.mjs +2 -1
package/src/cli.mjs
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {builtInProbe,probeFloorMs,publishedDeadline} from './probe-deadline.mjs';
|
|
2
3
|
import fs from 'node:fs';
|
|
4
|
+
import {directQualification,directInterpretation,observedNpmContext} from './direct-typescript.mjs';
|
|
5
|
+
import {directRunCertificateAllowed,directRunCertificate,directRunRecordable,directRunRecording,probeQuestions,validateRunRecord} from './direct-run-certificate.mjs';
|
|
6
|
+
import {queryAnswer,runFactChange} from './typescript-list.mjs';
|
|
7
|
+
import {patternMatcher} from './glob.mjs';
|
|
8
|
+
import {observedBy} from './installed-observation.mjs';
|
|
9
|
+
import {changedFiles,coverageReadable,gitIgnored,recordedEnvironment,testEnvironmentIssue,testGuardConfig,testRunGuard,testRunFacts,sharedInstalledFile,testsInterpretation,testsQualification} from './test-direct.mjs';
|
|
10
|
+
import {childProgram,generalIssue,lookup,pnpmConfigFiles,scriptCaches,scriptInstalledPaths,scriptInterpretation,scriptQualification,scriptRunFacts,pnpmNameless,toolOutput,toolchainDirs} from './node-script.mjs';
|
|
11
|
+
import {denialLine,docsQualification,endWithChild,hiddenDocs,hiddenEntries,hiddenQualification,hiddenRefusal,hidesIgnored,isSandboxed,runToEnd,supervisorIssue,visibleChange,testRun as sandboxTestRun,sandboxArgv,sandboxContext,sandboxDirs,sandboxEnv,
|
|
12
|
+
sandboxInterpretation,sandboxInterpretationOf,sandboxPlaces,sandboxQualification,sandboxSupport,systemPlaces} from './sandbox.mjs';
|
|
3
13
|
import path from 'node:path';
|
|
4
14
|
import net from 'node:net';
|
|
5
15
|
import os from 'node:os';
|
|
@@ -15,14 +25,17 @@ import {createHash,randomUUID} from 'node:crypto';
|
|
|
15
25
|
import {assertOwnedStatePlacement} from './owned-state.mjs';
|
|
16
26
|
import {permittedRoots} from './installed-inputs.mjs';
|
|
17
27
|
import {acquireRunLock,inspectRunLock,recoverRunLock} from './run-lock.mjs';
|
|
18
|
-
import {atomicJson,commitReceipt,receiptRevision} from './state-store.mjs';
|
|
19
|
-
import {timing} from './decision-profile.mjs';
|
|
28
|
+
import {atomicJson,commitReceipt,readReceipts,receiptRevision} from './state-store.mjs';
|
|
29
|
+
import {timing,profiling,mark,traceContext} from './decision-profile.mjs';
|
|
20
30
|
import {readCachedStatus,unavailableCached} from './cached-state.mjs';
|
|
21
31
|
import {renderRun} from './presentation.mjs';
|
|
32
|
+
import {note} from './stats.mjs';
|
|
22
33
|
|
|
34
|
+
mark('cli.loaded');
|
|
23
35
|
const here=path.dirname(fileURLToPath(import.meta.url));
|
|
24
36
|
const [configArg,action,name]=process.argv.slice(2);
|
|
25
37
|
if(!configArg||!action)throw Error('internal usage: cli.mjs CONFIG ACTION [check]');
|
|
38
|
+
const configStarted=performance.now();
|
|
26
39
|
const configFile=path.resolve(configArg), config=JSON.parse(fs.readFileSync(configFile));
|
|
27
40
|
const maintenance=['stop','uninstall'].includes(action);
|
|
28
41
|
const root=maintenance?path.resolve(config.root):realObservedPath(config.root), state=path.resolve(config.state),
|
|
@@ -34,10 +47,28 @@ if([path.parse(state).root,os.homedir(),path.dirname(os.homedir())]
|
|
|
34
47
|
!/^(?:vstate|redue)-/.test(path.basename(state)))
|
|
35
48
|
throw Error('state directory name must start with redue- (or legacy vstate-); use a dedicated directory outside the checkout');
|
|
36
49
|
const ownerFile=path.join(state,'vstate-owner-v1.json');
|
|
50
|
+
timing('cli.config_resolution',configStarted);
|
|
37
51
|
// A native Windows decision reconciles declared contents instead of trusting
|
|
38
52
|
// watcher history. The measured 6k-file pnpm workspace can legitimately need
|
|
39
53
|
// more than the former 15 s default; uncertainty still wins on expiry.
|
|
40
|
-
|
|
54
|
+
// A decision can include a full probe, whose deadline the observer publishes.
|
|
55
|
+
// Read when a wait starts, so a deadline the previous decision raised counts.
|
|
56
|
+
const decisionBaseMs=process.platform==='win32'?30000:15000;
|
|
57
|
+
function publishedProbeDeadline() {
|
|
58
|
+
try{return publishedDeadline(JSON.parse(fs.readFileSync(path.join(state,'status.json'),'utf8')).probe_deadline_ms);}
|
|
59
|
+
catch{return 0;}
|
|
60
|
+
}
|
|
61
|
+
// The wait for a decision, read when it starts: the base at once, and the
|
|
62
|
+
// published deadline when status.json has been read. That file is rewritten
|
|
63
|
+
// at every decision, and a first read of a fresh file is slow where a scanner
|
|
64
|
+
// checks files as they are opened, so it is read off the main thread while
|
|
65
|
+
// the call runs. On Windows it is read before the call, as before: the
|
|
66
|
+
// observer replaces it during the call, and a replacement fails there while a
|
|
67
|
+
// reader holds the file open. An override is exact.
|
|
68
|
+
const decisionWait=override=>override?{timeout:Number(override),deadline:null}:
|
|
69
|
+
process.platform==='win32'?{timeout:Math.max(decisionBaseMs,publishedProbeDeadline()+10000),deadline:null}:{timeout:decisionBaseMs,
|
|
70
|
+
deadline:fs.promises.readFile(path.join(state,'status.json'),'utf8')
|
|
71
|
+
.then(text=>Math.max(decisionBaseMs,publishedDeadline(JSON.parse(text).probe_deadline_ms)+10000),()=>decisionBaseMs)};
|
|
41
72
|
const digest=value=>createHash('sha256').update(value).digest('hex');
|
|
42
73
|
function nodeIdentity() {const real=fs.realpathSync(process.execPath),st=fs.statSync(real,{bigint:true});
|
|
43
74
|
return digest(JSON.stringify([real,process.version,...['dev','ino','mode','size','mtimeNs','ctimeNs']
|
|
@@ -49,7 +80,15 @@ function pathExecutableIdentity(name) {
|
|
|
49
80
|
catch{}
|
|
50
81
|
return digest('<unavailable>');
|
|
51
82
|
}
|
|
52
|
-
|
|
83
|
+
// The variables each test check's last run read directly (see testRunGuard).
|
|
84
|
+
const readsFile=path.join(state,'environment-reads-v1.json');
|
|
85
|
+
function environmentReads() {
|
|
86
|
+
try{const value=JSON.parse(fs.readFileSync(readsFile,'utf8'));return value?.schema===1&&value.checks||{};}catch{return {};}
|
|
87
|
+
}
|
|
88
|
+
const recordedRecipes=[testsQualification,scriptQualification];
|
|
89
|
+
// A sandboxed check's run is recorded where its sandbox cannot start (execute).
|
|
90
|
+
function contextHashes() {const reads=config.checks.some(check=>recordedRecipes.includes(check.qualification)||isSandboxed(check.qualification))?environmentReads():{};
|
|
91
|
+
return Object.fromEntries(config.checks.map(check=>[
|
|
53
92
|
check.name,Object.fromEntries([...(check.environment?.variables||[])
|
|
54
93
|
.map(key=>[key,digest(process.env[key]??'<unset>')]),
|
|
55
94
|
...(check.environment?.prefixes||[]).map(prefix=>[
|
|
@@ -59,15 +98,22 @@ function contextHashes() {return Object.fromEntries(config.checks.map(check=>[
|
|
|
59
98
|
.sort(([a],[b])=>a.localeCompare(b))))]),
|
|
60
99
|
...(check.environment?.pathExecutables||[]).map(name=>[
|
|
61
100
|
`__vstate_path_executable_${name}__`,pathExecutableIdentity(name)]),
|
|
62
|
-
...(check.environment?.executableIdentity?[['__vstate_node_identity__',nodeIdentity()]]:[])
|
|
101
|
+
...(check.environment?.executableIdentity?[['__vstate_node_identity__',nodeIdentity()]]:[]),
|
|
102
|
+
// A sandboxed run's environment, toolchain and places (sandbox.mjs).
|
|
103
|
+
...(isSandboxed(check.qualification)?Object.entries(sandboxContext(check,process.env,root)):[]),
|
|
104
|
+
// The variables the last test run read directly, compared here: which
|
|
105
|
+
// list was compared, and the first variable whose value now differs.
|
|
106
|
+
// Control requests are small, so the values stay out of them.
|
|
107
|
+
...(reads[check.name]?[['__redue_env_reads__',digest(JSON.stringify(reads[check.name]))],
|
|
108
|
+
['__redue_env_changed__',Object.keys(reads[check.name]).find(key=>
|
|
109
|
+
(process.env[key]===undefined?'<unset>':digest(process.env[key]))!==reads[check.name][key])||'']]:[])])]));}
|
|
63
110
|
function probeHash(plan) {
|
|
64
111
|
const started=performance.now();
|
|
65
112
|
const values=[];
|
|
113
|
+
// The observer's current deadline, so a large project's probe is not cut short.
|
|
114
|
+
const timeout=Math.max(probeFloorMs(plan.qualification),publishedProbeDeadline());
|
|
66
115
|
for(const argv of plan.probes||[]) {
|
|
67
|
-
const timeout
|
|
68
|
-
(process.platform==='win32'&&['typescript-noemit-v1','yarn-workspace-tsc-v1']
|
|
69
|
-
.includes(plan.qualification))?10000:5000;
|
|
70
|
-
const out=spawnSync(argv[0],argv.slice(1),{cwd:plan.cwd,
|
|
116
|
+
const out=spawnSync(argv[0],[...argv.slice(1),...(builtInProbe(argv)?[`--redue-deadline=${timeout}`]:[])],{cwd:plan.cwd,
|
|
71
117
|
timeout,maxBuffer:1024*1024,stdio:['ignore','pipe','pipe'],env:process.env});
|
|
72
118
|
if(out.error||out.signal||out.status!==0){
|
|
73
119
|
timing('cli.probe',started,{outcome:out.error?.code||out.signal||out.status||'error',
|
|
@@ -90,20 +136,26 @@ function owner(){
|
|
|
90
136
|
atomicJson(ownerFile,expected);
|
|
91
137
|
}
|
|
92
138
|
}
|
|
93
|
-
|
|
139
|
+
// deadline, when given, resolves to the whole wait in ms, which replaces
|
|
140
|
+
// timeout when it is longer.
|
|
141
|
+
function call(message,timeout=5000,deadline=null) {
|
|
94
142
|
return new Promise((resolve,reject)=>{
|
|
95
|
-
let data='',done=false;const client=net.createConnection(socket);
|
|
96
|
-
const
|
|
97
|
-
|
|
143
|
+
let data='',done=false;mark('client.control_connect',{deadlineMs:timeout});const client=net.createConnection(socket);
|
|
144
|
+
const started=Date.now(),expire=()=>{mark('client.control_timeout');client.destroy();reject(Error('observer synchronization timeout'));};
|
|
145
|
+
let timer=setTimeout(expire,timeout);
|
|
146
|
+
deadline?.then(total=>{if(done||total<=timeout)return;mark('client.control_deadline',{deadlineMs:total});
|
|
147
|
+
clearTimeout(timer);timer=setTimeout(expire,Math.max(0,total-(Date.now()-started)));});
|
|
148
|
+
client.on('connect',()=>{mark('client.control_submit');client.write(JSON.stringify({...message,...(profiling?{diagnostic:traceContext()}: {})})+'\n');});
|
|
98
149
|
client.on('data',chunk=>data+=chunk);
|
|
99
|
-
client.on('end',()=>{clearTimeout(timer);if(done)return;done=true;
|
|
150
|
+
client.on('end',()=>{mark('client.control_complete',{bytes:data.length});clearTimeout(timer);if(done)return;done=true;
|
|
100
151
|
try{resolve(JSON.parse(data));}catch(e){reject(e);}});
|
|
101
|
-
client.on('error',e=>{clearTimeout(timer);reject(e);});
|
|
152
|
+
client.on('error',e=>{mark('client.control_error',{code:e.code});clearTimeout(timer);done=true;reject(e);});
|
|
102
153
|
});
|
|
103
154
|
}
|
|
104
|
-
async function decisionCall(message,timeout) {
|
|
105
|
-
|
|
106
|
-
|
|
155
|
+
async function decisionCall(message,{timeout,deadline}) {
|
|
156
|
+
let total=timeout;deadline?.then(value=>{total=Math.max(total,value);});
|
|
157
|
+
const started=Date.now(),remaining=()=>Math.max(1,total-(Date.now()-started));
|
|
158
|
+
try{return await call(message,timeout,deadline);}
|
|
107
159
|
catch(error){
|
|
108
160
|
if(process.platform!=='win32'||!['ENOENT','ECONNREFUSED','ECONNRESET'].includes(error.code))throw error;
|
|
109
161
|
const pidFile=path.join(state,'observer.lock','pid');
|
|
@@ -144,16 +196,32 @@ async function decisionCall(message,timeout) {
|
|
|
144
196
|
}
|
|
145
197
|
}
|
|
146
198
|
const cached=()=>readCachedStatus(state,config.checks);
|
|
199
|
+
async function testRunBoundary(phase) {
|
|
200
|
+
if(process.env.VSTATE_TEST_FAULTS!=='1'||process.env.VSTATE_TEST_RUN_BOUNDARY!==phase)return;
|
|
201
|
+
const release=path.join(state,'test-run-release');
|
|
202
|
+
fs.writeFileSync(path.join(state,'test-run-paused'),phase);
|
|
203
|
+
const until=Date.now()+5000;
|
|
204
|
+
while(!fs.existsSync(release)&&Date.now()<until)await new Promise(resolve=>setTimeout(resolve,10));
|
|
205
|
+
if(!fs.existsSync(release))throw Error('test run boundary was not released');
|
|
206
|
+
}
|
|
147
207
|
async function execute() {
|
|
148
208
|
const totalStarted=performance.now();
|
|
149
209
|
const selected=config.checks.find(c=>c.name===name);
|
|
150
210
|
if(!selected)throw Error(`unknown check ${name}`);
|
|
151
|
-
|
|
211
|
+
// Recording uses existing state only. Never initialize or repair ownership
|
|
212
|
+
// as a fallback for unavailable observation.
|
|
213
|
+
const expectedOwner={schema:1,kind:'vstate-product-alpha',config:configFile,root,state};
|
|
214
|
+
const existingOwner=JSON.parse(fs.readFileSync(ownerFile,'utf8'));
|
|
215
|
+
if(JSON.stringify(existingOwner)!==JSON.stringify(expectedOwner))
|
|
216
|
+
throw Error('state ownership marker mismatch');
|
|
217
|
+
const lockStarted=performance.now(),runLock=acquireRunLock(state);
|
|
218
|
+
timing('cli.run_lock',lockStarted);
|
|
152
219
|
const captureIssues=[];
|
|
153
|
-
let activeChild=null,requestedSignal=null,treeStop=null,preserveRunLock=false;
|
|
220
|
+
let activeChild=null,requestedSignal=null,treeStop=null,preserveRunLock=false,ownGroup=false;
|
|
154
221
|
const forward=signal=>{requestedSignal=signal;
|
|
155
222
|
if(activeChild&&activeChild.exitCode===null){
|
|
156
|
-
|
|
223
|
+
// A Linux run, and a sandboxed one on macOS, has its own process group.
|
|
224
|
+
if((process.platform==='linux'||ownGroup)&&activeChild.pid){
|
|
157
225
|
try{process.kill(-activeChild.pid,signal);}
|
|
158
226
|
catch(e){if(e.code!=='ESRCH')throw e;}
|
|
159
227
|
}else if(process.platform==='win32'&&activeChild.pid){
|
|
@@ -172,6 +240,7 @@ async function execute() {
|
|
|
172
240
|
try {
|
|
173
241
|
if(!fs.existsSync(path.join(state,'plans-v1.json')))
|
|
174
242
|
throw Error('observer input plan is not ready; wait for redue status to report ready');
|
|
243
|
+
const resolutionStarted=performance.now();
|
|
175
244
|
const plan=JSON.parse(fs.readFileSync(path.join(state,'plans-v1.json'))).plans[name];
|
|
176
245
|
if(!plan)throw Error('selected input plan unavailable');
|
|
177
246
|
if(plan.selectedConfigHash!==digest(JSON.stringify(selected)))
|
|
@@ -181,22 +250,120 @@ async function execute() {
|
|
|
181
250
|
throw Error('invalid execution argv');
|
|
182
251
|
if(digest(JSON.stringify(command))!==plan.commandIdentity.argvHash)
|
|
183
252
|
throw Error('execution argv differs from discovered plan');
|
|
253
|
+
timing('cli.command_resolution',resolutionStarted);
|
|
184
254
|
const runId=randomUUID();
|
|
185
255
|
const contextStarted=performance.now();
|
|
186
256
|
const environmentBefore=contextHashes()[name];
|
|
257
|
+
const observedContext=selected.qualification===directQualification?{interpretation:directInterpretation,npmProxyDigest:observedNpmContext()}:null;
|
|
187
258
|
timing('cli.context_before',contextStarted);
|
|
188
|
-
const
|
|
189
|
-
captureTimeout=Number(process.env.VSTATE_CAPTURE_TIMEOUT_MS||decisionDeadlineMs);
|
|
259
|
+
const captureWait=decisionWait(process.env.VSTATE_CAPTURE_TIMEOUT_MS);
|
|
190
260
|
let before=null,after=null;
|
|
191
261
|
// Expensive state probes can take seconds. Keep the filesystem checkpoint
|
|
192
262
|
// adjacent to process launch so a late notification from earlier work
|
|
193
263
|
// does not enter the execution interval before the check even starts.
|
|
194
|
-
const
|
|
195
|
-
|
|
264
|
+
const callerCertificate=directRunCertificateAllowed(selected);
|
|
265
|
+
const probePlan={cwd:plan.cwd,probes:selected.probes||[],qualification:selected.qualification};
|
|
266
|
+
let probeBefore=callerCertificate?null:probeHash(probePlan);
|
|
196
267
|
const startSnapshotStarted=performance.now();
|
|
197
|
-
try{before=await call({action:'snapshot',name,contextHashes:contextHashes()
|
|
198
|
-
|
|
268
|
+
try{before=await call({action:'snapshot',name,contextHashes:contextHashes(),
|
|
269
|
+
...(callerCertificate?{runCertificate:1}:{})},captureWait.timeout,captureWait.deadline);}
|
|
270
|
+
catch(error){captureIssues.push('start observation unavailable'+
|
|
271
|
+
(typeof error.code==='string'&&/^[A-Z0-9_]+$/.test(error.code)?` (${error.code})`:''));}
|
|
199
272
|
timing('cli.start_checkpoint',startSnapshotStarted,{available:before?.snapshots?.[name]?1:0});
|
|
273
|
+
// Missing discovery metadata can recover through a full probe. A missing
|
|
274
|
+
// healthy start boundary cannot: freeze this decision before any recovery.
|
|
275
|
+
// Only the reviewed direct recipe on macOS and Linux enters this shortcut.
|
|
276
|
+
const unobservedDirectRun=callerCertificate&&
|
|
277
|
+
before?.snapshots?.[name]?.observationHealthy!==true;
|
|
278
|
+
if(unobservedDirectRun){
|
|
279
|
+
captureIssues.push('qualification probes and end checkpoint not performed: no healthy start checkpoint');
|
|
280
|
+
mark('cli.run_validation_skipped',{reason:'start_observation_unavailable',probes:2,endCheckpoint:1});
|
|
281
|
+
}
|
|
282
|
+
await testRunBoundary('after-start-checkpoint');
|
|
283
|
+
// Start the observed interval before caller validation, not after it. Any
|
|
284
|
+
// intervening edit (even restored before launch) remains disqualifying.
|
|
285
|
+
// With a certificate, the compile records its own answers instead of two probes.
|
|
286
|
+
// Without one after an edit, the recording can become the certificate at the
|
|
287
|
+
// end checkpoint; that checkpoint probes in full if it cannot.
|
|
288
|
+
let certificate=null,recording=null;
|
|
289
|
+
if(callerCertificate&&!unobservedDirectRun){
|
|
290
|
+
certificate=directRunCertificate(plan,selected,before?.snapshots?.[name],root);
|
|
291
|
+
if(!certificate)recording=directRunRecording(plan,selected,before?.snapshots?.[name],root);
|
|
292
|
+
probeBefore=certificate?.value??(recording?null:probeHash(probePlan));
|
|
293
|
+
}
|
|
294
|
+
// The record arrives on fd 3, so the compiler's environment is unchanged.
|
|
295
|
+
// Every direct run is recorded: even one checked by full probes needs its
|
|
296
|
+
// lookups outside the certificate, which no probe makes.
|
|
297
|
+
const recorded=directRunRecordable(selected)&&!unobservedDirectRun;
|
|
298
|
+
const launched=recorded?[command[0],'--require',path.join(here,'run-recorder.cjs'),
|
|
299
|
+
...command.slice(1)]:command;
|
|
300
|
+
// A sandboxed check runs inside the OS sandbox. Where the sandbox cannot
|
|
301
|
+
// start, a script the general recipe takes is recorded as that recipe
|
|
302
|
+
// records it (`fallback`); any other runs as it is, and is not reused.
|
|
303
|
+
const sandboxed=isSandboxed(selected.qualification),support=sandboxed?sandboxSupport():null;
|
|
304
|
+
const fallback=Boolean(support?.issue&&typeof selected.script==='string'&&!generalIssue(root,selected.script));
|
|
305
|
+
// A test run's processes record what the guard checks (test-recorder.cjs),
|
|
306
|
+
// and so do a general recipe run's, through the package manager.
|
|
307
|
+
const general=selected.qualification===scriptQualification||fallback;
|
|
308
|
+
const testRun=(recordedRecipes.includes(selected.qualification)||fallback)&&['darwin','linux'].includes(process.platform);
|
|
309
|
+
let testRecord=null,launchEnv=process.env,testFacts={},toolchain=[];
|
|
310
|
+
if(testRun){
|
|
311
|
+
// The package directory and installed node_modules, as the plan has them.
|
|
312
|
+
try{testFacts=general?scriptRunFacts(root,selected):testRunFacts(root,selected);}
|
|
313
|
+
catch(e){captureIssues.push(`${general?'script':'test'} run facts unavailable (${e.code||e.message})`);}
|
|
314
|
+
const environmentIssue=testEnvironmentIssue();
|
|
315
|
+
if(environmentIssue)captureIssues.push(environmentIssue);
|
|
316
|
+
testRecord=path.join(state,`test-record-${runId}`);
|
|
317
|
+
fs.mkdirSync(testRecord,{mode:0o700});
|
|
318
|
+
const guardFile=path.join(testRecord,'guard.json'),guardConfig=testGuardConfig(testRecord,root,process.env,testFacts);
|
|
319
|
+
if(general){toolchain=toolchainDirs();
|
|
320
|
+
// The Node on PATH, which the environment identifies by its file.
|
|
321
|
+
Object.assign(guardConfig,{installed:scriptInstalledPaths(root,testFacts.dirs||[]),toolchain,
|
|
322
|
+
node:lookup('node',process.env.PATH,process.cwd())});}
|
|
323
|
+
fs.writeFileSync(guardFile,JSON.stringify(guardConfig),{mode:0o600});
|
|
324
|
+
launchEnv=recordedEnvironment(guardFile);
|
|
325
|
+
// npm's and pnpm's update checks read state outside the checkout and the network.
|
|
326
|
+
if(general)launchEnv.npm_config_update_notifier='false';
|
|
327
|
+
}
|
|
328
|
+
// The sandboxed run, with the environment REDUE gives it.
|
|
329
|
+
let sandbox=null;
|
|
330
|
+
if(sandboxed&&!fallback){
|
|
331
|
+
if(support.issue)captureIssues.push(`the run was not sandboxed: ${support.issue}`);
|
|
332
|
+
else{
|
|
333
|
+
const dirs=sandboxDirs(state,name);
|
|
334
|
+
// A test run: loopback networking on Linux, supervised (the launcher
|
|
335
|
+
// reports in REDUE's state, outside what the test may touch), a
|
|
336
|
+
// private /tmp, and a fresh home each run: what a test leaves in its
|
|
337
|
+
// home is not observed, so it must not reach a later run.
|
|
338
|
+
const tests=sandboxTestRun(selected),stats=tests&&support.mode==='landlock'?path.join(state,`launcher-${runId}.json`):null;
|
|
339
|
+
if(tests){dirs.home=path.join(dirs.tmp,'home');fs.mkdirSync(dirs.home,{mode:0o700});}
|
|
340
|
+
const reads=[root,dirs.home,dirs.tmp,...sandboxPlaces(selected,process.env,root),...systemPlaces];
|
|
341
|
+
// sandbox-v2 hides the checkout's git-ignored entries, and sandbox-v3
|
|
342
|
+
// its documentation too. Where git cannot list them, nothing is
|
|
343
|
+
// hidden, and the receipt compares every file.
|
|
344
|
+
const hidden=hidesIgnored(selected.qualification)?hiddenEntries(root):null;
|
|
345
|
+
const docs=hidden&&selected.qualification===docsQualification?hiddenDocs(root):null;
|
|
346
|
+
const options={reads,tmp:dirs.tmp,python:support.python,stats,
|
|
347
|
+
...tests?{...support.mode==='landlock'?{network:'loopback'}:{},writes:[root,dirs.home,dirs.tmp],
|
|
348
|
+
fault:process.env.VSTATE_TEST_FAULTS==='1'?process.env.VSTATE_TEST_SUPERVISOR||null:null}:{}};
|
|
349
|
+
sandbox={mode:support.mode,dirs,reads,hidden,docs,options,stats,
|
|
350
|
+
argv:sandboxArgv(support.mode,command,{...options,hidden:(hidden||[]).map(rel=>path.join(root,rel.replace(/\/$/,''))),
|
|
351
|
+
...docs?{docs:{root,entries:docs}}:{}}),
|
|
352
|
+
env:sandboxEnv(selected,process.env,{root,...dirs})};
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const plainEnv={...process.env,...selected.script?{npm_config_update_notifier:'false'}:{}};
|
|
356
|
+
// Output kept, and its own process group, for a run as this recipe makes it.
|
|
357
|
+
const own=sandboxed&&!fallback;
|
|
358
|
+
ownGroup=own&&process.platform!=='win32';
|
|
359
|
+
// A sandboxed run's output is passed through and its start and end kept,
|
|
360
|
+
// to tell a refusal by the sandbox from a real failure.
|
|
361
|
+
const output={head:'',tail:''};
|
|
362
|
+
const keep=chunk=>{const text=chunk.toString('utf8');
|
|
363
|
+
if(output.head.length<65536)output.head+=text.slice(0,65536-output.head.length);
|
|
364
|
+
output.tail=(output.tail+text).slice(-65536);};
|
|
365
|
+
let runnerPid=null;
|
|
366
|
+
const record=[];
|
|
200
367
|
// A Windows full reconciliation may finish while queued watcher callbacks
|
|
201
368
|
// are still arriving. Fold callbacks delivered before process launch into
|
|
202
369
|
// the starting checkpoint; callbacks after launch still disqualify reuse.
|
|
@@ -204,29 +371,40 @@ async function execute() {
|
|
|
204
371
|
await new Promise(resolve=>setTimeout(resolve,100));
|
|
205
372
|
const prelaunchStarted=performance.now();
|
|
206
373
|
try{before=await call({action:'prelaunch',name,
|
|
207
|
-
contextHashes:contextHashes()},Math.min(
|
|
374
|
+
contextHashes:contextHashes()},Math.min(captureWait.timeout,5000));}
|
|
208
375
|
catch{before=null;captureIssues.push('prelaunch observation unavailable');}
|
|
209
376
|
timing('cli.prelaunch_checkpoint',prelaunchStarted,
|
|
210
377
|
{available:before?.snapshots?.[name]?1:0});
|
|
211
378
|
}
|
|
212
379
|
const snap=before?.snapshots?.[name];
|
|
213
380
|
if(!snap||!snap.observationHealthy)captureIssues.push('start checkpoint unavailable');
|
|
214
|
-
|
|
381
|
+
await testRunBoundary('before-launch');
|
|
382
|
+
const started=Date.now(),executionStarted=performance.now();
|
|
215
383
|
const invocation=await new Promise(resolve=>{
|
|
216
384
|
if(requestedSignal){resolve({status:'interrupted',exitCode:null,signal:requestedSignal});return;}
|
|
217
385
|
let child,spawnError=null;
|
|
218
386
|
try{runLock.phase('launching');
|
|
219
|
-
const launch=windowsLaunch(
|
|
387
|
+
const launch=windowsLaunch(sandbox?.argv??launched);
|
|
220
388
|
child=spawn(launch.file,launch.args,{cwd:plan.cwd,
|
|
221
|
-
|
|
222
|
-
|
|
389
|
+
// A sandboxed run's own group gets no terminal input, which would stop it.
|
|
390
|
+
stdio:recorded?['inherit','inherit','inherit','pipe']:own?['ignore','pipe','pipe']:'inherit',
|
|
391
|
+
env:sandbox?.env??(own?plainEnv:launchEnv),
|
|
392
|
+
detached:process.platform==='linux'||ownGroup,...launch.options});
|
|
393
|
+
child.stdio[3]?.on('data',chunk=>record.push(chunk));
|
|
394
|
+
if(own){child.stdout.on('data',chunk=>{process.stdout.write(chunk);keep(chunk);});
|
|
395
|
+
child.stderr.on('data',chunk=>{process.stderr.write(chunk);keep(chunk);});
|
|
396
|
+
// What the check left running is stopped when it exits, as a CI
|
|
397
|
+
// job's end does (on Linux the launcher also stops daemons that
|
|
398
|
+
// left the group), and the run ends a second after its exit.
|
|
399
|
+
endWithChild(child);}}
|
|
223
400
|
catch(e){resolve({status:'start_failed',exitCode:null,signal:null,errorCode:e.code||e.name});return;}
|
|
224
|
-
activeChild=child;runLock.phase('running',child.pid??null);
|
|
401
|
+
activeChild=child;runnerPid=child.pid??null;runLock.phase('running',child.pid??null);
|
|
402
|
+
mark('cli.child_started',{childPid:child.pid});
|
|
225
403
|
if(requestedSignal)forward(requestedSignal);
|
|
226
404
|
child.on('error',e=>{spawnError=e.code||e.name;});
|
|
227
405
|
child.on('close',async(exit,signal)=>{
|
|
228
406
|
if(treeStop)await treeStop;
|
|
229
|
-
if(process.platform==='linux'&&requestedSignal&&child.pid){
|
|
407
|
+
if((process.platform==='linux'||ownGroup)&&requestedSignal&&child.pid){
|
|
230
408
|
// A verification command may have forked children in its process
|
|
231
409
|
// group. Normal cancellation must reach them even if the leader has
|
|
232
410
|
// already exited. Commands that daemonize into a new session remain
|
|
@@ -241,35 +419,226 @@ async function execute() {
|
|
|
241
419
|
});
|
|
242
420
|
});
|
|
243
421
|
const ended=Date.now();
|
|
422
|
+
timing('cli.compiler_execution',executionStarted,{status:invocation.status,exit:invocation.exitCode});
|
|
423
|
+
await testRunBoundary('after-exit');
|
|
244
424
|
invocation.startedAt=started;invocation.durationMs=ended-started;
|
|
245
425
|
invocation.argvHash=plan.commandIdentity.argvHash;
|
|
246
426
|
invocation.argc=command.length;
|
|
247
427
|
invocation.runId=runId;
|
|
248
428
|
invocation.reporting='normal';
|
|
249
|
-
|
|
429
|
+
let result=invocation.status==='exited'?(invocation.exitCode===0?'PASS':'FAIL'):null;
|
|
250
430
|
const target={source:'direct-command',status:invocation.status==='exited'?'direct_executed':'unknown'};
|
|
251
|
-
const environmentAfter=contextHashes()[name]
|
|
252
|
-
|
|
431
|
+
const afterContextStarted=performance.now(),environmentAfter=contextHashes()[name];
|
|
432
|
+
timing('cli.context_after',afterContextStarted);
|
|
433
|
+
let probeAfter=null;
|
|
434
|
+
const recordText=Buffer.concat(record).toString('utf8');
|
|
435
|
+
// Lookups the certificate does not cover, kept with the receipt.
|
|
436
|
+
let runFacts=[];
|
|
437
|
+
if(certificate){
|
|
438
|
+
const issue=validateRunRecord(recordText,certificate,runFacts);
|
|
439
|
+
if(issue)captureIssues.push(`compiler inputs differ from discovery certificate (${issue})`);
|
|
440
|
+
probeAfter=certificate.value;
|
|
441
|
+
}else if(!unobservedDirectRun&&!recording)probeAfter=probeHash(probePlan);
|
|
442
|
+
let guard=null;
|
|
443
|
+
if(testRecord){
|
|
444
|
+
let coverage=null;if(!general)try{coverage=coverageReadable(root,testFacts);}catch{}
|
|
445
|
+
const programs=general?{runner:{file:command[0],args:command.slice(1),cwd:plan.cwd,searchPath:process.env.PATH,
|
|
446
|
+
env:{preload:true,bashEnv:Boolean(process.env.BASH_ENV)}},
|
|
447
|
+
judge:(child,main)=>childProgram(child,{toolchain,parentMain:main}),trusted:scriptCaches}:null;
|
|
448
|
+
guard=testRunGuard(testRecord,{root,sourcePatterns:plan.sourcePatterns||plan.patterns,files:snap?.files,
|
|
449
|
+
runner:runnerPid,environment:selected.environment||{},coverage,programs});
|
|
450
|
+
runFacts=guard.runFacts;
|
|
451
|
+
// The pnpm executable reads its configuration natively: kept by content.
|
|
452
|
+
if(guard.opaque)for(const file of pnpmConfigFiles())try{runFacts.push(['readFile',file,queryAnswer('readFile',file)]);}
|
|
453
|
+
catch{guard.issues.push(`pnpm configuration ${file} cannot be read`);}
|
|
454
|
+
// Hard links count only where the check observes them.
|
|
455
|
+
// As the index matches: source patterns, or additive (installed) ones.
|
|
456
|
+
const source=patternMatcher(plan.sourcePatterns||plan.patterns||[]),additive=patternMatcher(plan.additivePatterns||[]);
|
|
457
|
+
const linkFacts=[],observed=observedBy(root,rel=>source(rel)||additive(rel));
|
|
458
|
+
const shared=sharedInstalledFile(root,testFacts,path.join(state,'pnpm-store-links-v1.json'),{observed,facts:linkFacts});
|
|
459
|
+
if(shared)guard.issues.push(`installed file ${shared.file} ${shared.reason}; a change through it is not observed`);
|
|
460
|
+
else runFacts.push(...linkFacts);
|
|
461
|
+
fs.rmSync(testRecord,{recursive:true,force:true});
|
|
462
|
+
}
|
|
463
|
+
// The daemon reads the recording from owned state; control requests are small.
|
|
464
|
+
const recordFile=recording&&path.join(state,`run-record-${recording}.json`);
|
|
465
|
+
if(recordFile)try{fs.writeFileSync(recordFile,recordText,{mode:0o600});}catch{}
|
|
253
466
|
const endSnapshotStarted=performance.now();
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
467
|
+
if(!unobservedDirectRun){
|
|
468
|
+
// The start checkpoint's probe may have raised the deadline.
|
|
469
|
+
const endWait=decisionWait(process.env.VSTATE_CAPTURE_TIMEOUT_MS);
|
|
470
|
+
try{after=await call({action:'snapshot',name,contextHashes:contextHashes(),
|
|
471
|
+
...((testRun||sandboxed)&&Number.isSafeInteger(snap?.inputEventSerial)?{sinceSerial:snap.inputEventSerial}:{}),
|
|
472
|
+
...(recording?{runCertificate:1,adopt:recording}:recorded&&!certificate?{runCertificate:1,runFacts:1}:{})},
|
|
473
|
+
endWait.timeout,endWait.deadline);}
|
|
474
|
+
catch{captureIssues.push('end observation unavailable');}
|
|
475
|
+
finally{if(recordFile)fs.rmSync(recordFile,{force:true});}
|
|
476
|
+
timing('cli.end_checkpoint',endSnapshotStarted,{available:after?.snapshots?.[name]?1:0});
|
|
477
|
+
}
|
|
257
478
|
const end=after?.snapshots?.[name];
|
|
258
|
-
|
|
479
|
+
// A test run's own outputs: git-ignored files it created without reading
|
|
480
|
+
// what was there (test/tmp). When they are the only observed change, the
|
|
481
|
+
// run is kept with the end checkpoint, which includes them.
|
|
482
|
+
let outputs=null;
|
|
483
|
+
let eventsExplained=false,unexplained=end?.inputEventPaths||null;
|
|
484
|
+
if(guard&&snap&&end&&snap.planId===end.planId&&snap.probeHash===end.probeHash&&snap.observationHealthy&&end.observationHealthy&&
|
|
485
|
+
(snap.inputEventSerial===end.inputEventSerial||end.inputEventPathsComplete===true)){
|
|
486
|
+
// A directory event is not an input change; a change to its files shows
|
|
487
|
+
// in the end checkpoint's file map, which is compared too. A link is kept.
|
|
488
|
+
const directory=rel=>{try{return fs.lstatSync(path.join(root,rel)).isDirectory();}catch{return false;}};
|
|
489
|
+
const changed=changedFiles(snap.files,end.files),events=(end.inputEventPaths||[]).filter(rel=>!directory(rel));
|
|
490
|
+
const touched=[...new Set([...changed,...events])].sort();
|
|
491
|
+
const output=rel=>guard.isOutput(rel)||general&&(toolOutput(rel,guard)||pnpmNameless(rel,guard,root,[snap.files,end.files]));
|
|
492
|
+
const ignored=touched.length&&touched.every(output)?gitIgnored(root,touched):new Set();
|
|
493
|
+
unexplained=touched.filter(file=>!ignored.has(file));
|
|
494
|
+
if(!unexplained.length){eventsExplained=true;if(changed.length)outputs=changed;}
|
|
495
|
+
}
|
|
496
|
+
// A run that hid git-ignored files: what it wrote beneath them (test
|
|
497
|
+
// output, coverage data) is not a change to its inputs (visibleChange);
|
|
498
|
+
// anything else is named.
|
|
499
|
+
let hiddenOnly=false;
|
|
500
|
+
if(sandbox?.hidden&&snap&&end&&snap.planId===end.planId&&snap.observationHealthy&&end.observationHealthy&&
|
|
501
|
+
(snap.inputEventSerial===end.inputEventSerial||end.inputEventPathsComplete===true)){
|
|
502
|
+
const before=snap.files||{},after=end.files||{},events=end.inputEventPaths||[];
|
|
503
|
+
// During the run a new file outside the hidden entries was readable: it counts.
|
|
504
|
+
const found=visibleChange(root,sandbox.hidden,before,after,[...Object.keys(before),...Object.keys(after),...events],
|
|
505
|
+
{during:true,docs:sandbox.docs});
|
|
506
|
+
if(found===null){eventsExplained=true;hiddenOnly=true;}else unexplained=[found];
|
|
507
|
+
}
|
|
508
|
+
// The end certificate, recorded or probed, must cover what this compile received.
|
|
509
|
+
// With no input event, gap or content change in between, it also holds at the start.
|
|
510
|
+
// A run checked by full probes is compared with it too, for its run facts.
|
|
511
|
+
if(recorded&&!certificate){
|
|
512
|
+
const endCertificate=directRunCertificate(plan,selected,end,root),
|
|
513
|
+
questions=endCertificate||(!recording&&probeQuestions(plan,selected,end,root));
|
|
514
|
+
if(!questions)captureIssues.push('no discovery certificate at the end checkpoint');
|
|
515
|
+
else{
|
|
516
|
+
const issue=validateRunRecord(recordText,questions,runFacts=[]);
|
|
517
|
+
if(issue)captureIssues.push(`compiler inputs differ from discovery certificate (${issue})`);
|
|
518
|
+
if(recording)probeBefore=probeAfter=endCertificate.value;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// The receipt holds only run facts that still answer as they did during the run.
|
|
522
|
+
runFacts.sort();
|
|
523
|
+
if(runFacts.length>10000)captureIssues.push('too many lookups outside the discovery certificate');
|
|
524
|
+
else{const changed=runFactChange(runFacts);
|
|
525
|
+
if(changed)captureIssues.push(`a lookup outside the discovery certificate changed during execution: ${changed}`);}
|
|
526
|
+
if(recording&&snap?.observationGaps!==end?.observationGaps)
|
|
527
|
+
captureIssues.push('observation gap during execution');
|
|
528
|
+
if(!unobservedDirectRun&&(!end||!end.observationHealthy))captureIssues.push('end checkpoint unavailable');
|
|
259
529
|
if(JSON.stringify(environmentBefore)!==JSON.stringify(environmentAfter))
|
|
260
530
|
captureIssues.push('declared environment changed during execution');
|
|
261
|
-
if(!probeBefore||!probeAfter||probeBefore!==snap?.probeHash||
|
|
531
|
+
if(!unobservedDirectRun&&(!probeBefore||!probeAfter||probeBefore!==(recording?end:snap)?.probeHash||
|
|
532
|
+
probeAfter!==end?.probeHash))
|
|
262
533
|
captureIssues.push('declared state probe unavailable or changed');
|
|
263
534
|
if(before?.observation?.generation!==after?.observation?.generation)
|
|
264
535
|
captureIssues.push('observer generation changed during execution');
|
|
265
536
|
if(snap?.planGeneration!==end?.planGeneration)
|
|
266
537
|
captureIssues.push('input plan rebuilt during execution');
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
538
|
+
// The reason names up to five files the run did not explain, and a count of the rest.
|
|
539
|
+
if(snap?.inputEventSerial!==end?.inputEventSerial&&!eventsExplained){
|
|
540
|
+
const named=[...new Set(unexplained||[])],more=named.length-5,
|
|
541
|
+
unrecorded=end?.inputEventPathsComplete===false?' (not every event path was kept)':'';
|
|
542
|
+
captureIssues.push('declared input event occurred during execution'+(named.length?
|
|
543
|
+
`: ${named.slice(0,5).join(', ')}${more>0?` and ${more} more`:''}`:'')+unrecorded);
|
|
544
|
+
}
|
|
545
|
+
// A recorded certificate changes only the probe part of the fingerprint.
|
|
546
|
+
if(!outputs&&!hiddenOnly&&(!snap||!end||snap.planId!==end.planId||(recording?snap.inputKey!==end.inputKey:snap.fingerprint!==end.fingerprint)||
|
|
547
|
+
snap.revision!==end.revision))captureIssues.push('declared inputs changed or were not observed');
|
|
548
|
+
// A run that hid git-ignored files and failed with a refusal that names
|
|
549
|
+
// one runs again in the sandbox without hiding them: the check needs
|
|
550
|
+
// them. That run's result is kept, compared on every file, when the
|
|
551
|
+
// inputs held through it too. Its own refusal is handled as below.
|
|
552
|
+
let hiddenRerun=null;
|
|
553
|
+
if((sandbox?.hidden?.length||sandbox?.docs?.length)&&result==='FAIL'&&!requestedSignal){
|
|
554
|
+
const line=hiddenRefusal(output.head+'\n'+output.tail,[...sandbox.hidden||[],...sandbox.docs||[]]);
|
|
555
|
+
if(line){
|
|
556
|
+
console.error(`redue: the sandboxed run failed with what looks like a refusal of a hidden file (${line}); running it again in the sandbox without hiding files`);
|
|
557
|
+
const again=await runToEnd(sandboxArgv(sandbox.mode,command,sandbox.options),
|
|
558
|
+
{cwd:plan.cwd,env:sandbox.env,echo:true,started:child=>{activeChild=child;}});
|
|
559
|
+
activeChild=null;
|
|
560
|
+
hiddenRerun={line,exitCode:again.status,signal:again.signal||null,...again.error?{errorCode:again.error.code||again.error.name}:{}};
|
|
561
|
+
if(requestedSignal){
|
|
562
|
+
captureIssues.push('interrupted during the run without hidden files');
|
|
563
|
+
Object.assign(invocation,{status:'interrupted',exitCode:null,signal:requestedSignal});
|
|
564
|
+
}
|
|
565
|
+
else if(again.error||again.status===null)
|
|
566
|
+
captureIssues.push(`the run without hidden files did not finish (${again.error?.code||again.signal}), so a refusal of a file git ignores is not ruled out (${line})`);
|
|
567
|
+
else{
|
|
568
|
+
const wait=decisionWait(process.env.VSTATE_CAPTURE_TIMEOUT_MS);
|
|
569
|
+
// Inputs, plan, observer and environment held through it as through the first run.
|
|
570
|
+
let confirmed=null;const environmentNow=contextHashes()[name];
|
|
571
|
+
try{confirmed=await call({action:'snapshot',name,contextHashes:contextHashes(),
|
|
572
|
+
...Number.isSafeInteger(snap?.inputEventSerial)?{sinceSerial:snap.inputEventSerial}:{}},wait.timeout,wait.deadline);}catch{}
|
|
573
|
+
const last=confirmed?.snapshots?.[name];
|
|
574
|
+
if(!last?.observationHealthy||!snap||last.planId!==snap.planId||last.fingerprint!==snap.fingerprint||last.revision!==snap.revision||
|
|
575
|
+
last.inputEventSerial!==end?.inputEventSerial||last.planGeneration!==snap.planGeneration||
|
|
576
|
+
confirmed.observation?.generation!==before?.observation?.generation)
|
|
577
|
+
captureIssues.push('declared inputs changed or were not observed during the run without hidden files');
|
|
578
|
+
if(JSON.stringify(environmentNow)!==JSON.stringify(environmentBefore))
|
|
579
|
+
captureIssues.push('declared environment changed during the run without hidden files');
|
|
580
|
+
invocation.hiddenExitCode=invocation.exitCode;invocation.exitCode=again.status;
|
|
581
|
+
result=again.status===0?'PASS':'FAIL';
|
|
582
|
+
output.head=`${again.stdout}\n${again.stderr}`.slice(0,65536);output.tail='';
|
|
583
|
+
sandbox.hidden=null;sandbox.docs=null;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
// A sandboxed run that failed with what looks like a refusal by the
|
|
588
|
+
// sandbox runs again outside it. When that run exits differently, the
|
|
589
|
+
// sandbox changed the result: the plain run's result is kept, not reused.
|
|
590
|
+
// An interrupt before or during it stops there: the run is interrupted.
|
|
591
|
+
let sandboxRerun=null;
|
|
592
|
+
if(sandbox&&result==='FAIL'&&!requestedSignal){
|
|
593
|
+
const line=denialLine(output.head+'\n'+output.tail);
|
|
594
|
+
if(line){
|
|
595
|
+
console.error(`redue: the sandboxed run failed with what looks like a refusal by the sandbox (${line}); running it again outside the sandbox`);
|
|
596
|
+
// Interrupting REDUE interrupts the rerun's group too (forward).
|
|
597
|
+
const plain=await runToEnd(command,{cwd:plan.cwd,env:plainEnv,echo:true,started:child=>{activeChild=child;}});
|
|
598
|
+
activeChild=null;
|
|
599
|
+
sandboxRerun={line,exitCode:plain.status,signal:plain.signal||null,...plain.error?{errorCode:plain.error.code||plain.error.name}:{}};
|
|
600
|
+
if(requestedSignal){
|
|
601
|
+
captureIssues.push('interrupted during the run outside the sandbox');
|
|
602
|
+
Object.assign(invocation,{status:'interrupted',exitCode:null,signal:requestedSignal});
|
|
603
|
+
}
|
|
604
|
+
// A rerun that did not start or finish tells nothing: the sandboxed result stands, not reused.
|
|
605
|
+
else if(plain.error||plain.status===null)
|
|
606
|
+
captureIssues.push(`the run outside the sandbox did not finish (${plain.error?.code||plain.signal}), so a refusal by the sandbox is not ruled out (${line})`);
|
|
607
|
+
else if(plain.status!==invocation.exitCode){
|
|
608
|
+
captureIssues.push(`the sandbox changed the result: exit ${invocation.exitCode} inside it, ${plain.status??plain.signal} outside it (${line}); `+
|
|
609
|
+
'if this repeats, run redue init again to choose a recipe for this check');
|
|
610
|
+
invocation.sandboxedExitCode=invocation.exitCode;invocation.exitCode=plain.status;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
// A test run whose network was not supervised throughout is not reused.
|
|
615
|
+
if(sandbox?.stats){const issue=supervisorIssue(sandbox.stats);if(issue)captureIssues.push(issue);fs.rmSync(sandbox.stats,{force:true});}
|
|
616
|
+
if(sandbox)fs.rmSync(sandbox.dirs.tmp,{recursive:true,force:true});
|
|
271
617
|
const stable=captureIssues.length===0;
|
|
272
|
-
|
|
618
|
+
// Two runs on the same inputs, environment and plan that disagree make the
|
|
619
|
+
// check nondeterministic until its inputs change.
|
|
620
|
+
let nondeterministic=false;
|
|
621
|
+
const declared=hashes=>Object.entries(hashes||{}).filter(([key])=>!key.startsWith('__redue_env_'));
|
|
622
|
+
if(sandboxRerun)result=invocation.status==='exited'?(invocation.exitCode===0?'PASS':'FAIL'):null;
|
|
623
|
+
if((testRun||sandboxed)&&stable&&result){
|
|
624
|
+
const prior=readReceipts(state).checks[name];
|
|
625
|
+
// A difference explained by inputs REDUE did not observe proves nothing.
|
|
626
|
+
nondeterministic=Boolean(!guard?.issues.length&&prior?.stable&&!prior.guardIssues?.length&&prior.result&&prior.planId===(snap?.planId??plan.id)&&
|
|
627
|
+
prior.fingerprint===((recording||outputs?end:snap)?.fingerprint??null)&&
|
|
628
|
+
JSON.stringify(declared(prior.environmentHashes))===JSON.stringify(declared(environmentBefore))&&
|
|
629
|
+
JSON.stringify(prior.runFacts||[])===JSON.stringify([...runFacts].sort())&&
|
|
630
|
+
JSON.stringify(prior.environmentReads||{})===JSON.stringify(guard?.environmentReads||{})&&
|
|
631
|
+
(prior.result!==result||prior.nondeterministic===true));
|
|
632
|
+
}
|
|
633
|
+
// A recorded run's receipt carries its run facts, even none; observers that
|
|
634
|
+
// predate run facts do not reuse version 2.
|
|
635
|
+
// The recipe that made the receipt, which status and the hook name.
|
|
636
|
+
// A sandboxed receipt names what the run did: hid the git-ignored files or not.
|
|
637
|
+
const receipt={result,invocation,target,stable,fingerprintVersion:recorded||testRun?2:1,
|
|
638
|
+
recipe:(fallback?scriptQualification:own?(sandbox?.docs?docsQualification:sandbox?.hidden?hiddenQualification:sandboxQualification):selected.qualification)||null,
|
|
639
|
+
...(recorded||testRun?{runFacts}:{}),
|
|
640
|
+
...(testRun?{verificationRecipe:general?scriptInterpretation:testsInterpretation,guardIssues:guard?.issues||['the run record is unavailable'],
|
|
641
|
+
environmentReads:guard?.environmentReads||{},...(nondeterministic?{nondeterministic}:{})}:{}),
|
|
273
642
|
resolutionLinks:plan.resolutionLinks||[],
|
|
274
643
|
coverage:{qualified:plan.unresolved.length===0,unresolved:plan.unresolved},
|
|
275
644
|
checkpoint:{status:stable?'stable':'unavailable_or_changed',issues:captureIssues,
|
|
@@ -277,14 +646,23 @@ async function execute() {
|
|
|
277
646
|
...(process.env.REDUE_PUBLIC_INPUT_TRACE==='1'?{
|
|
278
647
|
startInputEventSerial:snap?.inputEventSerial??null,
|
|
279
648
|
endInputEventSerial:end?.inputEventSerial??null}: {})},
|
|
280
|
-
planId:snap?.planId??plan.id,fingerprint:snap?.fingerprint??null,
|
|
649
|
+
planId:snap?.planId??plan.id,fingerprint:(recording||outputs?end:snap)?.fingerprint??null,
|
|
281
650
|
provider:plan.provider,environmentHashes:environmentBefore,
|
|
282
|
-
|
|
651
|
+
...(observedContext?{observedContext,verificationRecipe:directInterpretation}:{}),
|
|
652
|
+
// `hidden`: the git-ignored entries the run did not see, which the observer does not compare.
|
|
653
|
+
...(own?{verificationRecipe:sandboxInterpretationOf(sandbox?.docs?docsQualification:sandbox?.hidden?hiddenQualification:sandboxQualification,selected.kind),
|
|
654
|
+
sandbox:{mode:sandbox?.mode??null,hidden:sandbox?.hidden??null,...sandbox?.docs?{docs:sandbox.docs}:{},...hiddenRerun?{hiddenRerun}:{},...sandboxRerun?{rerun:sandboxRerun}:{}}}:{}),
|
|
655
|
+
...(fallback?{sandbox:{mode:null,fallback:'node-script',issue:support.issue}}:{}),
|
|
656
|
+
files:(outputs?end:snap)?.files??null,observerGeneration:before?.observation?.generation??null,
|
|
283
657
|
startRevision:snap?.revision??null,endRevision:end?.revision??null};
|
|
284
658
|
const persistenceStarted=performance.now();
|
|
285
659
|
commitReceipt(state,name,receipt);
|
|
660
|
+
note(state,{kind:'run',check:name,ms:invocation.durationMs??null,result,stable});
|
|
661
|
+
if(testRun){const reads=environmentReads();reads[name]=receipt.environmentReads;atomicJson(readsFile,{schema:1,checks:reads});}
|
|
286
662
|
timing('cli.receipt_persistence',persistenceStarted);
|
|
663
|
+
const reloadStarted=performance.now();
|
|
287
664
|
try{await call({action:'reload'});}catch{/* receipt revisions invalidate older status even when reload IPC is unavailable */}
|
|
665
|
+
timing('cli.receipt_reload',reloadStarted);
|
|
288
666
|
const summary={check:name,result,target:target.status,
|
|
289
667
|
invocation:invocation.status,...(invocation.errorCode?{error_code:invocation.errorCode}:{}),
|
|
290
668
|
stable,coverage_qualified:receipt.coverage.qualified};
|
|
@@ -367,16 +745,30 @@ async function main(){
|
|
|
367
745
|
if(processAlive(pid)){await call({action:'stop'});
|
|
368
746
|
for(let i=0;i<100&&fs.existsSync(path.join(state,'observer.lock'));i++)
|
|
369
747
|
await new Promise(r=>setTimeout(r,100));
|
|
370
|
-
if(fs.existsSync(path.join(state,'observer.lock')))throw Error('observer did not stop');
|
|
748
|
+
if(fs.existsSync(path.join(state,'observer.lock')))throw Error('observer did not stop');
|
|
749
|
+
// It releases the lock before it exits. On Windows a file it still
|
|
750
|
+
// holds open stays listed after deletion, and removing the
|
|
751
|
+
// directory fails with ENOTEMPTY until the process is gone.
|
|
752
|
+
for(let i=0;i<100&&processAlive(pid);i++)await new Promise(r=>setTimeout(r,100));}
|
|
371
753
|
}
|
|
372
|
-
|
|
754
|
+
// A stopping observer can still be closing its files (Windows: ENOTEMPTY, EBUSY).
|
|
755
|
+
fs.rmSync(state,{recursive:true,force:true,maxRetries:10,retryDelay:200});
|
|
373
756
|
console.log(JSON.stringify({removed:state}));return;
|
|
374
757
|
}
|
|
375
758
|
if(action==='status'){console.log(JSON.stringify(cached()));return;}
|
|
376
759
|
if(action==='sync'||action==='detail'){
|
|
377
760
|
const started=performance.now();
|
|
761
|
+
if(profiling&&config.checks.some(check=>check.qualification===directQualification)){
|
|
762
|
+
const proxyShapes=Object.entries(process.env).filter(([key])=>/^npm_config_.*proxy$/i.test(key)).map(([key,value])=>{
|
|
763
|
+
try{const url=new URL(value),portDigest=digest(url.port);url.port='';
|
|
764
|
+
return {key,kind:'url',portDigest,withoutPortDigest:digest(url.href)};}
|
|
765
|
+
catch{return {key,kind:value===''?'empty':'non-url',valueDigest:digest(value)};}
|
|
766
|
+
});
|
|
767
|
+
timing('cli.direct_context',started,{interpretation:directInterpretation,
|
|
768
|
+
observedNpmDigest:observedNpmContext(),proxyShapes});
|
|
769
|
+
}
|
|
378
770
|
let value;try{value=await decisionCall({action:'sync',contextHashes:contextHashes()},
|
|
379
|
-
|
|
771
|
+
decisionWait(process.env.VSTATE_SYNC_TIMEOUT_MS));
|
|
380
772
|
// A client upgrade can encounter an older running observer. Its successful
|
|
381
773
|
// control response does not establish that it evaluated the latest receipt.
|
|
382
774
|
if(value.receipt_revision===null){
|
|
@@ -390,7 +782,9 @@ async function main(){
|
|
|
390
782
|
}}
|
|
391
783
|
catch(e){value=unavailableCached(state,e.message,config.checks);process.exitCode=2;}
|
|
392
784
|
timing('cli.synchronized_status',started,{available:value.observation?.healthy?1:0});
|
|
393
|
-
|
|
785
|
+
// In the front's process (bin/redue.mjs), the value is handed back.
|
|
786
|
+
if(action==='sync'&&process.argv.includes('--value'))syncValue=value;
|
|
787
|
+
else if(action==='sync')console.log(JSON.stringify(value));
|
|
394
788
|
else for(const row of value.checks)console.log(`${row.name}: ${row.freshness}/${row.result||'NO RESULT'} — ${row.reason}`);
|
|
395
789
|
return;
|
|
396
790
|
}
|
|
@@ -398,4 +792,5 @@ async function main(){
|
|
|
398
792
|
if(action==='run'){await execute();return;}
|
|
399
793
|
throw Error(`unknown action ${action}`);
|
|
400
794
|
}
|
|
401
|
-
|
|
795
|
+
export let syncValue=null;
|
|
796
|
+
export const finished=main().catch(e=>{console.error(`redue: ${e.message}`);process.exitCode=2;});
|