@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.
Files changed (67) hide show
  1. package/CHANGELOG.md +806 -0
  2. package/README.md +157 -99
  3. package/bin/redue.mjs +267 -61
  4. package/docs/README.md +3 -1
  5. package/docs/user/agents.md +333 -15
  6. package/docs/user/configuration.md +28 -2
  7. package/docs/user/direct-typescript.md +219 -0
  8. package/docs/user/getting-started.md +49 -7
  9. package/docs/user/json.md +4 -2
  10. package/docs/user/node-script.md +177 -0
  11. package/docs/user/npm-launcher-notice.md +42 -0
  12. package/docs/user/pilot.md +146 -0
  13. package/docs/user/sandbox.md +312 -0
  14. package/docs/user/shell.md +3 -3
  15. package/docs/user/supported-projects.md +69 -23
  16. package/docs/user/tests-direct.md +143 -0
  17. package/package.json +4 -3
  18. package/src/agent-integration.mjs +467 -0
  19. package/src/agent-policy.md +28 -0
  20. package/src/cached-state.mjs +24 -1
  21. package/src/claude-hook.mjs +616 -0
  22. package/src/cli.mjs +449 -54
  23. package/src/daemon.mjs +442 -93
  24. package/src/decision-profile.mjs +15 -1
  25. package/src/decision-validation.mjs +12 -1
  26. package/src/declared-plan.mjs +38 -6
  27. package/src/direct-run-certificate.mjs +254 -0
  28. package/src/direct-typescript-probe.mjs +27 -0
  29. package/src/direct-typescript.mjs +205 -0
  30. package/src/glob.mjs +8 -0
  31. package/src/history-worker.mjs +21 -13
  32. package/src/index.mjs +108 -21
  33. package/src/installed-inputs.mjs +7 -1
  34. package/src/installed-observation.mjs +23 -0
  35. package/src/landlock.py +527 -0
  36. package/src/linux-inotify.py +39 -10
  37. package/src/node-onboarding.mjs +203 -54
  38. package/src/node-script.mjs +406 -0
  39. package/src/npm-integrity.mjs +124 -0
  40. package/src/npm-typecheck-environment.mjs +36 -0
  41. package/src/npmrc.mjs +16 -0
  42. package/src/observation.mjs +34 -12
  43. package/src/path-identity.mjs +8 -0
  44. package/src/plan-worker.mjs +17 -17
  45. package/src/pnpm-inputs.mjs +122 -57
  46. package/src/pnpm-shim-templates.json +180 -0
  47. package/src/pnpm-shims.mjs +54 -0
  48. package/src/pnpm-typecheck-probe.mjs +11 -6
  49. package/src/presentation.mjs +13 -4
  50. package/src/probe-deadline.mjs +37 -0
  51. package/src/probe-result.mjs +7 -3
  52. package/src/project-location.mjs +34 -0
  53. package/src/python-onboarding.mjs +89 -0
  54. package/src/run-recorder.cjs +91 -0
  55. package/src/sandbox.mjs +653 -0
  56. package/src/setup.mjs +205 -0
  57. package/src/stats.mjs +72 -0
  58. package/src/test-direct.mjs +608 -0
  59. package/src/test-recorder.cjs +435 -0
  60. package/src/typescript-contract-probe.mjs +4 -187
  61. package/src/typescript-input-facts.mjs +92 -0
  62. package/src/typescript-invocation.mjs +92 -0
  63. package/src/typescript-list.mjs +118 -13
  64. package/src/typescript-qualification.mjs +29 -49
  65. package/src/workspace-manifests.mjs +24 -0
  66. package/src/yarn-workspace-inputs.mjs +3 -0
  67. 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
- const decisionDeadlineMs=process.platform==='win32'?30000:15000;
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
- function contextHashes() {return Object.fromEntries(config.checks.map(check=>[
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=plan.qualification==='pnpm-tsc-v1'||
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
- function call(message,timeout=5000) {
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 timer=setTimeout(()=>{client.destroy();reject(Error('observer synchronization timeout'));},timeout);
97
- client.on('connect',()=>client.write(JSON.stringify(message)+'\n'));
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
- const started=Date.now(),remaining=()=>Math.max(1,timeout-(Date.now()-started));
106
- try{return await call(message,remaining());}
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
- const runLock=acquireRunLock(state);
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
- if(process.platform==='linux'&&activeChild.pid){
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 probeBefore=probeHash({cwd:plan.cwd,probes:selected.probes||[],
195
- qualification:selected.qualification});
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()},captureTimeout);}
198
- catch{captureIssues.push('start observation unavailable');}
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(captureTimeout,5000));}
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
- const started=Date.now();
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(command);
387
+ const launch=windowsLaunch(sandbox?.argv??launched);
220
388
  child=spawn(launch.file,launch.args,{cwd:plan.cwd,
221
- stdio:'inherit',env:process.env,detached:process.platform==='linux',
222
- ...launch.options});}
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
- const result=invocation.status==='exited'?(invocation.exitCode===0?'PASS':'FAIL'):null;
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],probeAfter=probeHash({cwd:plan.cwd,
252
- probes:selected.probes||[],qualification:selected.qualification});
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
- try{after=await call({action:'snapshot',name,contextHashes:contextHashes()},captureTimeout);}
255
- catch{captureIssues.push('end observation unavailable');}
256
- timing('cli.end_checkpoint',endSnapshotStarted,{available:after?.snapshots?.[name]?1:0});
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
- if(!end||!end.observationHealthy)captureIssues.push('end checkpoint unavailable');
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||probeAfter!==end?.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
- if(snap?.inputEventSerial!==end?.inputEventSerial)
268
- captureIssues.push('declared input event occurred during execution');
269
- if(!snap||!end||snap.planId!==end.planId||snap.fingerprint!==end.fingerprint||
270
- snap.revision!==end.revision)captureIssues.push('declared inputs changed or were not observed');
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
- const receipt={result,invocation,target,stable,fingerprintVersion:1,
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
- files:snap?.files??null,observerGeneration:before?.observation?.generation??null,
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
- fs.rmSync(state,{recursive:true,force:true});
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
- Number(process.env.VSTATE_SYNC_TIMEOUT_MS||decisionDeadlineMs));
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
- if(action==='sync')console.log(JSON.stringify(value));
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
- main().catch(e=>{console.error(`redue: ${e.message}`);process.exitCode=2;});
795
+ export let syncValue=null;
796
+ export const finished=main().catch(e=>{console.error(`redue: ${e.message}`);process.exitCode=2;});