@redue/cli 0.0.0-stage → 0.1.0-alpha.10
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 +128 -0
- package/LICENSE +180 -0
- package/README.md +162 -2
- package/bin/redue.mjs +355 -0
- package/bin/vstate.mjs +3 -0
- package/docs/README.md +21 -0
- package/docs/user/agents.md +207 -0
- package/docs/user/configuration.md +60 -0
- package/docs/user/direct-typescript.md +96 -0
- package/docs/user/getting-started.md +148 -0
- package/docs/user/json.md +59 -0
- package/docs/user/npm-launcher-notice.md +42 -0
- package/docs/user/pilot.md +142 -0
- package/docs/user/receipt-selection-notice.md +60 -0
- package/docs/user/shell.md +25 -0
- package/docs/user/supported-projects.md +90 -0
- package/examples/redue.config.json +8 -0
- package/package.json +47 -4
- package/src/agent-integration.mjs +248 -0
- package/src/agent-policy.md +70 -0
- package/src/cached-state.mjs +42 -0
- package/src/cli.mjs +459 -0
- package/src/control-endpoint.mjs +11 -0
- package/src/daemon.mjs +1082 -0
- package/src/decision-profile.mjs +32 -0
- package/src/decision-validation.mjs +41 -0
- package/src/declared-plan.mjs +125 -0
- package/src/direct-run-certificate.mjs +40 -0
- package/src/direct-typescript-probe.mjs +56 -0
- package/src/direct-typescript.mjs +76 -0
- package/src/executable-lookup.mjs +18 -0
- package/src/glob.mjs +9 -0
- package/src/history-worker.mjs +23 -0
- package/src/index.mjs +306 -0
- package/src/installed-inputs.mjs +110 -0
- package/src/linux-inotify.py +228 -0
- package/src/linux-observer.mjs +79 -0
- package/src/node-onboarding.mjs +274 -0
- package/src/npm-typecheck-environment.mjs +36 -0
- package/src/npm-typecheck-probe.mjs +120 -0
- package/src/observation.mjs +39 -0
- package/src/owned-state.mjs +32 -0
- package/src/path-identity.mjs +43 -0
- package/src/plan-worker.mjs +140 -0
- package/src/plan.mjs +10 -0
- package/src/platform-observation.mjs +17 -0
- package/src/platform-state.mjs +14 -0
- package/src/pnpm-inputs.mjs +355 -0
- package/src/pnpm-typecheck-probe.mjs +47 -0
- package/src/presentation.mjs +53 -0
- package/src/probe-result.mjs +21 -0
- package/src/process-liveness.mjs +11 -0
- package/src/project-location.mjs +34 -0
- package/src/run-lock.mjs +77 -0
- package/src/state-store.mjs +73 -0
- package/src/toolchain-probe.mjs +21 -0
- package/src/typescript-contract-probe.mjs +5 -0
- package/src/typescript-input-facts.mjs +65 -0
- package/src/typescript-list.mjs +72 -0
- package/src/typescript-qualification.mjs +77 -0
- package/src/windows-command.mjs +16 -0
- package/src/windows-process.mjs +15 -0
- package/src/yarn-workspace-inputs.mjs +148 -0
- package/src/yarn-workspace-typecheck-probe.mjs +107 -0
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import {directQualification,directInterpretation,observedNpmContext} from './direct-typescript.mjs';
|
|
4
|
+
import {directRunCertificateAllowed,validateDirectRunCertificate} from './direct-run-certificate.mjs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import net from 'node:net';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import {spawn,spawnSync} from 'node:child_process';
|
|
9
|
+
import {fileURLToPath} from 'node:url';
|
|
10
|
+
import {processAlive} from './process-liveness.mjs';
|
|
11
|
+
import {controlEndpoint} from './control-endpoint.mjs';
|
|
12
|
+
import {samePath,withinPath,realObservedPath} from './path-identity.mjs';
|
|
13
|
+
import {findExecutable} from './executable-lookup.mjs';
|
|
14
|
+
import {windowsLaunch} from './windows-command.mjs';
|
|
15
|
+
import {terminateWindowsTree} from './windows-process.mjs';
|
|
16
|
+
import {createHash,randomUUID} from 'node:crypto';
|
|
17
|
+
import {assertOwnedStatePlacement} from './owned-state.mjs';
|
|
18
|
+
import {permittedRoots} from './installed-inputs.mjs';
|
|
19
|
+
import {acquireRunLock,inspectRunLock,recoverRunLock} from './run-lock.mjs';
|
|
20
|
+
import {atomicJson,commitReceipt,receiptRevision} from './state-store.mjs';
|
|
21
|
+
import {timing,profiling,mark,traceContext} from './decision-profile.mjs';
|
|
22
|
+
import {readCachedStatus,unavailableCached} from './cached-state.mjs';
|
|
23
|
+
import {renderRun} from './presentation.mjs';
|
|
24
|
+
|
|
25
|
+
mark('cli.loaded');
|
|
26
|
+
const here=path.dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const [configArg,action,name]=process.argv.slice(2);
|
|
28
|
+
if(!configArg||!action)throw Error('internal usage: cli.mjs CONFIG ACTION [check]');
|
|
29
|
+
const configStarted=performance.now();
|
|
30
|
+
const configFile=path.resolve(configArg), config=JSON.parse(fs.readFileSync(configFile));
|
|
31
|
+
const maintenance=['stop','uninstall'].includes(action);
|
|
32
|
+
const root=maintenance?path.resolve(config.root):realObservedPath(config.root), state=path.resolve(config.state),
|
|
33
|
+
endpoint=controlEndpoint(state),socket=endpoint.address;
|
|
34
|
+
assertOwnedStatePlacement(config);
|
|
35
|
+
if(withinPath(state,root))throw Error('state must be outside checkout');
|
|
36
|
+
if([path.parse(state).root,os.homedir(),path.dirname(os.homedir())]
|
|
37
|
+
.some(value=>samePath(value,state))||
|
|
38
|
+
!/^(?:vstate|redue)-/.test(path.basename(state)))
|
|
39
|
+
throw Error('state directory name must start with redue- (or legacy vstate-); use a dedicated directory outside the checkout');
|
|
40
|
+
const ownerFile=path.join(state,'vstate-owner-v1.json');
|
|
41
|
+
timing('cli.config_resolution',configStarted);
|
|
42
|
+
// A native Windows decision reconciles declared contents instead of trusting
|
|
43
|
+
// watcher history. The measured 6k-file pnpm workspace can legitimately need
|
|
44
|
+
// more than the former 15 s default; uncertainty still wins on expiry.
|
|
45
|
+
const decisionDeadlineMs=process.platform==='win32'?30000:15000;
|
|
46
|
+
const digest=value=>createHash('sha256').update(value).digest('hex');
|
|
47
|
+
function nodeIdentity() {const real=fs.realpathSync(process.execPath),st=fs.statSync(real,{bigint:true});
|
|
48
|
+
return digest(JSON.stringify([real,process.version,...['dev','ino','mode','size','mtimeNs','ctimeNs']
|
|
49
|
+
.map(key=>String(st[key]))]));}
|
|
50
|
+
function pathExecutableIdentity(name) {
|
|
51
|
+
try{const real=findExecutable(name),st=real&&fs.statSync(real,{bigint:true});
|
|
52
|
+
if(st?.isFile())return digest(JSON.stringify([real,...['dev','ino','mode','size','mtimeNs','ctimeNs']
|
|
53
|
+
.map(key=>String(st[key]))]));}
|
|
54
|
+
catch{}
|
|
55
|
+
return digest('<unavailable>');
|
|
56
|
+
}
|
|
57
|
+
function contextHashes() {return Object.fromEntries(config.checks.map(check=>[
|
|
58
|
+
check.name,Object.fromEntries([...(check.environment?.variables||[])
|
|
59
|
+
.map(key=>[key,digest(process.env[key]??'<unset>')]),
|
|
60
|
+
...(check.environment?.prefixes||[]).map(prefix=>[
|
|
61
|
+
`__vstate_env_prefix_${prefix.toLowerCase()}__`,digest(JSON.stringify(
|
|
62
|
+
Object.entries(process.env).filter(([key])=>
|
|
63
|
+
key.toLowerCase().startsWith(prefix.toLowerCase()))
|
|
64
|
+
.sort(([a],[b])=>a.localeCompare(b))))]),
|
|
65
|
+
...(check.environment?.pathExecutables||[]).map(name=>[
|
|
66
|
+
`__vstate_path_executable_${name}__`,pathExecutableIdentity(name)]),
|
|
67
|
+
...(check.environment?.executableIdentity?[['__vstate_node_identity__',nodeIdentity()]]:[])])]));}
|
|
68
|
+
function probeHash(plan) {
|
|
69
|
+
const started=performance.now();
|
|
70
|
+
const values=[];
|
|
71
|
+
for(const argv of plan.probes||[]) {
|
|
72
|
+
const timeout=plan.qualification==='pnpm-tsc-v1'||
|
|
73
|
+
(process.platform==='win32'&&['typescript-noemit-v1','npm-typescript-direct-v1','yarn-workspace-tsc-v1']
|
|
74
|
+
.includes(plan.qualification))?10000:5000;
|
|
75
|
+
const out=spawnSync(argv[0],argv.slice(1),{cwd:plan.cwd,
|
|
76
|
+
timeout,maxBuffer:1024*1024,stdio:['ignore','pipe','pipe'],env:process.env});
|
|
77
|
+
if(out.error||out.signal||out.status!==0){
|
|
78
|
+
timing('cli.probe',started,{outcome:out.error?.code||out.signal||out.status||'error',
|
|
79
|
+
commands:values.length+1});return null;}
|
|
80
|
+
values.push([argv,out.status,digest(out.stdout),digest(out.stderr)]);
|
|
81
|
+
}
|
|
82
|
+
timing('cli.probe',started,{outcome:'ok',commands:values.length});
|
|
83
|
+
return digest(JSON.stringify(values));
|
|
84
|
+
}
|
|
85
|
+
function owner(){
|
|
86
|
+
fs.mkdirSync(state,{recursive:true,mode:0o700});
|
|
87
|
+
const expected={schema:1,kind:'vstate-product-alpha',config:configFile,root,state};
|
|
88
|
+
if(fs.existsSync(ownerFile)){
|
|
89
|
+
const existing=JSON.parse(fs.readFileSync(ownerFile));
|
|
90
|
+
if(JSON.stringify(existing)!==JSON.stringify(expected))throw Error('state ownership marker mismatch');
|
|
91
|
+
} else {
|
|
92
|
+
const allowed=withinPath(configFile,state)?path.basename(configFile):null;
|
|
93
|
+
if(fs.readdirSync(state).some(entry=>entry!==allowed))
|
|
94
|
+
throw Error('unowned state directory is not empty; inspect before initializing');
|
|
95
|
+
atomicJson(ownerFile,expected);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function call(message,timeout=5000) {
|
|
99
|
+
return new Promise((resolve,reject)=>{
|
|
100
|
+
let data='',done=false;mark('client.control_connect',{deadlineMs:timeout});const client=net.createConnection(socket);
|
|
101
|
+
const timer=setTimeout(()=>{mark('client.control_timeout');client.destroy();reject(Error('observer synchronization timeout'));},timeout);
|
|
102
|
+
client.on('connect',()=>{mark('client.control_submit');client.write(JSON.stringify({...message,...(profiling?{diagnostic:traceContext()}: {})})+'\n');});
|
|
103
|
+
client.on('data',chunk=>data+=chunk);
|
|
104
|
+
client.on('end',()=>{mark('client.control_complete',{bytes:data.length});clearTimeout(timer);if(done)return;done=true;
|
|
105
|
+
try{resolve(JSON.parse(data));}catch(e){reject(e);}});
|
|
106
|
+
client.on('error',e=>{mark('client.control_error',{code:e.code});clearTimeout(timer);reject(e);});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
async function decisionCall(message,timeout) {
|
|
110
|
+
const started=Date.now(),remaining=()=>Math.max(1,timeout-(Date.now()-started));
|
|
111
|
+
try{return await call(message,remaining());}
|
|
112
|
+
catch(error){
|
|
113
|
+
if(process.platform!=='win32'||!['ENOENT','ECONNREFUSED','ECONNRESET'].includes(error.code))throw error;
|
|
114
|
+
const pidFile=path.join(state,'observer.lock','pid');
|
|
115
|
+
let pid=null;try{pid=Number(fs.readFileSync(pidFile));}catch{}
|
|
116
|
+
const alive=Number.isSafeInteger(pid)&&pid>0?processAlive(pid):null;
|
|
117
|
+
const diagnostic={at:Date.now(),kind:'control_connection_unavailable',code:error.code,
|
|
118
|
+
pid,alive,remainingMs:remaining(),cached:cached().observation};
|
|
119
|
+
try{fs.appendFileSync(path.join(state,'events.jsonl'),JSON.stringify(diagnostic)+'\n');}catch{}
|
|
120
|
+
if(alive){
|
|
121
|
+
// Exactly one reconnect. The sync operation still validates contents;
|
|
122
|
+
// connection recovery by itself is never evidence of applicability.
|
|
123
|
+
await new Promise(resolve=>setTimeout(resolve,100));
|
|
124
|
+
return call(message,remaining());
|
|
125
|
+
}
|
|
126
|
+
if(alive!==false||!fs.existsSync(ownerFile)||inspectRunLock(state).state!=='absent')throw error;
|
|
127
|
+
owner();
|
|
128
|
+
const recovery=path.join(state,'control-recovery.json');
|
|
129
|
+
try{fs.writeFileSync(recovery,JSON.stringify({pid,at:Date.now()}),{flag:'wx',mode:0o600});}
|
|
130
|
+
catch{throw Error('automatic control recovery already attempted; inspect observer.log and run start');}
|
|
131
|
+
const child=spawn(process.execPath,[fileURLToPath(import.meta.url),configFile,'start'],{
|
|
132
|
+
cwd:os.tmpdir(),env:{...process.env,VSTATE_START_READY_WAIT_MS:String(Math.min(remaining(),10000))},
|
|
133
|
+
stdio:'ignore'});
|
|
134
|
+
await new Promise((resolve,reject)=>{
|
|
135
|
+
const timer=setTimeout(()=>{child.kill();reject(Error('control recovery start timeout'));},remaining());
|
|
136
|
+
child.on('error',e=>{clearTimeout(timer);reject(e);});
|
|
137
|
+
child.on('exit',code=>{clearTimeout(timer);code===0?resolve():reject(Error('control recovery start failed'));});
|
|
138
|
+
});
|
|
139
|
+
let value=await call(message,remaining());
|
|
140
|
+
// Initialization may be honest UNVERIFIED. Never wait/retry an arbitrary
|
|
141
|
+
// failure; only allow the normal started observer to finish its one index.
|
|
142
|
+
while(!value.observation?.healthy&&
|
|
143
|
+
['discovery','indexing','reconciling','validating'].includes(value.observation?.phase)&&remaining()>100){
|
|
144
|
+
await new Promise(resolve=>setTimeout(resolve,100));
|
|
145
|
+
value=await call(message,remaining());
|
|
146
|
+
}
|
|
147
|
+
if(value.observation?.healthy)fs.unlinkSync(recovery);
|
|
148
|
+
return value;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const cached=()=>readCachedStatus(state,config.checks);
|
|
152
|
+
async function testRunBoundary(phase) {
|
|
153
|
+
if(process.env.VSTATE_TEST_FAULTS!=='1'||process.env.VSTATE_TEST_RUN_BOUNDARY!==phase)return;
|
|
154
|
+
const release=path.join(state,'test-run-release');
|
|
155
|
+
fs.writeFileSync(path.join(state,'test-run-paused'),phase);
|
|
156
|
+
const until=Date.now()+5000;
|
|
157
|
+
while(!fs.existsSync(release)&&Date.now()<until)await new Promise(resolve=>setTimeout(resolve,10));
|
|
158
|
+
if(!fs.existsSync(release))throw Error('test run boundary was not released');
|
|
159
|
+
}
|
|
160
|
+
async function execute() {
|
|
161
|
+
const totalStarted=performance.now();
|
|
162
|
+
const selected=config.checks.find(c=>c.name===name);
|
|
163
|
+
if(!selected)throw Error(`unknown check ${name}`);
|
|
164
|
+
// Recording uses existing state only. Never initialize or repair ownership
|
|
165
|
+
// as a fallback for unavailable observation.
|
|
166
|
+
const expectedOwner={schema:1,kind:'vstate-product-alpha',config:configFile,root,state};
|
|
167
|
+
const existingOwner=JSON.parse(fs.readFileSync(ownerFile,'utf8'));
|
|
168
|
+
if(JSON.stringify(existingOwner)!==JSON.stringify(expectedOwner))
|
|
169
|
+
throw Error('state ownership marker mismatch');
|
|
170
|
+
const lockStarted=performance.now(),runLock=acquireRunLock(state);
|
|
171
|
+
timing('cli.run_lock',lockStarted);
|
|
172
|
+
const captureIssues=[];
|
|
173
|
+
let activeChild=null,requestedSignal=null,treeStop=null,preserveRunLock=false;
|
|
174
|
+
const forward=signal=>{requestedSignal=signal;
|
|
175
|
+
if(activeChild&&activeChild.exitCode===null){
|
|
176
|
+
if(process.platform==='linux'&&activeChild.pid){
|
|
177
|
+
try{process.kill(-activeChild.pid,signal);}
|
|
178
|
+
catch(e){if(e.code!=='ESRCH')throw e;}
|
|
179
|
+
}else if(process.platform==='win32'&&activeChild.pid){
|
|
180
|
+
treeStop??=terminateWindowsTree(activeChild.pid).catch(e=>{
|
|
181
|
+
captureIssues.push(`Windows check-tree cancellation unavailable: ${e.message}`);
|
|
182
|
+
preserveRunLock=true;
|
|
183
|
+
activeChild?.kill();
|
|
184
|
+
});
|
|
185
|
+
}else activeChild.kill(signal);
|
|
186
|
+
}};
|
|
187
|
+
const interrupt=()=>forward('SIGINT'),terminate=()=>forward('SIGTERM');
|
|
188
|
+
process.on('SIGINT',interrupt);process.on('SIGTERM',terminate);
|
|
189
|
+
const cancelMessage=message=>{if(message?.action==='cancel'&&
|
|
190
|
+
['SIGINT','SIGTERM'].includes(message.signal))forward(message.signal);};
|
|
191
|
+
process.on('message',cancelMessage);
|
|
192
|
+
try {
|
|
193
|
+
if(!fs.existsSync(path.join(state,'plans-v1.json')))
|
|
194
|
+
throw Error('observer input plan is not ready; wait for redue status to report ready');
|
|
195
|
+
const resolutionStarted=performance.now();
|
|
196
|
+
const plan=JSON.parse(fs.readFileSync(path.join(state,'plans-v1.json'))).plans[name];
|
|
197
|
+
if(!plan)throw Error('selected input plan unavailable');
|
|
198
|
+
if(plan.selectedConfigHash!==digest(JSON.stringify(selected)))
|
|
199
|
+
throw Error('selected check configuration changed; restart observer before recording');
|
|
200
|
+
const command=selected.command;
|
|
201
|
+
if(!Array.isArray(command)||!command.length||!command.every(x=>typeof x==='string'&&x))
|
|
202
|
+
throw Error('invalid execution argv');
|
|
203
|
+
if(digest(JSON.stringify(command))!==plan.commandIdentity.argvHash)
|
|
204
|
+
throw Error('execution argv differs from discovered plan');
|
|
205
|
+
timing('cli.command_resolution',resolutionStarted);
|
|
206
|
+
const runId=randomUUID();
|
|
207
|
+
const contextStarted=performance.now();
|
|
208
|
+
const environmentBefore=contextHashes()[name];
|
|
209
|
+
const observedContext=selected.qualification===directQualification?{interpretation:directInterpretation,npmProxyDigest:observedNpmContext()}:null;
|
|
210
|
+
timing('cli.context_before',contextStarted);
|
|
211
|
+
const
|
|
212
|
+
captureTimeout=Number(process.env.VSTATE_CAPTURE_TIMEOUT_MS||decisionDeadlineMs);
|
|
213
|
+
let before=null,after=null;
|
|
214
|
+
// Expensive state probes can take seconds. Keep the filesystem checkpoint
|
|
215
|
+
// adjacent to process launch so a late notification from earlier work
|
|
216
|
+
// does not enter the execution interval before the check even starts.
|
|
217
|
+
const callerCertificate=directRunCertificateAllowed(selected);
|
|
218
|
+
const probePlan={cwd:plan.cwd,probes:selected.probes||[],qualification:selected.qualification};
|
|
219
|
+
let probeBefore=callerCertificate?null:probeHash(probePlan);
|
|
220
|
+
const startSnapshotStarted=performance.now();
|
|
221
|
+
try{before=await call({action:'snapshot',name,contextHashes:contextHashes(),
|
|
222
|
+
...(callerCertificate?{runCertificate:1}:{})},captureTimeout);}
|
|
223
|
+
catch(error){captureIssues.push('start observation unavailable'+
|
|
224
|
+
(typeof error.code==='string'&&/^[A-Z0-9_]+$/.test(error.code)?` (${error.code})`:''));}
|
|
225
|
+
timing('cli.start_checkpoint',startSnapshotStarted,{available:before?.snapshots?.[name]?1:0});
|
|
226
|
+
// Missing discovery metadata can recover through a full probe. A missing
|
|
227
|
+
// healthy start boundary cannot: freeze this decision before any recovery.
|
|
228
|
+
// Only the reviewed macOS direct recipe enters this qualification shortcut.
|
|
229
|
+
const unobservedDirectRun=callerCertificate&&
|
|
230
|
+
before?.snapshots?.[name]?.observationHealthy!==true;
|
|
231
|
+
if(unobservedDirectRun){
|
|
232
|
+
captureIssues.push('qualification probes and end checkpoint not performed: no healthy start checkpoint');
|
|
233
|
+
mark('cli.run_validation_skipped',{reason:'start_observation_unavailable',probes:2,endCheckpoint:1});
|
|
234
|
+
}
|
|
235
|
+
await testRunBoundary('after-start-checkpoint');
|
|
236
|
+
// Start the observed interval before caller validation, not after it. Any
|
|
237
|
+
// intervening edit (even restored before launch) remains disqualifying.
|
|
238
|
+
if(callerCertificate&&!unobservedDirectRun)probeBefore=validateDirectRunCertificate(plan,selected,before?.snapshots?.[name])??probeHash(probePlan);
|
|
239
|
+
// A Windows full reconciliation may finish while queued watcher callbacks
|
|
240
|
+
// are still arriving. Fold callbacks delivered before process launch into
|
|
241
|
+
// the starting checkpoint; callbacks after launch still disqualify reuse.
|
|
242
|
+
if(process.platform==='win32'&&before?.snapshots?.[name]?.observationHealthy){
|
|
243
|
+
await new Promise(resolve=>setTimeout(resolve,100));
|
|
244
|
+
const prelaunchStarted=performance.now();
|
|
245
|
+
try{before=await call({action:'prelaunch',name,
|
|
246
|
+
contextHashes:contextHashes()},Math.min(captureTimeout,5000));}
|
|
247
|
+
catch{before=null;captureIssues.push('prelaunch observation unavailable');}
|
|
248
|
+
timing('cli.prelaunch_checkpoint',prelaunchStarted,
|
|
249
|
+
{available:before?.snapshots?.[name]?1:0});
|
|
250
|
+
}
|
|
251
|
+
const snap=before?.snapshots?.[name];
|
|
252
|
+
if(!snap||!snap.observationHealthy)captureIssues.push('start checkpoint unavailable');
|
|
253
|
+
await testRunBoundary('before-launch');
|
|
254
|
+
const started=Date.now(),executionStarted=performance.now();
|
|
255
|
+
const invocation=await new Promise(resolve=>{
|
|
256
|
+
if(requestedSignal){resolve({status:'interrupted',exitCode:null,signal:requestedSignal});return;}
|
|
257
|
+
let child,spawnError=null;
|
|
258
|
+
try{runLock.phase('launching');
|
|
259
|
+
const launch=windowsLaunch(command);
|
|
260
|
+
child=spawn(launch.file,launch.args,{cwd:plan.cwd,
|
|
261
|
+
stdio:'inherit',env:process.env,detached:process.platform==='linux',
|
|
262
|
+
...launch.options});}
|
|
263
|
+
catch(e){resolve({status:'start_failed',exitCode:null,signal:null,errorCode:e.code||e.name});return;}
|
|
264
|
+
activeChild=child;runLock.phase('running',child.pid??null);
|
|
265
|
+
mark('cli.child_started',{childPid:child.pid});
|
|
266
|
+
if(requestedSignal)forward(requestedSignal);
|
|
267
|
+
child.on('error',e=>{spawnError=e.code||e.name;});
|
|
268
|
+
child.on('close',async(exit,signal)=>{
|
|
269
|
+
if(treeStop)await treeStop;
|
|
270
|
+
if(process.platform==='linux'&&requestedSignal&&child.pid){
|
|
271
|
+
// A verification command may have forked children in its process
|
|
272
|
+
// group. Normal cancellation must reach them even if the leader has
|
|
273
|
+
// already exited. Commands that daemonize into a new session remain
|
|
274
|
+
// an explicit unsupported process boundary.
|
|
275
|
+
try{process.kill(-child.pid,'SIGTERM');}
|
|
276
|
+
catch(e){if(e.code!=='ESRCH')captureIssues.push('descendant stop uncertain');}
|
|
277
|
+
}
|
|
278
|
+
activeChild=null;if(!preserveRunLock)runLock.phase('finished');
|
|
279
|
+
resolve({status:spawnError?'start_failed':signal||requestedSignal?'interrupted':'exited',
|
|
280
|
+
exitCode:exit,signal:signal||requestedSignal||null,
|
|
281
|
+
...(spawnError?{errorCode:spawnError}:{})});
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
const ended=Date.now();
|
|
285
|
+
timing('cli.compiler_execution',executionStarted,{status:invocation.status,exit:invocation.exitCode});
|
|
286
|
+
await testRunBoundary('after-exit');
|
|
287
|
+
invocation.startedAt=started;invocation.durationMs=ended-started;
|
|
288
|
+
invocation.argvHash=plan.commandIdentity.argvHash;
|
|
289
|
+
invocation.argc=command.length;
|
|
290
|
+
invocation.runId=runId;
|
|
291
|
+
invocation.reporting='normal';
|
|
292
|
+
const result=invocation.status==='exited'?(invocation.exitCode===0?'PASS':'FAIL'):null;
|
|
293
|
+
const target={source:'direct-command',status:invocation.status==='exited'?'direct_executed':'unknown'};
|
|
294
|
+
const afterContextStarted=performance.now(),environmentAfter=contextHashes()[name];
|
|
295
|
+
timing('cli.context_after',afterContextStarted);
|
|
296
|
+
const probeAfter=unobservedDirectRun?null:((callerCertificate?validateDirectRunCertificate(plan,selected,before?.snapshots?.[name]):null)??probeHash(probePlan));
|
|
297
|
+
const endSnapshotStarted=performance.now();
|
|
298
|
+
if(!unobservedDirectRun){
|
|
299
|
+
try{after=await call({action:'snapshot',name,contextHashes:contextHashes()},captureTimeout);}
|
|
300
|
+
catch{captureIssues.push('end observation unavailable');}
|
|
301
|
+
timing('cli.end_checkpoint',endSnapshotStarted,{available:after?.snapshots?.[name]?1:0});
|
|
302
|
+
}
|
|
303
|
+
const end=after?.snapshots?.[name];
|
|
304
|
+
if(!unobservedDirectRun&&(!end||!end.observationHealthy))captureIssues.push('end checkpoint unavailable');
|
|
305
|
+
if(JSON.stringify(environmentBefore)!==JSON.stringify(environmentAfter))
|
|
306
|
+
captureIssues.push('declared environment changed during execution');
|
|
307
|
+
if(!unobservedDirectRun&&(!probeBefore||!probeAfter||probeBefore!==snap?.probeHash||probeAfter!==end?.probeHash))
|
|
308
|
+
captureIssues.push('declared state probe unavailable or changed');
|
|
309
|
+
if(before?.observation?.generation!==after?.observation?.generation)
|
|
310
|
+
captureIssues.push('observer generation changed during execution');
|
|
311
|
+
if(snap?.planGeneration!==end?.planGeneration)
|
|
312
|
+
captureIssues.push('input plan rebuilt during execution');
|
|
313
|
+
if(snap?.inputEventSerial!==end?.inputEventSerial)
|
|
314
|
+
captureIssues.push('declared input event occurred during execution');
|
|
315
|
+
if(!snap||!end||snap.planId!==end.planId||snap.fingerprint!==end.fingerprint||
|
|
316
|
+
snap.revision!==end.revision)captureIssues.push('declared inputs changed or were not observed');
|
|
317
|
+
const stable=captureIssues.length===0;
|
|
318
|
+
const receipt={result,invocation,target,stable,fingerprintVersion:1,
|
|
319
|
+
resolutionLinks:plan.resolutionLinks||[],
|
|
320
|
+
coverage:{qualified:plan.unresolved.length===0,unresolved:plan.unresolved},
|
|
321
|
+
checkpoint:{status:stable?'stable':'unavailable_or_changed',issues:captureIssues,
|
|
322
|
+
startRevision:snap?.revision??null,endRevision:end?.revision??null,
|
|
323
|
+
...(process.env.REDUE_PUBLIC_INPUT_TRACE==='1'?{
|
|
324
|
+
startInputEventSerial:snap?.inputEventSerial??null,
|
|
325
|
+
endInputEventSerial:end?.inputEventSerial??null}: {})},
|
|
326
|
+
planId:snap?.planId??plan.id,fingerprint:snap?.fingerprint??null,
|
|
327
|
+
provider:plan.provider,environmentHashes:environmentBefore,
|
|
328
|
+
...(observedContext?{observedContext,verificationRecipe:directInterpretation}:{}),
|
|
329
|
+
files:snap?.files??null,observerGeneration:before?.observation?.generation??null,
|
|
330
|
+
startRevision:snap?.revision??null,endRevision:end?.revision??null};
|
|
331
|
+
const persistenceStarted=performance.now();
|
|
332
|
+
commitReceipt(state,name,receipt);
|
|
333
|
+
timing('cli.receipt_persistence',persistenceStarted);
|
|
334
|
+
const reloadStarted=performance.now();
|
|
335
|
+
try{await call({action:'reload'});}catch{/* receipt revisions invalidate older status even when reload IPC is unavailable */}
|
|
336
|
+
timing('cli.receipt_reload',reloadStarted);
|
|
337
|
+
const summary={check:name,result,target:target.status,
|
|
338
|
+
invocation:invocation.status,...(invocation.errorCode?{error_code:invocation.errorCode}:{}),
|
|
339
|
+
stable,coverage_qualified:receipt.coverage.qualified};
|
|
340
|
+
console.log(process.argv[5]==='--human'?renderRun(summary):JSON.stringify(summary));
|
|
341
|
+
timing('cli.run_total',totalStarted,{stable:stable?1:0});
|
|
342
|
+
process.exitCode=invocation.status==='exited'?invocation.exitCode:
|
|
343
|
+
invocation.status==='interrupted'?128+(os.constants.signals[invocation.signal]||1):127;
|
|
344
|
+
}finally{
|
|
345
|
+
process.off('SIGINT',interrupt);process.off('SIGTERM',terminate);
|
|
346
|
+
process.off('message',cancelMessage);
|
|
347
|
+
if(process.connected)process.disconnect();
|
|
348
|
+
if(!preserveRunLock)runLock.release();}
|
|
349
|
+
}
|
|
350
|
+
async function main(){
|
|
351
|
+
if(action==='init') {owner();
|
|
352
|
+
console.log(JSON.stringify({root,state,config:configFile,executable:path.join(here,'cli.mjs')}));return;}
|
|
353
|
+
if(action==='start'){
|
|
354
|
+
if(endpoint.filesystem&&Buffer.byteLength(socket)>100)
|
|
355
|
+
throw Error(`state path is too long for a Unix control socket: ${state}; choose a shorter --state-dir`);
|
|
356
|
+
owner();
|
|
357
|
+
for(const check of config.checks)permittedRoots(root,check.allowedExternalRoots||[]);
|
|
358
|
+
const observerLock=path.join(state,'observer.lock');
|
|
359
|
+
if(fs.existsSync(observerLock)){
|
|
360
|
+
const pid=Number(fs.readFileSync(path.join(observerLock,'pid')));
|
|
361
|
+
if(processAlive(pid))throw Error(`observer already running at PID ${pid}`);
|
|
362
|
+
// Only this development state/socket are owned here; never touch shared services.
|
|
363
|
+
fs.rmSync(observerLock,{recursive:true});
|
|
364
|
+
if(endpoint.filesystem)try{fs.unlinkSync(socket);}catch(e){if(e.code!=='ENOENT')throw e;}
|
|
365
|
+
}
|
|
366
|
+
const log=fs.openSync(path.join(state,'observer.log'),'a',0o600);
|
|
367
|
+
const child=spawn(process.execPath,[path.join(here,'daemon.mjs'),configFile],
|
|
368
|
+
{cwd:os.tmpdir(),detached:true,stdio:['ignore',log,log]});child.unref();fs.closeSync(log);
|
|
369
|
+
const waitMs=Math.min(120000,Math.max(100,
|
|
370
|
+
Number(process.env.VSTATE_START_READY_WAIT_MS||5000)));
|
|
371
|
+
for(let i=0;i<Math.ceil(waitMs/100);i++){
|
|
372
|
+
await new Promise(r=>setTimeout(r,100));
|
|
373
|
+
const data=cached();
|
|
374
|
+
if(data.observation?.healthy){console.log(JSON.stringify({pid:child.pid,state:data.state,
|
|
375
|
+
health:data.observation,ready:true}));return;}
|
|
376
|
+
if(child.exitCode!==null)break;
|
|
377
|
+
}
|
|
378
|
+
try {await call({action:'metrics'},1000);
|
|
379
|
+
const data=cached();console.log(JSON.stringify({pid:child.pid,state:data.state,
|
|
380
|
+
health:data.observation,ready:Boolean(data.observation?.healthy)}));return;}
|
|
381
|
+
catch {
|
|
382
|
+
let live=processAlive(child.pid);
|
|
383
|
+
if(live&&fs.existsSync(path.join(observerLock,'pid')))
|
|
384
|
+
live=Number(fs.readFileSync(path.join(observerLock,'pid')))==child.pid;
|
|
385
|
+
if(live){console.log(JSON.stringify({pid:child.pid,state:'unverified',ready:false,
|
|
386
|
+
health:{healthy:false,reason:'observer initialization pending; query status before relying on evidence'}}));
|
|
387
|
+
return;}
|
|
388
|
+
throw Error('observer control did not become available; inspect owned observer.log');
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if(action==='run-lock-status'){
|
|
392
|
+
console.log(JSON.stringify(inspectRunLock(state)));return;
|
|
393
|
+
}
|
|
394
|
+
if(action==='recover-run-lock'){
|
|
395
|
+
owner();
|
|
396
|
+
console.log(JSON.stringify(recoverRunLock(state,name==='--confirm-check-stopped')));return;
|
|
397
|
+
}
|
|
398
|
+
if(action==='stop'){
|
|
399
|
+
if(!fs.existsSync(ownerFile))throw Error('state ownership marker absent; refusing stop');
|
|
400
|
+
owner();
|
|
401
|
+
try{const answer=await call({action:'stop'});
|
|
402
|
+
for(let i=0;i<100&&fs.existsSync(path.join(state,'observer.lock'));i++)
|
|
403
|
+
await new Promise(r=>setTimeout(r,100));
|
|
404
|
+
if(fs.existsSync(path.join(state,'observer.lock')))
|
|
405
|
+
throw Error('observer did not stop within 10 seconds');
|
|
406
|
+
console.log(JSON.stringify(answer));}
|
|
407
|
+
catch(e){console.log(JSON.stringify({ok:false,state:'observer_unavailable',
|
|
408
|
+
reason:e.code||e.message,recovery:'restore observed root and run start'}));}
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if(action==='uninstall'){
|
|
412
|
+
if(!fs.existsSync(ownerFile))throw Error('state ownership marker absent; refusing removal');
|
|
413
|
+
owner();
|
|
414
|
+
if(fs.existsSync(path.join(state,'observer.lock'))){
|
|
415
|
+
const pid=Number(fs.readFileSync(path.join(state,'observer.lock','pid')));
|
|
416
|
+
if(processAlive(pid)){await call({action:'stop'});
|
|
417
|
+
for(let i=0;i<100&&fs.existsSync(path.join(state,'observer.lock'));i++)
|
|
418
|
+
await new Promise(r=>setTimeout(r,100));
|
|
419
|
+
if(fs.existsSync(path.join(state,'observer.lock')))throw Error('observer did not stop');}
|
|
420
|
+
}
|
|
421
|
+
fs.rmSync(state,{recursive:true,force:true});
|
|
422
|
+
console.log(JSON.stringify({removed:state}));return;
|
|
423
|
+
}
|
|
424
|
+
if(action==='status'){console.log(JSON.stringify(cached()));return;}
|
|
425
|
+
if(action==='sync'||action==='detail'){
|
|
426
|
+
const started=performance.now();
|
|
427
|
+
if(profiling&&config.checks.some(check=>check.qualification===directQualification)){
|
|
428
|
+
const proxyShapes=Object.entries(process.env).filter(([key])=>/^npm_config_.*proxy$/i.test(key)).map(([key,value])=>{
|
|
429
|
+
try{const url=new URL(value),portDigest=digest(url.port);url.port='';
|
|
430
|
+
return {key,kind:'url',portDigest,withoutPortDigest:digest(url.href)};}
|
|
431
|
+
catch{return {key,kind:value===''?'empty':'non-url',valueDigest:digest(value)};}
|
|
432
|
+
});
|
|
433
|
+
timing('cli.direct_context',started,{interpretation:directInterpretation,
|
|
434
|
+
observedNpmDigest:observedNpmContext(),proxyShapes});
|
|
435
|
+
}
|
|
436
|
+
let value;try{value=await decisionCall({action:'sync',contextHashes:contextHashes()},
|
|
437
|
+
Number(process.env.VSTATE_SYNC_TIMEOUT_MS||decisionDeadlineMs));
|
|
438
|
+
// A client upgrade can encounter an older running observer. Its successful
|
|
439
|
+
// control response does not establish that it evaluated the latest receipt.
|
|
440
|
+
if(value.receipt_revision===null){
|
|
441
|
+
value=unavailableCached(state,'latest receipt selection unavailable; inspect receipt state before reuse',config.checks);
|
|
442
|
+
}else if(typeof value.receipt_revision!=='string'){
|
|
443
|
+
value=unavailableCached(state,'observer cannot confirm latest receipt selection; stop and start the observer after upgrading',config.checks);
|
|
444
|
+
process.exitCode=2;
|
|
445
|
+
}else if(value.receipt_revision!==receiptRevision(state)){
|
|
446
|
+
value=unavailableCached(state,'receipt selection changed during synchronization; retry synchronized status',config.checks);
|
|
447
|
+
process.exitCode=2;
|
|
448
|
+
}}
|
|
449
|
+
catch(e){value=unavailableCached(state,e.message,config.checks);process.exitCode=2;}
|
|
450
|
+
timing('cli.synchronized_status',started,{available:value.observation?.healthy?1:0});
|
|
451
|
+
if(action==='sync')console.log(JSON.stringify(value));
|
|
452
|
+
else for(const row of value.checks)console.log(`${row.name}: ${row.freshness}/${row.result||'NO RESULT'} — ${row.reason}`);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if(action==='metrics'){console.log(JSON.stringify(await call({action:'metrics'})));return;}
|
|
456
|
+
if(action==='run'){await execute();return;}
|
|
457
|
+
throw Error(`unknown action ${action}`);
|
|
458
|
+
}
|
|
459
|
+
main().catch(e=>{console.error(`redue: ${e.message}`);process.exitCode=2;});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {createHash} from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
export function controlEndpoint(state,platform=process.platform) {
|
|
5
|
+
if(platform==='win32'){
|
|
6
|
+
const identity=path.win32.normalize(state).toLowerCase();
|
|
7
|
+
const id=createHash('sha256').update(identity).digest('hex').slice(0,24);
|
|
8
|
+
return {address:`\\\\.\\pipe\\vstate-${id}`,filesystem:false};
|
|
9
|
+
}
|
|
10
|
+
return {address:path.join(state,'observer.sock'),filesystem:true};
|
|
11
|
+
}
|