@redue/cli 0.0.0-stage → 0.1.0-alpha.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +181 -0
- package/LICENSE +180 -0
- package/README.md +162 -2
- package/bin/redue.mjs +371 -0
- package/bin/vstate.mjs +3 -0
- package/docs/README.md +21 -0
- package/docs/user/agents.md +253 -0
- package/docs/user/configuration.md +60 -0
- package/docs/user/direct-typescript.md +114 -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 +145 -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 +294 -0
- package/src/agent-policy.md +70 -0
- package/src/cached-state.mjs +42 -0
- package/src/claude-hook.mjs +115 -0
- package/src/cli.mjs +499 -0
- package/src/control-endpoint.mjs +11 -0
- package/src/daemon.mjs +1236 -0
- package/src/decision-profile.mjs +32 -0
- package/src/decision-validation.mjs +51 -0
- package/src/declared-plan.mjs +125 -0
- package/src/direct-run-certificate.mjs +185 -0
- package/src/direct-typescript-probe.mjs +22 -0
- package/src/direct-typescript.mjs +109 -0
- package/src/executable-lookup.mjs +18 -0
- package/src/glob.mjs +9 -0
- package/src/history-worker.mjs +26 -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 +59 -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 +25 -0
- package/src/process-liveness.mjs +11 -0
- package/src/project-location.mjs +34 -0
- package/src/run-lock.mjs +77 -0
- package/src/run-recorder.cjs +78 -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 +68 -0
- package/src/typescript-list.mjs +141 -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
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Claude Code PreToolUse hook: skip an exact typecheck rerun when REDUE reports
|
|
2
|
+
// CURRENT/PASS with reuse_eligible on the current inputs. When the check is not
|
|
3
|
+
// CURRENT, the command becomes `redue run <check>` so the run is recorded. Every
|
|
4
|
+
// other outcome, including errors and timeouts, allows the command unchanged.
|
|
5
|
+
// Never exit 2: Claude Code treats exit 2 as a block.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import {spawn} from 'node:child_process';
|
|
9
|
+
|
|
10
|
+
export const hookCommand='redue agent hook claude';
|
|
11
|
+
const safe=/^[A-Za-z0-9_./:=,+-]+$/;
|
|
12
|
+
const typecheck=check=>check?.kind==='typecheck'||check?.name==='typecheck';
|
|
13
|
+
|
|
14
|
+
// Exact commands that rerun each configured typecheck. Names that would need
|
|
15
|
+
// shell quoting get no form, so the hook never fires for them.
|
|
16
|
+
export function hookForms(config){
|
|
17
|
+
const rows=[];
|
|
18
|
+
for(const check of config?.checks||[]){
|
|
19
|
+
if(!typecheck(check)||typeof check.name!=='string'||!safe.test(check.name))continue;
|
|
20
|
+
const forms=[`redue run ${check.name}`];
|
|
21
|
+
// Other forms run from the project root, so they match only a root check.
|
|
22
|
+
if(check.cwd!==undefined&&check.cwd!=='.'){rows.push({check:check.name,forms});continue;}
|
|
23
|
+
if(config.packageManager==='npm'&&typeof check.script==='string'&&safe.test(check.script))
|
|
24
|
+
forms.push(`npm run ${check.script}`);
|
|
25
|
+
if(check.qualification==='npm-typescript-direct-v1')forms.push('npx tsc --noEmit');
|
|
26
|
+
if(Array.isArray(check.command)&&check.command.length&&
|
|
27
|
+
check.command.every(v=>typeof v==='string'&&safe.test(v)&&!v.startsWith('@')))
|
|
28
|
+
forms.push(check.command.join(' '));
|
|
29
|
+
rows.push({check:check.name,forms:[...new Set(forms)]});
|
|
30
|
+
}
|
|
31
|
+
return rows;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Accept only an optional trailing `2>&1`, then an optional `| tail|head -n N`
|
|
35
|
+
// or `-N`. Newlines separate commands, so they never match.
|
|
36
|
+
function split(command){
|
|
37
|
+
if(typeof command!=='string'||/[\r\n]/.test(command))return null;
|
|
38
|
+
const [,base,suffix]=command.trim().replace(/[ \t]+/g,' ')
|
|
39
|
+
.match(/^(.*?)((?: 2>&1)?(?: \| (?:tail|head) (?:-n \d+|-\d+))?)$/);
|
|
40
|
+
return {base,suffix};
|
|
41
|
+
}
|
|
42
|
+
export function matchCheck(command,config){
|
|
43
|
+
const parts=split(command);
|
|
44
|
+
return parts&&hookForms(config).find(row=>row.forms.includes(parts.base))?.check||null;
|
|
45
|
+
}
|
|
46
|
+
// The same check through REDUE, keeping the output suffix; null if it already is.
|
|
47
|
+
export function recordedCommand(command,name,configFile='redue.config.json'){
|
|
48
|
+
const parts=split(command);
|
|
49
|
+
if(!parts||!safe.test(name)||!safe.test(configFile))return null;
|
|
50
|
+
const run=`redue ${configFile==='redue.config.json'?'':`--config ${configFile} `}run ${name}`;
|
|
51
|
+
return parts.base===run||parts.base===`redue run ${name}`?null:run+parts.suffix;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function verdict(status,name){
|
|
55
|
+
const row=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)?
|
|
56
|
+
status.checks.find(item=>item?.name===name):null;
|
|
57
|
+
const runId=row?.invocation?.runId;
|
|
58
|
+
if(!row||row.freshness!=='CURRENT'||row.result!=='PASS'||row.reuse_eligible!==true||
|
|
59
|
+
typeof runId!=='string'||!/^[0-9a-f-]{36}$/.test(runId))return null;
|
|
60
|
+
return `REDUE: check ${JSON.stringify(name)} passed on these exact inputs (run ${runId}); `+
|
|
61
|
+
'rerun skipped. To run it anyway, prefix the command with REDUE_FORCE=1.';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function synchronizedStatus(entry,config,root,timeout){
|
|
65
|
+
return new Promise(resolve=>{
|
|
66
|
+
let child,out='',done=false;
|
|
67
|
+
const finish=value=>{if(done)return;done=true;clearTimeout(timer);
|
|
68
|
+
if(child?.pid&&child.exitCode===null)try{process.platform==='win32'?child.kill('SIGKILL'):process.kill(-child.pid,'SIGKILL');}catch{}
|
|
69
|
+
resolve(value);};
|
|
70
|
+
const timer=setTimeout(()=>finish(null),timeout);
|
|
71
|
+
// Its own process group on POSIX, so a timeout also ends the engine it starts.
|
|
72
|
+
try{child=spawn(process.execPath,[entry,'--config',config,'status','--sync','--json'],
|
|
73
|
+
{cwd:root,stdio:['ignore','pipe','ignore'],env:process.env,windowsHide:true,detached:process.platform!=='win32'});}
|
|
74
|
+
catch{return finish(null);}
|
|
75
|
+
child.on('error',()=>finish(null));
|
|
76
|
+
child.stdout.on('data',chunk=>{out+=chunk;if(out.length>4*1024*1024)finish(null);});
|
|
77
|
+
child.on('close',code=>{if(code!==0)return finish(null);
|
|
78
|
+
try{finish(JSON.parse(out));}catch{finish(null);}});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Returns the hook's stdout object, or null to allow the command.
|
|
83
|
+
export async function claudeHook(input,{entry,timeout=2000}){
|
|
84
|
+
if(input?.tool_name!=='Bash')return null;
|
|
85
|
+
const command=input.tool_input?.command,root=input.cwd;
|
|
86
|
+
if(typeof command!=='string'||/^\s*REDUE_FORCE=1(?:\s|$)/.test(command))return null;
|
|
87
|
+
if(typeof root!=='string'||!path.isAbsolute(root))return null;
|
|
88
|
+
const record=JSON.parse(fs.readFileSync(path.join(root,'.redue','agents','claude.json'),'utf8'));
|
|
89
|
+
if(record?.schema!==1||record.host!=='claude'||typeof record.config!=='string'||
|
|
90
|
+
!/^[^/\\]+\.json$/.test(record.config))return null;
|
|
91
|
+
const config=path.join(root,record.config);
|
|
92
|
+
const name=matchCheck(command,JSON.parse(fs.readFileSync(config,'utf8')));
|
|
93
|
+
if(!name)return null;
|
|
94
|
+
const status=await synchronizedStatus(entry,config,root,timeout),reason=verdict(status,name);
|
|
95
|
+
if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',
|
|
96
|
+
permissionDecision:'deny',permissionDecisionReason:reason}};
|
|
97
|
+
// Rewrite only on a healthy answer for this check: without an observer,
|
|
98
|
+
// `redue run` cannot record, so the command stays as Claude sent it.
|
|
99
|
+
const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
|
|
100
|
+
status.checks.some(item=>item?.name===name)?recordedCommand(command,name,record.config):null;
|
|
101
|
+
// updatedInput replaces the whole tool input. Deny and ask rules still apply
|
|
102
|
+
// to the rewritten command; "allow" skips only the prompt.
|
|
103
|
+
return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
|
|
104
|
+
permissionDecisionReason:`REDUE: check ${JSON.stringify(name)} is not CURRENT; running it through redue run so the result is recorded.`,
|
|
105
|
+
updatedInput:{...input.tool_input,command:rewritten}}}:null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function runClaudeHook(entry){
|
|
109
|
+
try{
|
|
110
|
+
let text='';process.stdin.setEncoding('utf8');
|
|
111
|
+
for await(const chunk of process.stdin){text+=chunk;if(text.length>1024*1024)return;}
|
|
112
|
+
const output=await claudeHook(JSON.parse(text),{entry});
|
|
113
|
+
if(output)await new Promise(resolve=>process.stdout.write(JSON.stringify(output)+'\n',resolve));
|
|
114
|
+
}catch{}
|
|
115
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import {directQualification,directInterpretation,observedNpmContext} from './direct-typescript.mjs';
|
|
4
|
+
import {directRunCertificateAllowed,directRunCertificate,directRunRecording,validateRunRecord} 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 direct recipe on macOS and Linux enters this 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
|
+
// With a certificate, the compile records its own answers instead of two probes.
|
|
239
|
+
// Without one after an edit, the recording can become the certificate at the
|
|
240
|
+
// end checkpoint; that checkpoint probes in full if it cannot.
|
|
241
|
+
let certificate=null,recording=null;
|
|
242
|
+
if(callerCertificate&&!unobservedDirectRun){
|
|
243
|
+
certificate=directRunCertificate(plan,selected,before?.snapshots?.[name],root);
|
|
244
|
+
if(!certificate)recording=directRunRecording(plan,selected,before?.snapshots?.[name],root);
|
|
245
|
+
probeBefore=certificate?.value??(recording?null:probeHash(probePlan));
|
|
246
|
+
}
|
|
247
|
+
// The record arrives on fd 3, so the compiler's environment is unchanged.
|
|
248
|
+
const launched=certificate||recording?[command[0],'--require',path.join(here,'run-recorder.cjs'),
|
|
249
|
+
...command.slice(1)]:command;
|
|
250
|
+
const record=[];
|
|
251
|
+
// A Windows full reconciliation may finish while queued watcher callbacks
|
|
252
|
+
// are still arriving. Fold callbacks delivered before process launch into
|
|
253
|
+
// the starting checkpoint; callbacks after launch still disqualify reuse.
|
|
254
|
+
if(process.platform==='win32'&&before?.snapshots?.[name]?.observationHealthy){
|
|
255
|
+
await new Promise(resolve=>setTimeout(resolve,100));
|
|
256
|
+
const prelaunchStarted=performance.now();
|
|
257
|
+
try{before=await call({action:'prelaunch',name,
|
|
258
|
+
contextHashes:contextHashes()},Math.min(captureTimeout,5000));}
|
|
259
|
+
catch{before=null;captureIssues.push('prelaunch observation unavailable');}
|
|
260
|
+
timing('cli.prelaunch_checkpoint',prelaunchStarted,
|
|
261
|
+
{available:before?.snapshots?.[name]?1:0});
|
|
262
|
+
}
|
|
263
|
+
const snap=before?.snapshots?.[name];
|
|
264
|
+
if(!snap||!snap.observationHealthy)captureIssues.push('start checkpoint unavailable');
|
|
265
|
+
await testRunBoundary('before-launch');
|
|
266
|
+
const started=Date.now(),executionStarted=performance.now();
|
|
267
|
+
const invocation=await new Promise(resolve=>{
|
|
268
|
+
if(requestedSignal){resolve({status:'interrupted',exitCode:null,signal:requestedSignal});return;}
|
|
269
|
+
let child,spawnError=null;
|
|
270
|
+
try{runLock.phase('launching');
|
|
271
|
+
const launch=windowsLaunch(launched);
|
|
272
|
+
child=spawn(launch.file,launch.args,{cwd:plan.cwd,
|
|
273
|
+
stdio:certificate||recording?['inherit','inherit','inherit','pipe']:'inherit',env:process.env,
|
|
274
|
+
detached:process.platform==='linux',...launch.options});
|
|
275
|
+
child.stdio[3]?.on('data',chunk=>record.push(chunk));}
|
|
276
|
+
catch(e){resolve({status:'start_failed',exitCode:null,signal:null,errorCode:e.code||e.name});return;}
|
|
277
|
+
activeChild=child;runLock.phase('running',child.pid??null);
|
|
278
|
+
mark('cli.child_started',{childPid:child.pid});
|
|
279
|
+
if(requestedSignal)forward(requestedSignal);
|
|
280
|
+
child.on('error',e=>{spawnError=e.code||e.name;});
|
|
281
|
+
child.on('close',async(exit,signal)=>{
|
|
282
|
+
if(treeStop)await treeStop;
|
|
283
|
+
if(process.platform==='linux'&&requestedSignal&&child.pid){
|
|
284
|
+
// A verification command may have forked children in its process
|
|
285
|
+
// group. Normal cancellation must reach them even if the leader has
|
|
286
|
+
// already exited. Commands that daemonize into a new session remain
|
|
287
|
+
// an explicit unsupported process boundary.
|
|
288
|
+
try{process.kill(-child.pid,'SIGTERM');}
|
|
289
|
+
catch(e){if(e.code!=='ESRCH')captureIssues.push('descendant stop uncertain');}
|
|
290
|
+
}
|
|
291
|
+
activeChild=null;if(!preserveRunLock)runLock.phase('finished');
|
|
292
|
+
resolve({status:spawnError?'start_failed':signal||requestedSignal?'interrupted':'exited',
|
|
293
|
+
exitCode:exit,signal:signal||requestedSignal||null,
|
|
294
|
+
...(spawnError?{errorCode:spawnError}:{})});
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
const ended=Date.now();
|
|
298
|
+
timing('cli.compiler_execution',executionStarted,{status:invocation.status,exit:invocation.exitCode});
|
|
299
|
+
await testRunBoundary('after-exit');
|
|
300
|
+
invocation.startedAt=started;invocation.durationMs=ended-started;
|
|
301
|
+
invocation.argvHash=plan.commandIdentity.argvHash;
|
|
302
|
+
invocation.argc=command.length;
|
|
303
|
+
invocation.runId=runId;
|
|
304
|
+
invocation.reporting='normal';
|
|
305
|
+
const result=invocation.status==='exited'?(invocation.exitCode===0?'PASS':'FAIL'):null;
|
|
306
|
+
const target={source:'direct-command',status:invocation.status==='exited'?'direct_executed':'unknown'};
|
|
307
|
+
const afterContextStarted=performance.now(),environmentAfter=contextHashes()[name];
|
|
308
|
+
timing('cli.context_after',afterContextStarted);
|
|
309
|
+
let probeAfter=null;
|
|
310
|
+
const recordText=Buffer.concat(record).toString('utf8');
|
|
311
|
+
if(certificate){
|
|
312
|
+
const issue=validateRunRecord(recordText,certificate);
|
|
313
|
+
if(issue)captureIssues.push(`compiler inputs differ from discovery certificate (${issue})`);
|
|
314
|
+
probeAfter=certificate.value;
|
|
315
|
+
}else if(!unobservedDirectRun&&!recording)probeAfter=probeHash(probePlan);
|
|
316
|
+
// The daemon reads the recording from owned state; control requests are small.
|
|
317
|
+
const recordFile=recording&&path.join(state,`run-record-${recording}.json`);
|
|
318
|
+
if(recordFile)try{fs.writeFileSync(recordFile,recordText,{mode:0o600});}catch{}
|
|
319
|
+
const endSnapshotStarted=performance.now();
|
|
320
|
+
if(!unobservedDirectRun){
|
|
321
|
+
try{after=await call({action:'snapshot',name,contextHashes:contextHashes(),
|
|
322
|
+
...(recording?{runCertificate:1,adopt:recording}:{})},captureTimeout);}
|
|
323
|
+
catch{captureIssues.push('end observation unavailable');}
|
|
324
|
+
finally{if(recordFile)fs.rmSync(recordFile,{force:true});}
|
|
325
|
+
timing('cli.end_checkpoint',endSnapshotStarted,{available:after?.snapshots?.[name]?1:0});
|
|
326
|
+
}
|
|
327
|
+
const end=after?.snapshots?.[name];
|
|
328
|
+
// The end certificate, recorded or probed, must cover what this compile received.
|
|
329
|
+
// With no input event, gap or content change in between, it also holds at the start.
|
|
330
|
+
if(recording&&end?.recordingAccepted===true)probeBefore=probeAfter=end.probeHash;
|
|
331
|
+
else if(recording){
|
|
332
|
+
const endCertificate=directRunCertificate(plan,selected,end,root);
|
|
333
|
+
if(!endCertificate)captureIssues.push('no discovery certificate at the end checkpoint');
|
|
334
|
+
else{
|
|
335
|
+
const issue=validateRunRecord(recordText,endCertificate);
|
|
336
|
+
if(issue)captureIssues.push(`compiler inputs differ from discovery certificate (${issue})`);
|
|
337
|
+
probeBefore=probeAfter=endCertificate.value;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if(recording&&snap?.observationGaps!==end?.observationGaps)
|
|
341
|
+
captureIssues.push('observation gap during execution');
|
|
342
|
+
if(!unobservedDirectRun&&(!end||!end.observationHealthy))captureIssues.push('end checkpoint unavailable');
|
|
343
|
+
if(JSON.stringify(environmentBefore)!==JSON.stringify(environmentAfter))
|
|
344
|
+
captureIssues.push('declared environment changed during execution');
|
|
345
|
+
if(!unobservedDirectRun&&(!probeBefore||!probeAfter||probeBefore!==(recording?end:snap)?.probeHash||
|
|
346
|
+
probeAfter!==end?.probeHash))
|
|
347
|
+
captureIssues.push('declared state probe unavailable or changed');
|
|
348
|
+
if(before?.observation?.generation!==after?.observation?.generation)
|
|
349
|
+
captureIssues.push('observer generation changed during execution');
|
|
350
|
+
if(snap?.planGeneration!==end?.planGeneration)
|
|
351
|
+
captureIssues.push('input plan rebuilt during execution');
|
|
352
|
+
if(snap?.inputEventSerial!==end?.inputEventSerial)
|
|
353
|
+
captureIssues.push('declared input event occurred during execution');
|
|
354
|
+
// A recorded certificate changes only the probe part of the fingerprint.
|
|
355
|
+
if(!snap||!end||snap.planId!==end.planId||(recording?snap.inputKey!==end.inputKey:snap.fingerprint!==end.fingerprint)||
|
|
356
|
+
snap.revision!==end.revision)captureIssues.push('declared inputs changed or were not observed');
|
|
357
|
+
const stable=captureIssues.length===0;
|
|
358
|
+
const receipt={result,invocation,target,stable,fingerprintVersion:1,
|
|
359
|
+
resolutionLinks:plan.resolutionLinks||[],
|
|
360
|
+
coverage:{qualified:plan.unresolved.length===0,unresolved:plan.unresolved},
|
|
361
|
+
checkpoint:{status:stable?'stable':'unavailable_or_changed',issues:captureIssues,
|
|
362
|
+
startRevision:snap?.revision??null,endRevision:end?.revision??null,
|
|
363
|
+
...(process.env.REDUE_PUBLIC_INPUT_TRACE==='1'?{
|
|
364
|
+
startInputEventSerial:snap?.inputEventSerial??null,
|
|
365
|
+
endInputEventSerial:end?.inputEventSerial??null}: {})},
|
|
366
|
+
planId:snap?.planId??plan.id,fingerprint:(recording?end:snap)?.fingerprint??null,
|
|
367
|
+
provider:plan.provider,environmentHashes:environmentBefore,
|
|
368
|
+
...(observedContext?{observedContext,verificationRecipe:directInterpretation}:{}),
|
|
369
|
+
files:snap?.files??null,observerGeneration:before?.observation?.generation??null,
|
|
370
|
+
startRevision:snap?.revision??null,endRevision:end?.revision??null};
|
|
371
|
+
const persistenceStarted=performance.now();
|
|
372
|
+
commitReceipt(state,name,receipt);
|
|
373
|
+
timing('cli.receipt_persistence',persistenceStarted);
|
|
374
|
+
const reloadStarted=performance.now();
|
|
375
|
+
try{await call({action:'reload'});}catch{/* receipt revisions invalidate older status even when reload IPC is unavailable */}
|
|
376
|
+
timing('cli.receipt_reload',reloadStarted);
|
|
377
|
+
const summary={check:name,result,target:target.status,
|
|
378
|
+
invocation:invocation.status,...(invocation.errorCode?{error_code:invocation.errorCode}:{}),
|
|
379
|
+
stable,coverage_qualified:receipt.coverage.qualified};
|
|
380
|
+
console.log(process.argv[5]==='--human'?renderRun(summary):JSON.stringify(summary));
|
|
381
|
+
timing('cli.run_total',totalStarted,{stable:stable?1:0});
|
|
382
|
+
process.exitCode=invocation.status==='exited'?invocation.exitCode:
|
|
383
|
+
invocation.status==='interrupted'?128+(os.constants.signals[invocation.signal]||1):127;
|
|
384
|
+
}finally{
|
|
385
|
+
process.off('SIGINT',interrupt);process.off('SIGTERM',terminate);
|
|
386
|
+
process.off('message',cancelMessage);
|
|
387
|
+
if(process.connected)process.disconnect();
|
|
388
|
+
if(!preserveRunLock)runLock.release();}
|
|
389
|
+
}
|
|
390
|
+
async function main(){
|
|
391
|
+
if(action==='init') {owner();
|
|
392
|
+
console.log(JSON.stringify({root,state,config:configFile,executable:path.join(here,'cli.mjs')}));return;}
|
|
393
|
+
if(action==='start'){
|
|
394
|
+
if(endpoint.filesystem&&Buffer.byteLength(socket)>100)
|
|
395
|
+
throw Error(`state path is too long for a Unix control socket: ${state}; choose a shorter --state-dir`);
|
|
396
|
+
owner();
|
|
397
|
+
for(const check of config.checks)permittedRoots(root,check.allowedExternalRoots||[]);
|
|
398
|
+
const observerLock=path.join(state,'observer.lock');
|
|
399
|
+
if(fs.existsSync(observerLock)){
|
|
400
|
+
const pid=Number(fs.readFileSync(path.join(observerLock,'pid')));
|
|
401
|
+
if(processAlive(pid))throw Error(`observer already running at PID ${pid}`);
|
|
402
|
+
// Only this development state/socket are owned here; never touch shared services.
|
|
403
|
+
fs.rmSync(observerLock,{recursive:true});
|
|
404
|
+
if(endpoint.filesystem)try{fs.unlinkSync(socket);}catch(e){if(e.code!=='ENOENT')throw e;}
|
|
405
|
+
}
|
|
406
|
+
const log=fs.openSync(path.join(state,'observer.log'),'a',0o600);
|
|
407
|
+
const child=spawn(process.execPath,[path.join(here,'daemon.mjs'),configFile],
|
|
408
|
+
{cwd:os.tmpdir(),detached:true,stdio:['ignore',log,log]});child.unref();fs.closeSync(log);
|
|
409
|
+
const waitMs=Math.min(120000,Math.max(100,
|
|
410
|
+
Number(process.env.VSTATE_START_READY_WAIT_MS||5000)));
|
|
411
|
+
for(let i=0;i<Math.ceil(waitMs/100);i++){
|
|
412
|
+
await new Promise(r=>setTimeout(r,100));
|
|
413
|
+
const data=cached();
|
|
414
|
+
if(data.observation?.healthy){console.log(JSON.stringify({pid:child.pid,state:data.state,
|
|
415
|
+
health:data.observation,ready:true}));return;}
|
|
416
|
+
if(child.exitCode!==null)break;
|
|
417
|
+
}
|
|
418
|
+
try {await call({action:'metrics'},1000);
|
|
419
|
+
const data=cached();console.log(JSON.stringify({pid:child.pid,state:data.state,
|
|
420
|
+
health:data.observation,ready:Boolean(data.observation?.healthy)}));return;}
|
|
421
|
+
catch {
|
|
422
|
+
let live=processAlive(child.pid);
|
|
423
|
+
if(live&&fs.existsSync(path.join(observerLock,'pid')))
|
|
424
|
+
live=Number(fs.readFileSync(path.join(observerLock,'pid')))==child.pid;
|
|
425
|
+
if(live){console.log(JSON.stringify({pid:child.pid,state:'unverified',ready:false,
|
|
426
|
+
health:{healthy:false,reason:'observer initialization pending; query status before relying on evidence'}}));
|
|
427
|
+
return;}
|
|
428
|
+
throw Error('observer control did not become available; inspect owned observer.log');
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if(action==='run-lock-status'){
|
|
432
|
+
console.log(JSON.stringify(inspectRunLock(state)));return;
|
|
433
|
+
}
|
|
434
|
+
if(action==='recover-run-lock'){
|
|
435
|
+
owner();
|
|
436
|
+
console.log(JSON.stringify(recoverRunLock(state,name==='--confirm-check-stopped')));return;
|
|
437
|
+
}
|
|
438
|
+
if(action==='stop'){
|
|
439
|
+
if(!fs.existsSync(ownerFile))throw Error('state ownership marker absent; refusing stop');
|
|
440
|
+
owner();
|
|
441
|
+
try{const answer=await call({action:'stop'});
|
|
442
|
+
for(let i=0;i<100&&fs.existsSync(path.join(state,'observer.lock'));i++)
|
|
443
|
+
await new Promise(r=>setTimeout(r,100));
|
|
444
|
+
if(fs.existsSync(path.join(state,'observer.lock')))
|
|
445
|
+
throw Error('observer did not stop within 10 seconds');
|
|
446
|
+
console.log(JSON.stringify(answer));}
|
|
447
|
+
catch(e){console.log(JSON.stringify({ok:false,state:'observer_unavailable',
|
|
448
|
+
reason:e.code||e.message,recovery:'restore observed root and run start'}));}
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if(action==='uninstall'){
|
|
452
|
+
if(!fs.existsSync(ownerFile))throw Error('state ownership marker absent; refusing removal');
|
|
453
|
+
owner();
|
|
454
|
+
if(fs.existsSync(path.join(state,'observer.lock'))){
|
|
455
|
+
const pid=Number(fs.readFileSync(path.join(state,'observer.lock','pid')));
|
|
456
|
+
if(processAlive(pid)){await call({action:'stop'});
|
|
457
|
+
for(let i=0;i<100&&fs.existsSync(path.join(state,'observer.lock'));i++)
|
|
458
|
+
await new Promise(r=>setTimeout(r,100));
|
|
459
|
+
if(fs.existsSync(path.join(state,'observer.lock')))throw Error('observer did not stop');}
|
|
460
|
+
}
|
|
461
|
+
fs.rmSync(state,{recursive:true,force:true});
|
|
462
|
+
console.log(JSON.stringify({removed:state}));return;
|
|
463
|
+
}
|
|
464
|
+
if(action==='status'){console.log(JSON.stringify(cached()));return;}
|
|
465
|
+
if(action==='sync'||action==='detail'){
|
|
466
|
+
const started=performance.now();
|
|
467
|
+
if(profiling&&config.checks.some(check=>check.qualification===directQualification)){
|
|
468
|
+
const proxyShapes=Object.entries(process.env).filter(([key])=>/^npm_config_.*proxy$/i.test(key)).map(([key,value])=>{
|
|
469
|
+
try{const url=new URL(value),portDigest=digest(url.port);url.port='';
|
|
470
|
+
return {key,kind:'url',portDigest,withoutPortDigest:digest(url.href)};}
|
|
471
|
+
catch{return {key,kind:value===''?'empty':'non-url',valueDigest:digest(value)};}
|
|
472
|
+
});
|
|
473
|
+
timing('cli.direct_context',started,{interpretation:directInterpretation,
|
|
474
|
+
observedNpmDigest:observedNpmContext(),proxyShapes});
|
|
475
|
+
}
|
|
476
|
+
let value;try{value=await decisionCall({action:'sync',contextHashes:contextHashes()},
|
|
477
|
+
Number(process.env.VSTATE_SYNC_TIMEOUT_MS||decisionDeadlineMs));
|
|
478
|
+
// A client upgrade can encounter an older running observer. Its successful
|
|
479
|
+
// control response does not establish that it evaluated the latest receipt.
|
|
480
|
+
if(value.receipt_revision===null){
|
|
481
|
+
value=unavailableCached(state,'latest receipt selection unavailable; inspect receipt state before reuse',config.checks);
|
|
482
|
+
}else if(typeof value.receipt_revision!=='string'){
|
|
483
|
+
value=unavailableCached(state,'observer cannot confirm latest receipt selection; stop and start the observer after upgrading',config.checks);
|
|
484
|
+
process.exitCode=2;
|
|
485
|
+
}else if(value.receipt_revision!==receiptRevision(state)){
|
|
486
|
+
value=unavailableCached(state,'receipt selection changed during synchronization; retry synchronized status',config.checks);
|
|
487
|
+
process.exitCode=2;
|
|
488
|
+
}}
|
|
489
|
+
catch(e){value=unavailableCached(state,e.message,config.checks);process.exitCode=2;}
|
|
490
|
+
timing('cli.synchronized_status',started,{available:value.observation?.healthy?1:0});
|
|
491
|
+
if(action==='sync')console.log(JSON.stringify(value));
|
|
492
|
+
else for(const row of value.checks)console.log(`${row.name}: ${row.freshness}/${row.result||'NO RESULT'} — ${row.reason}`);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if(action==='metrics'){console.log(JSON.stringify(await call({action:'metrics'})));return;}
|
|
496
|
+
if(action==='run'){await execute();return;}
|
|
497
|
+
throw Error(`unknown action ${action}`);
|
|
498
|
+
}
|
|
499
|
+
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
|
+
}
|