@redue/cli 0.1.0-alpha.2 → 0.1.0-alpha.20
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 +467 -0
- package/README.md +107 -2
- package/bin/redue.mjs +130 -49
- package/docs/README.md +2 -1
- package/docs/user/agents.md +299 -15
- package/docs/user/configuration.md +28 -2
- package/docs/user/direct-typescript.md +209 -0
- package/docs/user/getting-started.md +23 -2
- package/docs/user/json.md +2 -0
- package/docs/user/node-script.md +153 -0
- package/docs/user/npm-launcher-notice.md +42 -0
- package/docs/user/pilot.md +146 -0
- package/docs/user/receipt-selection-notice.md +60 -0
- package/docs/user/supported-projects.md +49 -21
- package/docs/user/tests-direct.md +132 -0
- package/package.json +4 -3
- package/src/agent-integration.mjs +388 -0
- package/src/agent-policy.md +28 -0
- package/src/cached-state.mjs +46 -8
- package/src/claude-hook.mjs +449 -0
- package/src/cli.mjs +263 -40
- package/src/daemon.mjs +433 -94
- package/src/decision-profile.mjs +15 -1
- package/src/decision-validation.mjs +10 -0
- package/src/declared-plan.mjs +29 -6
- package/src/direct-run-certificate.mjs +252 -0
- package/src/direct-typescript-probe.mjs +24 -0
- package/src/direct-typescript.mjs +185 -0
- package/src/glob.mjs +8 -0
- package/src/history-worker.mjs +21 -13
- package/src/index.mjs +72 -19
- package/src/installed-inputs.mjs +7 -1
- package/src/linux-inotify.py +39 -10
- package/src/node-onboarding.mjs +157 -48
- package/src/node-script.mjs +362 -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 +114 -54
- 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 +9 -1
- package/src/probe-deadline.mjs +37 -0
- package/src/probe-result.mjs +7 -3
- package/src/project-location.mjs +34 -0
- package/src/run-recorder.cjs +91 -0
- package/src/state-store.mjs +37 -1
- package/src/test-direct.mjs +560 -0
- package/src/test-recorder.cjs +406 -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 +108 -13
- package/src/typescript-qualification.mjs +29 -49
- package/src/workspace-manifests.mjs +22 -0
- package/src/yarn-workspace-typecheck-probe.mjs +2 -1
package/src/cli.mjs
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
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 {changedFiles,coverageReadable,gitIgnored,recordedEnvironment,testEnvironmentIssue,testGuardConfig,testRunGuard,testRunFacts,sharedInstalledFile,testsInterpretation,testsQualification} from './test-direct.mjs';
|
|
8
|
+
import {childProgram,lookup,pnpmConfigFiles,scriptCaches,scriptInstalledPaths,scriptInterpretation,scriptQualification,scriptRunFacts,toolOutput,toolchainDirs} from './node-script.mjs';
|
|
3
9
|
import path from 'node:path';
|
|
4
10
|
import net from 'node:net';
|
|
5
11
|
import os from 'node:os';
|
|
@@ -15,14 +21,16 @@ import {createHash,randomUUID} from 'node:crypto';
|
|
|
15
21
|
import {assertOwnedStatePlacement} from './owned-state.mjs';
|
|
16
22
|
import {permittedRoots} from './installed-inputs.mjs';
|
|
17
23
|
import {acquireRunLock,inspectRunLock,recoverRunLock} from './run-lock.mjs';
|
|
18
|
-
import {atomicJson,commitReceipt} from './state-store.mjs';
|
|
19
|
-
import {timing} from './decision-profile.mjs';
|
|
24
|
+
import {atomicJson,commitReceipt,readReceipts,receiptRevision} from './state-store.mjs';
|
|
25
|
+
import {timing,profiling,mark,traceContext} from './decision-profile.mjs';
|
|
20
26
|
import {readCachedStatus,unavailableCached} from './cached-state.mjs';
|
|
21
27
|
import {renderRun} from './presentation.mjs';
|
|
22
28
|
|
|
29
|
+
mark('cli.loaded');
|
|
23
30
|
const here=path.dirname(fileURLToPath(import.meta.url));
|
|
24
31
|
const [configArg,action,name]=process.argv.slice(2);
|
|
25
32
|
if(!configArg||!action)throw Error('internal usage: cli.mjs CONFIG ACTION [check]');
|
|
33
|
+
const configStarted=performance.now();
|
|
26
34
|
const configFile=path.resolve(configArg), config=JSON.parse(fs.readFileSync(configFile));
|
|
27
35
|
const maintenance=['stop','uninstall'].includes(action);
|
|
28
36
|
const root=maintenance?path.resolve(config.root):realObservedPath(config.root), state=path.resolve(config.state),
|
|
@@ -34,10 +42,18 @@ if([path.parse(state).root,os.homedir(),path.dirname(os.homedir())]
|
|
|
34
42
|
!/^(?:vstate|redue)-/.test(path.basename(state)))
|
|
35
43
|
throw Error('state directory name must start with redue- (or legacy vstate-); use a dedicated directory outside the checkout');
|
|
36
44
|
const ownerFile=path.join(state,'vstate-owner-v1.json');
|
|
45
|
+
timing('cli.config_resolution',configStarted);
|
|
37
46
|
// A native Windows decision reconciles declared contents instead of trusting
|
|
38
47
|
// watcher history. The measured 6k-file pnpm workspace can legitimately need
|
|
39
48
|
// more than the former 15 s default; uncertainty still wins on expiry.
|
|
40
|
-
|
|
49
|
+
// A decision can include a full probe, whose deadline the observer publishes.
|
|
50
|
+
// Read when a wait starts, so a deadline the previous decision raised counts.
|
|
51
|
+
const decisionBaseMs=process.platform==='win32'?30000:15000;
|
|
52
|
+
function publishedProbeDeadline() {
|
|
53
|
+
try{return publishedDeadline(JSON.parse(fs.readFileSync(path.join(state,'status.json'),'utf8')).probe_deadline_ms);}
|
|
54
|
+
catch{return 0;}
|
|
55
|
+
}
|
|
56
|
+
const decisionDeadline=()=>Math.max(decisionBaseMs,publishedProbeDeadline()+10000);
|
|
41
57
|
const digest=value=>createHash('sha256').update(value).digest('hex');
|
|
42
58
|
function nodeIdentity() {const real=fs.realpathSync(process.execPath),st=fs.statSync(real,{bigint:true});
|
|
43
59
|
return digest(JSON.stringify([real,process.version,...['dev','ino','mode','size','mtimeNs','ctimeNs']
|
|
@@ -49,7 +65,14 @@ function pathExecutableIdentity(name) {
|
|
|
49
65
|
catch{}
|
|
50
66
|
return digest('<unavailable>');
|
|
51
67
|
}
|
|
52
|
-
|
|
68
|
+
// The variables each test check's last run read directly (see testRunGuard).
|
|
69
|
+
const readsFile=path.join(state,'environment-reads-v1.json');
|
|
70
|
+
function environmentReads() {
|
|
71
|
+
try{const value=JSON.parse(fs.readFileSync(readsFile,'utf8'));return value?.schema===1&&value.checks||{};}catch{return {};}
|
|
72
|
+
}
|
|
73
|
+
const recordedRecipes=[testsQualification,scriptQualification];
|
|
74
|
+
function contextHashes() {const reads=config.checks.some(check=>recordedRecipes.includes(check.qualification))?environmentReads():{};
|
|
75
|
+
return Object.fromEntries(config.checks.map(check=>[
|
|
53
76
|
check.name,Object.fromEntries([...(check.environment?.variables||[])
|
|
54
77
|
.map(key=>[key,digest(process.env[key]??'<unset>')]),
|
|
55
78
|
...(check.environment?.prefixes||[]).map(prefix=>[
|
|
@@ -59,15 +82,20 @@ function contextHashes() {return Object.fromEntries(config.checks.map(check=>[
|
|
|
59
82
|
.sort(([a],[b])=>a.localeCompare(b))))]),
|
|
60
83
|
...(check.environment?.pathExecutables||[]).map(name=>[
|
|
61
84
|
`__vstate_path_executable_${name}__`,pathExecutableIdentity(name)]),
|
|
62
|
-
...(check.environment?.executableIdentity?[['__vstate_node_identity__',nodeIdentity()]]:[])
|
|
85
|
+
...(check.environment?.executableIdentity?[['__vstate_node_identity__',nodeIdentity()]]:[]),
|
|
86
|
+
// The variables the last test run read directly, compared here: which
|
|
87
|
+
// list was compared, and the first variable whose value now differs.
|
|
88
|
+
// Control requests are small, so the values stay out of them.
|
|
89
|
+
...(reads[check.name]?[['__redue_env_reads__',digest(JSON.stringify(reads[check.name]))],
|
|
90
|
+
['__redue_env_changed__',Object.keys(reads[check.name]).find(key=>
|
|
91
|
+
(process.env[key]===undefined?'<unset>':digest(process.env[key]))!==reads[check.name][key])||'']]:[])])]));}
|
|
63
92
|
function probeHash(plan) {
|
|
64
93
|
const started=performance.now();
|
|
65
94
|
const values=[];
|
|
95
|
+
// The observer's current deadline, so a large project's probe is not cut short.
|
|
96
|
+
const timeout=Math.max(probeFloorMs(plan.qualification),publishedProbeDeadline());
|
|
66
97
|
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,
|
|
98
|
+
const out=spawnSync(argv[0],[...argv.slice(1),...(builtInProbe(argv)?[`--redue-deadline=${timeout}`]:[])],{cwd:plan.cwd,
|
|
71
99
|
timeout,maxBuffer:1024*1024,stdio:['ignore','pipe','pipe'],env:process.env});
|
|
72
100
|
if(out.error||out.signal||out.status!==0){
|
|
73
101
|
timing('cli.probe',started,{outcome:out.error?.code||out.signal||out.status||'error',
|
|
@@ -92,13 +120,13 @@ function owner(){
|
|
|
92
120
|
}
|
|
93
121
|
function call(message,timeout=5000) {
|
|
94
122
|
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'));
|
|
123
|
+
let data='',done=false;mark('client.control_connect',{deadlineMs:timeout});const client=net.createConnection(socket);
|
|
124
|
+
const timer=setTimeout(()=>{mark('client.control_timeout');client.destroy();reject(Error('observer synchronization timeout'));},timeout);
|
|
125
|
+
client.on('connect',()=>{mark('client.control_submit');client.write(JSON.stringify({...message,...(profiling?{diagnostic:traceContext()}: {})})+'\n');});
|
|
98
126
|
client.on('data',chunk=>data+=chunk);
|
|
99
|
-
client.on('end',()=>{clearTimeout(timer);if(done)return;done=true;
|
|
127
|
+
client.on('end',()=>{mark('client.control_complete',{bytes:data.length});clearTimeout(timer);if(done)return;done=true;
|
|
100
128
|
try{resolve(JSON.parse(data));}catch(e){reject(e);}});
|
|
101
|
-
client.on('error',e=>{clearTimeout(timer);reject(e);});
|
|
129
|
+
client.on('error',e=>{mark('client.control_error',{code:e.code});clearTimeout(timer);reject(e);});
|
|
102
130
|
});
|
|
103
131
|
}
|
|
104
132
|
async function decisionCall(message,timeout) {
|
|
@@ -144,11 +172,26 @@ async function decisionCall(message,timeout) {
|
|
|
144
172
|
}
|
|
145
173
|
}
|
|
146
174
|
const cached=()=>readCachedStatus(state,config.checks);
|
|
175
|
+
async function testRunBoundary(phase) {
|
|
176
|
+
if(process.env.VSTATE_TEST_FAULTS!=='1'||process.env.VSTATE_TEST_RUN_BOUNDARY!==phase)return;
|
|
177
|
+
const release=path.join(state,'test-run-release');
|
|
178
|
+
fs.writeFileSync(path.join(state,'test-run-paused'),phase);
|
|
179
|
+
const until=Date.now()+5000;
|
|
180
|
+
while(!fs.existsSync(release)&&Date.now()<until)await new Promise(resolve=>setTimeout(resolve,10));
|
|
181
|
+
if(!fs.existsSync(release))throw Error('test run boundary was not released');
|
|
182
|
+
}
|
|
147
183
|
async function execute() {
|
|
148
184
|
const totalStarted=performance.now();
|
|
149
185
|
const selected=config.checks.find(c=>c.name===name);
|
|
150
186
|
if(!selected)throw Error(`unknown check ${name}`);
|
|
151
|
-
|
|
187
|
+
// Recording uses existing state only. Never initialize or repair ownership
|
|
188
|
+
// as a fallback for unavailable observation.
|
|
189
|
+
const expectedOwner={schema:1,kind:'vstate-product-alpha',config:configFile,root,state};
|
|
190
|
+
const existingOwner=JSON.parse(fs.readFileSync(ownerFile,'utf8'));
|
|
191
|
+
if(JSON.stringify(existingOwner)!==JSON.stringify(expectedOwner))
|
|
192
|
+
throw Error('state ownership marker mismatch');
|
|
193
|
+
const lockStarted=performance.now(),runLock=acquireRunLock(state);
|
|
194
|
+
timing('cli.run_lock',lockStarted);
|
|
152
195
|
const captureIssues=[];
|
|
153
196
|
let activeChild=null,requestedSignal=null,treeStop=null,preserveRunLock=false;
|
|
154
197
|
const forward=signal=>{requestedSignal=signal;
|
|
@@ -172,6 +215,7 @@ async function execute() {
|
|
|
172
215
|
try {
|
|
173
216
|
if(!fs.existsSync(path.join(state,'plans-v1.json')))
|
|
174
217
|
throw Error('observer input plan is not ready; wait for redue status to report ready');
|
|
218
|
+
const resolutionStarted=performance.now();
|
|
175
219
|
const plan=JSON.parse(fs.readFileSync(path.join(state,'plans-v1.json'))).plans[name];
|
|
176
220
|
if(!plan)throw Error('selected input plan unavailable');
|
|
177
221
|
if(plan.selectedConfigHash!==digest(JSON.stringify(selected)))
|
|
@@ -181,22 +225,79 @@ async function execute() {
|
|
|
181
225
|
throw Error('invalid execution argv');
|
|
182
226
|
if(digest(JSON.stringify(command))!==plan.commandIdentity.argvHash)
|
|
183
227
|
throw Error('execution argv differs from discovered plan');
|
|
228
|
+
timing('cli.command_resolution',resolutionStarted);
|
|
184
229
|
const runId=randomUUID();
|
|
185
230
|
const contextStarted=performance.now();
|
|
186
231
|
const environmentBefore=contextHashes()[name];
|
|
232
|
+
const observedContext=selected.qualification===directQualification?{interpretation:directInterpretation,npmProxyDigest:observedNpmContext()}:null;
|
|
187
233
|
timing('cli.context_before',contextStarted);
|
|
188
234
|
const
|
|
189
|
-
captureTimeout=Number(process.env.VSTATE_CAPTURE_TIMEOUT_MS||
|
|
235
|
+
captureTimeout=Number(process.env.VSTATE_CAPTURE_TIMEOUT_MS||decisionDeadline());
|
|
190
236
|
let before=null,after=null;
|
|
191
237
|
// Expensive state probes can take seconds. Keep the filesystem checkpoint
|
|
192
238
|
// adjacent to process launch so a late notification from earlier work
|
|
193
239
|
// does not enter the execution interval before the check even starts.
|
|
194
|
-
const
|
|
195
|
-
|
|
240
|
+
const callerCertificate=directRunCertificateAllowed(selected);
|
|
241
|
+
const probePlan={cwd:plan.cwd,probes:selected.probes||[],qualification:selected.qualification};
|
|
242
|
+
let probeBefore=callerCertificate?null:probeHash(probePlan);
|
|
196
243
|
const startSnapshotStarted=performance.now();
|
|
197
|
-
try{before=await call({action:'snapshot',name,contextHashes:contextHashes()
|
|
198
|
-
|
|
244
|
+
try{before=await call({action:'snapshot',name,contextHashes:contextHashes(),
|
|
245
|
+
...(callerCertificate?{runCertificate:1}:{})},captureTimeout);}
|
|
246
|
+
catch(error){captureIssues.push('start observation unavailable'+
|
|
247
|
+
(typeof error.code==='string'&&/^[A-Z0-9_]+$/.test(error.code)?` (${error.code})`:''));}
|
|
199
248
|
timing('cli.start_checkpoint',startSnapshotStarted,{available:before?.snapshots?.[name]?1:0});
|
|
249
|
+
// Missing discovery metadata can recover through a full probe. A missing
|
|
250
|
+
// healthy start boundary cannot: freeze this decision before any recovery.
|
|
251
|
+
// Only the reviewed direct recipe on macOS and Linux enters this shortcut.
|
|
252
|
+
const unobservedDirectRun=callerCertificate&&
|
|
253
|
+
before?.snapshots?.[name]?.observationHealthy!==true;
|
|
254
|
+
if(unobservedDirectRun){
|
|
255
|
+
captureIssues.push('qualification probes and end checkpoint not performed: no healthy start checkpoint');
|
|
256
|
+
mark('cli.run_validation_skipped',{reason:'start_observation_unavailable',probes:2,endCheckpoint:1});
|
|
257
|
+
}
|
|
258
|
+
await testRunBoundary('after-start-checkpoint');
|
|
259
|
+
// Start the observed interval before caller validation, not after it. Any
|
|
260
|
+
// intervening edit (even restored before launch) remains disqualifying.
|
|
261
|
+
// With a certificate, the compile records its own answers instead of two probes.
|
|
262
|
+
// Without one after an edit, the recording can become the certificate at the
|
|
263
|
+
// end checkpoint; that checkpoint probes in full if it cannot.
|
|
264
|
+
let certificate=null,recording=null;
|
|
265
|
+
if(callerCertificate&&!unobservedDirectRun){
|
|
266
|
+
certificate=directRunCertificate(plan,selected,before?.snapshots?.[name],root);
|
|
267
|
+
if(!certificate)recording=directRunRecording(plan,selected,before?.snapshots?.[name],root);
|
|
268
|
+
probeBefore=certificate?.value??(recording?null:probeHash(probePlan));
|
|
269
|
+
}
|
|
270
|
+
// The record arrives on fd 3, so the compiler's environment is unchanged.
|
|
271
|
+
// Every direct run is recorded: even one checked by full probes needs its
|
|
272
|
+
// lookups outside the certificate, which no probe makes.
|
|
273
|
+
const recorded=directRunRecordable(selected)&&!unobservedDirectRun;
|
|
274
|
+
const launched=recorded?[command[0],'--require',path.join(here,'run-recorder.cjs'),
|
|
275
|
+
...command.slice(1)]:command;
|
|
276
|
+
// A test run's processes record what the guard checks (test-recorder.cjs),
|
|
277
|
+
// and so do a general recipe run's, through the package manager.
|
|
278
|
+
const general=selected.qualification===scriptQualification;
|
|
279
|
+
const testRun=recordedRecipes.includes(selected.qualification)&&['darwin','linux'].includes(process.platform);
|
|
280
|
+
let testRecord=null,launchEnv=process.env,testFacts={},toolchain=[];
|
|
281
|
+
if(testRun){
|
|
282
|
+
// The package directory and installed node_modules, as the plan has them.
|
|
283
|
+
try{testFacts=general?scriptRunFacts(root,selected):testRunFacts(root,selected);}
|
|
284
|
+
catch(e){captureIssues.push(`${general?'script':'test'} run facts unavailable (${e.code||e.message})`);}
|
|
285
|
+
const environmentIssue=testEnvironmentIssue();
|
|
286
|
+
if(environmentIssue)captureIssues.push(environmentIssue);
|
|
287
|
+
testRecord=path.join(state,`test-record-${runId}`);
|
|
288
|
+
fs.mkdirSync(testRecord,{mode:0o700});
|
|
289
|
+
const guardFile=path.join(testRecord,'guard.json'),guardConfig=testGuardConfig(testRecord,root,process.env,testFacts);
|
|
290
|
+
if(general){toolchain=toolchainDirs();
|
|
291
|
+
// The Node on PATH, which the environment identifies by its file.
|
|
292
|
+
Object.assign(guardConfig,{installed:scriptInstalledPaths(root,testFacts.dirs||[]),toolchain,
|
|
293
|
+
node:lookup('node',process.env.PATH,process.cwd())});}
|
|
294
|
+
fs.writeFileSync(guardFile,JSON.stringify(guardConfig),{mode:0o600});
|
|
295
|
+
launchEnv=recordedEnvironment(guardFile);
|
|
296
|
+
// npm's and pnpm's update checks read state outside the checkout and the network.
|
|
297
|
+
if(general)launchEnv.npm_config_update_notifier='false';
|
|
298
|
+
}
|
|
299
|
+
let runnerPid=null;
|
|
300
|
+
const record=[];
|
|
200
301
|
// A Windows full reconciliation may finish while queued watcher callbacks
|
|
201
302
|
// are still arriving. Fold callbacks delivered before process launch into
|
|
202
303
|
// the starting checkpoint; callbacks after launch still disqualify reuse.
|
|
@@ -211,17 +312,20 @@ async function execute() {
|
|
|
211
312
|
}
|
|
212
313
|
const snap=before?.snapshots?.[name];
|
|
213
314
|
if(!snap||!snap.observationHealthy)captureIssues.push('start checkpoint unavailable');
|
|
214
|
-
|
|
315
|
+
await testRunBoundary('before-launch');
|
|
316
|
+
const started=Date.now(),executionStarted=performance.now();
|
|
215
317
|
const invocation=await new Promise(resolve=>{
|
|
216
318
|
if(requestedSignal){resolve({status:'interrupted',exitCode:null,signal:requestedSignal});return;}
|
|
217
319
|
let child,spawnError=null;
|
|
218
320
|
try{runLock.phase('launching');
|
|
219
|
-
const launch=windowsLaunch(
|
|
321
|
+
const launch=windowsLaunch(launched);
|
|
220
322
|
child=spawn(launch.file,launch.args,{cwd:plan.cwd,
|
|
221
|
-
stdio:'inherit',
|
|
222
|
-
|
|
323
|
+
stdio:recorded?['inherit','inherit','inherit','pipe']:'inherit',env:launchEnv,
|
|
324
|
+
detached:process.platform==='linux',...launch.options});
|
|
325
|
+
child.stdio[3]?.on('data',chunk=>record.push(chunk));}
|
|
223
326
|
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);
|
|
327
|
+
activeChild=child;runnerPid=child.pid??null;runLock.phase('running',child.pid??null);
|
|
328
|
+
mark('cli.child_started',{childPid:child.pid});
|
|
225
329
|
if(requestedSignal)forward(requestedSignal);
|
|
226
330
|
child.on('error',e=>{spawnError=e.code||e.name;});
|
|
227
331
|
child.on('close',async(exit,signal)=>{
|
|
@@ -241,6 +345,8 @@ async function execute() {
|
|
|
241
345
|
});
|
|
242
346
|
});
|
|
243
347
|
const ended=Date.now();
|
|
348
|
+
timing('cli.compiler_execution',executionStarted,{status:invocation.status,exit:invocation.exitCode});
|
|
349
|
+
await testRunBoundary('after-exit');
|
|
244
350
|
invocation.startedAt=started;invocation.durationMs=ended-started;
|
|
245
351
|
invocation.argvHash=plan.commandIdentity.argvHash;
|
|
246
352
|
invocation.argc=command.length;
|
|
@@ -248,28 +354,121 @@ async function execute() {
|
|
|
248
354
|
invocation.reporting='normal';
|
|
249
355
|
const result=invocation.status==='exited'?(invocation.exitCode===0?'PASS':'FAIL'):null;
|
|
250
356
|
const target={source:'direct-command',status:invocation.status==='exited'?'direct_executed':'unknown'};
|
|
251
|
-
const environmentAfter=contextHashes()[name]
|
|
252
|
-
|
|
357
|
+
const afterContextStarted=performance.now(),environmentAfter=contextHashes()[name];
|
|
358
|
+
timing('cli.context_after',afterContextStarted);
|
|
359
|
+
let probeAfter=null;
|
|
360
|
+
const recordText=Buffer.concat(record).toString('utf8');
|
|
361
|
+
// Lookups the certificate does not cover, kept with the receipt.
|
|
362
|
+
let runFacts=[];
|
|
363
|
+
if(certificate){
|
|
364
|
+
const issue=validateRunRecord(recordText,certificate,runFacts);
|
|
365
|
+
if(issue)captureIssues.push(`compiler inputs differ from discovery certificate (${issue})`);
|
|
366
|
+
probeAfter=certificate.value;
|
|
367
|
+
}else if(!unobservedDirectRun&&!recording)probeAfter=probeHash(probePlan);
|
|
368
|
+
let guard=null;
|
|
369
|
+
if(testRecord){
|
|
370
|
+
let coverage=null;if(!general)try{coverage=coverageReadable(root,testFacts);}catch{}
|
|
371
|
+
const programs=general?{runner:{file:command[0],args:command.slice(1),cwd:plan.cwd,searchPath:process.env.PATH,
|
|
372
|
+
env:{preload:true,bashEnv:Boolean(process.env.BASH_ENV)}},
|
|
373
|
+
judge:(child,main)=>childProgram(child,{toolchain,parentMain:main}),trusted:scriptCaches}:null;
|
|
374
|
+
guard=testRunGuard(testRecord,{root,sourcePatterns:plan.sourcePatterns||plan.patterns,files:snap?.files,
|
|
375
|
+
runner:runnerPid,environment:selected.environment||{},coverage,programs});
|
|
376
|
+
runFacts=guard.runFacts;
|
|
377
|
+
// The pnpm executable reads its configuration natively: kept by content.
|
|
378
|
+
if(guard.opaque)for(const file of pnpmConfigFiles())try{runFacts.push(['readFile',file,queryAnswer('readFile',file)]);}
|
|
379
|
+
catch{guard.issues.push(`pnpm configuration ${file} cannot be read`);}
|
|
380
|
+
const shared=sharedInstalledFile(root,testFacts,path.join(state,'pnpm-store-links-v1.json'));
|
|
381
|
+
if(shared)guard.issues.push(`installed file ${shared.file} ${shared.reason}; a change through it is not observed`);
|
|
382
|
+
fs.rmSync(testRecord,{recursive:true,force:true});
|
|
383
|
+
}
|
|
384
|
+
// The daemon reads the recording from owned state; control requests are small.
|
|
385
|
+
const recordFile=recording&&path.join(state,`run-record-${recording}.json`);
|
|
386
|
+
if(recordFile)try{fs.writeFileSync(recordFile,recordText,{mode:0o600});}catch{}
|
|
253
387
|
const endSnapshotStarted=performance.now();
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
388
|
+
if(!unobservedDirectRun){
|
|
389
|
+
try{after=await call({action:'snapshot',name,contextHashes:contextHashes(),
|
|
390
|
+
...(testRun&&Number.isSafeInteger(snap?.inputEventSerial)?{sinceSerial:snap.inputEventSerial}:{}),
|
|
391
|
+
...(recording?{runCertificate:1,adopt:recording}:recorded&&!certificate?{runCertificate:1,runFacts:1}:{})},
|
|
392
|
+
// The start checkpoint's probe may have raised the deadline.
|
|
393
|
+
Number(process.env.VSTATE_CAPTURE_TIMEOUT_MS||decisionDeadline()));}
|
|
394
|
+
catch{captureIssues.push('end observation unavailable');}
|
|
395
|
+
finally{if(recordFile)fs.rmSync(recordFile,{force:true});}
|
|
396
|
+
timing('cli.end_checkpoint',endSnapshotStarted,{available:after?.snapshots?.[name]?1:0});
|
|
397
|
+
}
|
|
257
398
|
const end=after?.snapshots?.[name];
|
|
258
|
-
|
|
399
|
+
// A test run's own outputs: git-ignored files it created without reading
|
|
400
|
+
// what was there (test/tmp). When they are the only observed change, the
|
|
401
|
+
// run is kept with the end checkpoint, which includes them.
|
|
402
|
+
let outputs=null;
|
|
403
|
+
let eventsExplained=false;
|
|
404
|
+
if(guard&&snap&&end&&snap.planId===end.planId&&snap.probeHash===end.probeHash&&snap.observationHealthy&&end.observationHealthy&&
|
|
405
|
+
(snap.inputEventSerial===end.inputEventSerial||end.inputEventPathsComplete===true)){
|
|
406
|
+
// A directory event is not an input change; a change to its files shows
|
|
407
|
+
// in the end checkpoint's file map, which is compared too. A link is kept.
|
|
408
|
+
const directory=rel=>{try{return fs.lstatSync(path.join(root,rel)).isDirectory();}catch{return false;}};
|
|
409
|
+
const changed=changedFiles(snap.files,end.files),events=(end.inputEventPaths||[]).filter(rel=>!directory(rel));
|
|
410
|
+
const touched=[...new Set([...changed,...events])].sort();
|
|
411
|
+
const output=rel=>guard.isOutput(rel)||general&&toolOutput(rel,guard);
|
|
412
|
+
const ignored=touched.length&&touched.every(output)?gitIgnored(root,touched):new Set();
|
|
413
|
+
if(touched.every(file=>ignored.has(file))){eventsExplained=true;if(changed.length)outputs=changed;}
|
|
414
|
+
}
|
|
415
|
+
// The end certificate, recorded or probed, must cover what this compile received.
|
|
416
|
+
// With no input event, gap or content change in between, it also holds at the start.
|
|
417
|
+
// A run checked by full probes is compared with it too, for its run facts.
|
|
418
|
+
if(recorded&&!certificate){
|
|
419
|
+
const endCertificate=directRunCertificate(plan,selected,end,root),
|
|
420
|
+
questions=endCertificate||(!recording&&probeQuestions(plan,selected,end,root));
|
|
421
|
+
if(!questions)captureIssues.push('no discovery certificate at the end checkpoint');
|
|
422
|
+
else{
|
|
423
|
+
const issue=validateRunRecord(recordText,questions,runFacts=[]);
|
|
424
|
+
if(issue)captureIssues.push(`compiler inputs differ from discovery certificate (${issue})`);
|
|
425
|
+
if(recording)probeBefore=probeAfter=endCertificate.value;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
// The receipt holds only run facts that still answer as they did during the run.
|
|
429
|
+
runFacts.sort();
|
|
430
|
+
if(runFacts.length>10000)captureIssues.push('too many lookups outside the discovery certificate');
|
|
431
|
+
else{const changed=runFactChange(runFacts);
|
|
432
|
+
if(changed)captureIssues.push(`a lookup outside the discovery certificate changed during execution: ${changed}`);}
|
|
433
|
+
if(recording&&snap?.observationGaps!==end?.observationGaps)
|
|
434
|
+
captureIssues.push('observation gap during execution');
|
|
435
|
+
if(!unobservedDirectRun&&(!end||!end.observationHealthy))captureIssues.push('end checkpoint unavailable');
|
|
259
436
|
if(JSON.stringify(environmentBefore)!==JSON.stringify(environmentAfter))
|
|
260
437
|
captureIssues.push('declared environment changed during execution');
|
|
261
|
-
if(!probeBefore||!probeAfter||probeBefore!==snap?.probeHash||
|
|
438
|
+
if(!unobservedDirectRun&&(!probeBefore||!probeAfter||probeBefore!==(recording?end:snap)?.probeHash||
|
|
439
|
+
probeAfter!==end?.probeHash))
|
|
262
440
|
captureIssues.push('declared state probe unavailable or changed');
|
|
263
441
|
if(before?.observation?.generation!==after?.observation?.generation)
|
|
264
442
|
captureIssues.push('observer generation changed during execution');
|
|
265
443
|
if(snap?.planGeneration!==end?.planGeneration)
|
|
266
444
|
captureIssues.push('input plan rebuilt during execution');
|
|
267
|
-
if(snap?.inputEventSerial!==end?.inputEventSerial)
|
|
445
|
+
if(snap?.inputEventSerial!==end?.inputEventSerial&&!eventsExplained)
|
|
268
446
|
captureIssues.push('declared input event occurred during execution');
|
|
269
|
-
|
|
270
|
-
|
|
447
|
+
// A recorded certificate changes only the probe part of the fingerprint.
|
|
448
|
+
if(!outputs&&(!snap||!end||snap.planId!==end.planId||(recording?snap.inputKey!==end.inputKey:snap.fingerprint!==end.fingerprint)||
|
|
449
|
+
snap.revision!==end.revision))captureIssues.push('declared inputs changed or were not observed');
|
|
271
450
|
const stable=captureIssues.length===0;
|
|
272
|
-
|
|
451
|
+
// Two runs on the same inputs, environment and plan that disagree make the
|
|
452
|
+
// check nondeterministic until its inputs change.
|
|
453
|
+
let nondeterministic=false;
|
|
454
|
+
const declared=hashes=>Object.entries(hashes||{}).filter(([key])=>!key.startsWith('__redue_env_'));
|
|
455
|
+
if(testRun&&stable&&result){
|
|
456
|
+
const prior=readReceipts(state).checks[name];
|
|
457
|
+
// A difference explained by inputs REDUE did not observe proves nothing.
|
|
458
|
+
nondeterministic=Boolean(!guard?.issues.length&&prior?.stable&&!prior.guardIssues?.length&&prior.result&&prior.planId===(snap?.planId??plan.id)&&
|
|
459
|
+
prior.fingerprint===((recording||outputs?end:snap)?.fingerprint??null)&&
|
|
460
|
+
JSON.stringify(declared(prior.environmentHashes))===JSON.stringify(declared(environmentBefore))&&
|
|
461
|
+
JSON.stringify(prior.runFacts||[])===JSON.stringify([...runFacts].sort())&&
|
|
462
|
+
JSON.stringify(prior.environmentReads||{})===JSON.stringify(guard?.environmentReads||{})&&
|
|
463
|
+
(prior.result!==result||prior.nondeterministic===true));
|
|
464
|
+
}
|
|
465
|
+
// A recorded run's receipt carries its run facts, even none; observers that
|
|
466
|
+
// predate run facts do not reuse version 2.
|
|
467
|
+
// The recipe that made the receipt, which status and the hook name.
|
|
468
|
+
const receipt={result,invocation,target,stable,fingerprintVersion:recorded||testRun?2:1,recipe:selected.qualification||null,
|
|
469
|
+
...(recorded||testRun?{runFacts}:{}),
|
|
470
|
+
...(testRun?{verificationRecipe:general?scriptInterpretation:testsInterpretation,guardIssues:guard?.issues||['the run record is unavailable'],
|
|
471
|
+
environmentReads:guard?.environmentReads||{},...(nondeterministic?{nondeterministic}:{})}:{}),
|
|
273
472
|
resolutionLinks:plan.resolutionLinks||[],
|
|
274
473
|
coverage:{qualified:plan.unresolved.length===0,unresolved:plan.unresolved},
|
|
275
474
|
checkpoint:{status:stable?'stable':'unavailable_or_changed',issues:captureIssues,
|
|
@@ -277,14 +476,18 @@ async function execute() {
|
|
|
277
476
|
...(process.env.REDUE_PUBLIC_INPUT_TRACE==='1'?{
|
|
278
477
|
startInputEventSerial:snap?.inputEventSerial??null,
|
|
279
478
|
endInputEventSerial:end?.inputEventSerial??null}: {})},
|
|
280
|
-
planId:snap?.planId??plan.id,fingerprint:snap?.fingerprint??null,
|
|
479
|
+
planId:snap?.planId??plan.id,fingerprint:(recording||outputs?end:snap)?.fingerprint??null,
|
|
281
480
|
provider:plan.provider,environmentHashes:environmentBefore,
|
|
282
|
-
|
|
481
|
+
...(observedContext?{observedContext,verificationRecipe:directInterpretation}:{}),
|
|
482
|
+
files:(outputs?end:snap)?.files??null,observerGeneration:before?.observation?.generation??null,
|
|
283
483
|
startRevision:snap?.revision??null,endRevision:end?.revision??null};
|
|
284
484
|
const persistenceStarted=performance.now();
|
|
285
485
|
commitReceipt(state,name,receipt);
|
|
486
|
+
if(testRun){const reads=environmentReads();reads[name]=receipt.environmentReads;atomicJson(readsFile,{schema:1,checks:reads});}
|
|
286
487
|
timing('cli.receipt_persistence',persistenceStarted);
|
|
287
|
-
|
|
488
|
+
const reloadStarted=performance.now();
|
|
489
|
+
try{await call({action:'reload'});}catch{/* receipt revisions invalidate older status even when reload IPC is unavailable */}
|
|
490
|
+
timing('cli.receipt_reload',reloadStarted);
|
|
288
491
|
const summary={check:name,result,target:target.status,
|
|
289
492
|
invocation:invocation.status,...(invocation.errorCode?{error_code:invocation.errorCode}:{}),
|
|
290
493
|
stable,coverage_qualified:receipt.coverage.qualified};
|
|
@@ -375,8 +578,28 @@ async function main(){
|
|
|
375
578
|
if(action==='status'){console.log(JSON.stringify(cached()));return;}
|
|
376
579
|
if(action==='sync'||action==='detail'){
|
|
377
580
|
const started=performance.now();
|
|
581
|
+
if(profiling&&config.checks.some(check=>check.qualification===directQualification)){
|
|
582
|
+
const proxyShapes=Object.entries(process.env).filter(([key])=>/^npm_config_.*proxy$/i.test(key)).map(([key,value])=>{
|
|
583
|
+
try{const url=new URL(value),portDigest=digest(url.port);url.port='';
|
|
584
|
+
return {key,kind:'url',portDigest,withoutPortDigest:digest(url.href)};}
|
|
585
|
+
catch{return {key,kind:value===''?'empty':'non-url',valueDigest:digest(value)};}
|
|
586
|
+
});
|
|
587
|
+
timing('cli.direct_context',started,{interpretation:directInterpretation,
|
|
588
|
+
observedNpmDigest:observedNpmContext(),proxyShapes});
|
|
589
|
+
}
|
|
378
590
|
let value;try{value=await decisionCall({action:'sync',contextHashes:contextHashes()},
|
|
379
|
-
Number(process.env.VSTATE_SYNC_TIMEOUT_MS||
|
|
591
|
+
Number(process.env.VSTATE_SYNC_TIMEOUT_MS||decisionDeadline()));
|
|
592
|
+
// A client upgrade can encounter an older running observer. Its successful
|
|
593
|
+
// control response does not establish that it evaluated the latest receipt.
|
|
594
|
+
if(value.receipt_revision===null){
|
|
595
|
+
value=unavailableCached(state,'latest receipt selection unavailable; inspect receipt state before reuse',config.checks);
|
|
596
|
+
}else if(typeof value.receipt_revision!=='string'){
|
|
597
|
+
value=unavailableCached(state,'observer cannot confirm latest receipt selection; stop and start the observer after upgrading',config.checks);
|
|
598
|
+
process.exitCode=2;
|
|
599
|
+
}else if(value.receipt_revision!==receiptRevision(state)){
|
|
600
|
+
value=unavailableCached(state,'receipt selection changed during synchronization; retry synchronized status',config.checks);
|
|
601
|
+
process.exitCode=2;
|
|
602
|
+
}}
|
|
380
603
|
catch(e){value=unavailableCached(state,e.message,config.checks);process.exitCode=2;}
|
|
381
604
|
timing('cli.synchronized_status',started,{available:value.observation?.healthy?1:0});
|
|
382
605
|
if(action==='sync')console.log(JSON.stringify(value));
|