@redue/cli 0.0.0-stage → 0.1.0-alpha.2
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 +22 -0
- package/LICENSE +180 -0
- package/README.md +123 -2
- package/bin/redue.mjs +329 -0
- package/bin/vstate.mjs +3 -0
- package/docs/README.md +20 -0
- package/docs/user/agents.md +32 -0
- package/docs/user/configuration.md +57 -0
- package/docs/user/getting-started.md +127 -0
- package/docs/user/json.md +59 -0
- package/docs/user/shell.md +25 -0
- package/docs/user/supported-projects.md +89 -0
- package/examples/redue.config.json +8 -0
- package/package.json +46 -4
- package/src/cached-state.mjs +27 -0
- package/src/cli.mjs +390 -0
- package/src/control-endpoint.mjs +11 -0
- package/src/daemon.mjs +989 -0
- package/src/decision-profile.mjs +18 -0
- package/src/decision-validation.mjs +41 -0
- package/src/declared-plan.mjs +125 -0
- package/src/executable-lookup.mjs +18 -0
- package/src/glob.mjs +9 -0
- package/src/history-worker.mjs +18 -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 +263 -0
- package/src/npm-typecheck-probe.mjs +120 -0
- package/src/observation.mjs +37 -0
- package/src/owned-state.mjs +32 -0
- package/src/path-identity.mjs +43 -0
- package/src/plan-worker.mjs +131 -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 +52 -0
- package/src/probe-result.mjs +21 -0
- package/src/process-liveness.mjs +11 -0
- package/src/run-lock.mjs +77 -0
- package/src/state-store.mjs +37 -0
- package/src/toolchain-probe.mjs +21 -0
- package/src/typescript-contract-probe.mjs +188 -0
- package/src/typescript-list.mjs +68 -0
- package/src/typescript-qualification.mjs +96 -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/daemon.mjs
ADDED
|
@@ -0,0 +1,989 @@
|
|
|
1
|
+
import {inflateSync} from 'node:zlib';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import {randomUUID} from 'node:crypto';
|
|
6
|
+
import {spawnSync} from 'node:child_process';
|
|
7
|
+
import {Worker} from 'node:worker_threads';
|
|
8
|
+
import {fileURLToPath} from 'node:url';
|
|
9
|
+
import {subscribe,hasHistory,backend} from './platform-observation.mjs';
|
|
10
|
+
import {controlEndpoint} from './control-endpoint.mjs';
|
|
11
|
+
import {observedRelative,withinPath,realObservedPath} from './path-identity.mjs';
|
|
12
|
+
import micromatch from './glob.mjs';
|
|
13
|
+
import {sha} from './plan.mjs';
|
|
14
|
+
import {InputIndex} from './index.mjs';
|
|
15
|
+
import {permittedRoots} from './installed-inputs.mjs';
|
|
16
|
+
import {assertOwnedStatePlacement} from './owned-state.mjs';
|
|
17
|
+
import {atomicJson,readReceipts} from './state-store.mjs';
|
|
18
|
+
import {history} from './observation.mjs';
|
|
19
|
+
import {inputKey} from './decision-validation.mjs';
|
|
20
|
+
import {timing} from './decision-profile.mjs';
|
|
21
|
+
|
|
22
|
+
const configFile=path.resolve(process.argv[2]);
|
|
23
|
+
const config=JSON.parse(fs.readFileSync(configFile));
|
|
24
|
+
const root=realObservedPath(config.root), state=path.resolve(config.state);
|
|
25
|
+
assertOwnedStatePlacement(config);
|
|
26
|
+
let rootIdentity=fs.statSync(root,{bigint:true});
|
|
27
|
+
if(withinPath(state,root))throw Error('state must be outside checkout');
|
|
28
|
+
fs.mkdirSync(state,{recursive:true,mode:0o700});
|
|
29
|
+
const endpoint=controlEndpoint(state),socket=endpoint.address,
|
|
30
|
+
lock=path.join(state,'observer.lock');
|
|
31
|
+
fs.mkdirSync(lock); // Existing lock requires operator inspection; never steal it.
|
|
32
|
+
fs.writeFileSync(path.join(lock,'pid'),String(process.pid),{mode:0o600});
|
|
33
|
+
const generation=randomUUID(), startedAt=Date.now();
|
|
34
|
+
let index, receipts={}, watcher, healthy=false, reason='initial reconciliation', stopping=false;
|
|
35
|
+
let receiptStateError=null;
|
|
36
|
+
const external=new Map();
|
|
37
|
+
for(const check of config.checks)for(const boundary of permittedRoots(root,check.allowedExternalRoots||[]))
|
|
38
|
+
if(!external.has(boundary))external.set(boundary,{root:boundary,watcher:null,index:null,
|
|
39
|
+
healthy:false,gap:false,reason:'external observation initializing',pending:new Set(),
|
|
40
|
+
recoveryDelay:1000,
|
|
41
|
+
snapshot:path.join(state,`external-${sha(boundary).slice(0,16)}.snapshot`)});
|
|
42
|
+
let pending=new Set(), pendingChecks=new Set(), flushTimer, planDirty=false, planning=false, lastObservation=0;
|
|
43
|
+
let decisionRequests=0;
|
|
44
|
+
let decisionSequence=0;
|
|
45
|
+
let inputEventSerial=new Map();
|
|
46
|
+
let diagnosticInputEvents=0;
|
|
47
|
+
let synchronizing=null;
|
|
48
|
+
let installedRecheck=null;
|
|
49
|
+
let historyDisabled=!hasHistory,recoveryTimer=null,recovering=false,recoveryAttempts=0;
|
|
50
|
+
let identityChanged=false;
|
|
51
|
+
let identityGapKey=null;
|
|
52
|
+
let triggerHashes=new Map();
|
|
53
|
+
let candidateDescendants=new Map();
|
|
54
|
+
let workspacePatterns=(()=>{const w=JSON.parse(fs.readFileSync(path.join(root,'package.json'))).workspaces;
|
|
55
|
+
return Array.isArray(w)?w:w?.packages||[];})();
|
|
56
|
+
let planningWorker=null;
|
|
57
|
+
let validatedPlan=null;
|
|
58
|
+
let gapDuringPlan=false;
|
|
59
|
+
const snapshotPath=path.join(state,'fs-events.snapshot');
|
|
60
|
+
const metrics={notifications:0,flushes:0,reconciliations:0,planRebuilds:0,gaps:0,
|
|
61
|
+
maxQueue:0,historyQueries:0,historyTimeouts:0,startupAt:startedAt};
|
|
62
|
+
const historyDeadlineMs=Math.max(100,Number(process.env.VSTATE_HISTORY_TIMEOUT_MS||5000));
|
|
63
|
+
const reconciliationDeadlineMs=Math.max(1000,
|
|
64
|
+
Number(process.env.VSTATE_RECONCILE_TIMEOUT_MS||120000));
|
|
65
|
+
async function queryHistory(observedRoot,snapshot,phase) {
|
|
66
|
+
try{const changes=await history(observedRoot,snapshot,backend,historyDeadlineMs,
|
|
67
|
+
process.env.VSTATE_TEST_HISTORY_FAULT_FILE);
|
|
68
|
+
metrics.historyQueries++;event('history_query_completed',{phase,events:changes.length});
|
|
69
|
+
return changes;}
|
|
70
|
+
catch(e){if(e.code==='ETIMEDOUT'){metrics.historyTimeouts++;
|
|
71
|
+
event('history_timeout',{phase,deadline_ms:historyDeadlineMs});}
|
|
72
|
+
throw e;}
|
|
73
|
+
}
|
|
74
|
+
const atomic=atomicJson;
|
|
75
|
+
function event(kind,fields={}) {
|
|
76
|
+
fs.appendFileSync(path.join(state,'events.jsonl'),JSON.stringify({at:Date.now(),kind,generation,
|
|
77
|
+
repository:root,...fields})+'\n');
|
|
78
|
+
}
|
|
79
|
+
function loadReceipts() {
|
|
80
|
+
try {receipts=readReceipts(state).checks;receiptStateError=null;}
|
|
81
|
+
catch(e){receipts={};receiptStateError=e.message;event('receipt_state_unavailable',
|
|
82
|
+
{classification:e.name||'Error'});}
|
|
83
|
+
}
|
|
84
|
+
function persistedPlan(bundle) {
|
|
85
|
+
return {schema:1,provider:bundle.provider,root:bundle.root,discoveredAt:bundle.discoveredAt,
|
|
86
|
+
workspaceCount:bundle.workspaceCount,configurationInputs:bundle.configurationInputs,
|
|
87
|
+
plans:Object.fromEntries(Object.entries(bundle.plans).map(([name,plan])=>{
|
|
88
|
+
const safe=structuredClone(plan);
|
|
89
|
+
safe.commandIdentity={argvHash:sha(JSON.stringify(plan.command)),argc:plan.command.length,
|
|
90
|
+
source:plan.executionProvenance};
|
|
91
|
+
delete safe.command;delete safe.taskCommand;
|
|
92
|
+
safe.probes=plan.probes.map(argv=>({argvHash:sha(JSON.stringify(argv)),argc:argv.length}));
|
|
93
|
+
safe.taskGraph=safe.taskGraph.map(row=>{
|
|
94
|
+
const item={...row,commandHash:sha(JSON.stringify(row.command??null)),
|
|
95
|
+
optionsHash:sha(JSON.stringify(row.options??null))};
|
|
96
|
+
delete item.command;delete item.options;return item;
|
|
97
|
+
});
|
|
98
|
+
return [name,safe];
|
|
99
|
+
}))};
|
|
100
|
+
}
|
|
101
|
+
function probe(row,allowReuse=false) {
|
|
102
|
+
const started=performance.now();
|
|
103
|
+
const supported=new Map([
|
|
104
|
+
['typescript-noemit-v1','typescript-contract-probe.mjs'],
|
|
105
|
+
['yarn-workspace-tsc-v1','yarn-workspace-typecheck-probe.mjs'],
|
|
106
|
+
['pnpm-tsc-v1','pnpm-typecheck-probe.mjs']]);
|
|
107
|
+
const expected=supported.get(row.plan.qualification);
|
|
108
|
+
const cacheable=process.platform==='win32'&&row.plan.probes.length===1&&expected&&
|
|
109
|
+
row.plan.probes[0][1]===fileURLToPath(new URL(expected,import.meta.url));
|
|
110
|
+
const key=inputKey(row);
|
|
111
|
+
if(cacheable&&allowReuse&&row.probeCache?.input===key){
|
|
112
|
+
const argv=row.plan.probes[0];
|
|
113
|
+
const out=spawnSync(argv[0],[...argv.slice(1),'--redue-context'],{
|
|
114
|
+
cwd:row.plan.cwd,timeout:10000,maxBuffer:1024*1024,stdio:['ignore','pipe','pipe']});
|
|
115
|
+
if(!out.error&&!out.signal&&out.status===0&&
|
|
116
|
+
out.stdout.toString()===row.probeCache.context){
|
|
117
|
+
row.probeHash=row.probeCache.hash;row.probeAt=Date.now();row.probeError=null;
|
|
118
|
+
metrics.probeReuses=(metrics.probeReuses||0)+1;
|
|
119
|
+
timing('daemon.probe_context_validation',started,{outcome:'reused',reason:'content_and_context_unchanged'});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
timing('daemon.probe_context_validation',started,{outcome:'changed_or_unavailable'});
|
|
123
|
+
}
|
|
124
|
+
timing('daemon.probe_reason',started,{reason:!cacheable?'generic_probe':
|
|
125
|
+
!row.probeCache?'no_validated_result':row.probeCache.input!==key?'inputs_changed':'context_changed'});
|
|
126
|
+
const values=[];let checkpoint=null;
|
|
127
|
+
for(const argv of row.plan.probes) {
|
|
128
|
+
// Supported TypeScript probes perform bounded input/toolchain hashing.
|
|
129
|
+
// Native NTFS runs have exceeded the generic 5 s cap under load, despite
|
|
130
|
+
// completing normally and with the duplicated work already removed.
|
|
131
|
+
const timeout=row.plan.qualification==='pnpm-tsc-v1'||
|
|
132
|
+
(process.platform==='win32'&&['typescript-noemit-v1','yarn-workspace-tsc-v1']
|
|
133
|
+
.includes(row.plan.qualification))?10000:5000;
|
|
134
|
+
const out=spawnSync(argv[0],[...argv.slice(1),...(cacheable?['--redue-checkpoint']:[])],{cwd:row.plan.cwd,
|
|
135
|
+
timeout,maxBuffer:1024*1024,stdio:['ignore','pipe','pipe']});
|
|
136
|
+
if(out.error||out.signal||out.status!==0) {
|
|
137
|
+
if(!row.probeError){row.revision++;row.lastReason='declared state probe became unavailable';}
|
|
138
|
+
row.probeHash=null;row.probeAt=0;
|
|
139
|
+
const code=['typescript-noemit-v1','yarn-workspace-tsc-v1','pnpm-tsc-v1']
|
|
140
|
+
.includes(row.plan.qualification)?
|
|
141
|
+
/VSTATE_REASON:([a-z-]+)/.exec(out.stderr?.toString()||'')?.[1]:null;
|
|
142
|
+
const explanations={
|
|
143
|
+
'typecheck-script-changed':'typecheck script or lifecycle changed; rerun init or review it',
|
|
144
|
+
'execution-environment-unsupported':'Node/npm preload or configuration environment is unsupported',
|
|
145
|
+
'typescript-executable-changed':'local TypeScript executable changed or is missing',
|
|
146
|
+
'typescript-config-unavailable':'TypeScript configuration cannot be resolved',
|
|
147
|
+
'typescript-config-graph-unsupported':'TypeScript configuration extends, references, or uses compiler features outside this contract',
|
|
148
|
+
'typescript-incremental-unsupported':'TypeScript incremental or composite compilation needs explicit review',
|
|
149
|
+
'typescript-config-outside-repository':'TypeScript configuration extends outside the observed repository',
|
|
150
|
+
'typescript-plugin-unsupported':'TypeScript compiler plugin needs explicit review',
|
|
151
|
+
'typescript-input-list-unavailable':'TypeScript input listing did not complete',
|
|
152
|
+
'typescript-input-outside-repository':'TypeScript consumes files outside the observed repository',
|
|
153
|
+
'npm-installation-layout-unsupported':'npm executable or installation layout is unsupported',
|
|
154
|
+
'npm-installation-unobservable':'npm installation contains unobservable entries',
|
|
155
|
+
'npm-configuration-unsupported':'npm script configuration needs review',
|
|
156
|
+
'typescript-contract-unavailable':'TypeScript applicability probe could not establish inputs',
|
|
157
|
+
'workspace-execution-environment-unsupported':'Yarn/Node launcher environment override needs review',
|
|
158
|
+
'yarn-toolchain-unavailable':'pinned Yarn version or executable is unavailable',
|
|
159
|
+
'yarn-distribution-unobservable':'Corepack Yarn distribution cannot be inspected',
|
|
160
|
+
'typescript-input-outside-workspace-contract':'TypeScript reads outside the selected workspace, declared dependency outputs, or installed roots',
|
|
161
|
+
'workspace-typecheck-contract-unavailable':'workspace TypeScript applicability probe could not establish inputs'
|
|
162
|
+
,'pnpm-execution-environment-unsupported':'pnpm or Node launcher environment override needs review'
|
|
163
|
+
,'pnpm-distribution-unobservable':'pnpm executable contents cannot be observed'
|
|
164
|
+
,'pnpm-version-mismatch':'pnpm executable differs from the pinned version'
|
|
165
|
+
,'typescript-input-outside-pnpm-contract':'TypeScript reads outside the selected workspace, linked outputs, or installed inputs'
|
|
166
|
+
,'pnpm-input-coverage-unavailable':'pnpm installed-input coverage became unavailable; inspect check details'
|
|
167
|
+
,'pnpm-typecheck-contract-unavailable':'pnpm TypeScript input contract could not be established'
|
|
168
|
+
};
|
|
169
|
+
row.probeError=code?explanations[code]||`TypeScript applicability unavailable (${code})`:
|
|
170
|
+
`probe unavailable: ${argv[0]} (${out.error?.code||out.signal||out.status})`;
|
|
171
|
+
timing('daemon.probe',started,{outcome:out.error?.code||out.signal||out.status||'error',
|
|
172
|
+
commands:values.length+1});return;
|
|
173
|
+
}
|
|
174
|
+
if(cacheable){
|
|
175
|
+
try{checkpoint=JSON.parse(out.stdout);
|
|
176
|
+
checkpoint.queries=checkpoint.queryData?JSON.parse(inflateSync(
|
|
177
|
+
Buffer.from(checkpoint.queryData,'base64'),{maxOutputLength:8*1024*1024})):null;
|
|
178
|
+
if(!/^[a-f0-9]{64}$/.test(checkpoint.output)||!/^[a-f0-9]{64}$/.test(checkpoint.context))
|
|
179
|
+
throw Error('invalid supported probe checkpoint');}
|
|
180
|
+
catch{row.probeHash=null;row.probeAt=0;row.probeError='probe checkpoint unavailable';return;}
|
|
181
|
+
}
|
|
182
|
+
values.push([argv,out.status,sha(cacheable?checkpoint.output:out.stdout),sha(out.stderr)]);
|
|
183
|
+
}
|
|
184
|
+
const value=sha(JSON.stringify(values));
|
|
185
|
+
if((row.probeHash!==null&&row.probeHash!==value)||row.probeError){
|
|
186
|
+
row.revision++;row.lastReason='declared state probe changed or recovered';}
|
|
187
|
+
row.probeHash=value;row.probeAt=Date.now();row.probeError=null;
|
|
188
|
+
if(checkpoint)row.probeCache=checkpoint.queries?{input:key,context:checkpoint.context,
|
|
189
|
+
hash:value,queries:checkpoint.queries}:null;
|
|
190
|
+
timing('daemon.probe',started,{outcome:'ok',commands:values.length,
|
|
191
|
+
reusable:row.probeCache?1:0,queries:row.probeCache?.queries.length||0});
|
|
192
|
+
}
|
|
193
|
+
function applicableExternal(name) {return [...external.values()].filter(entry=>entry.index?.checks.has(name));}
|
|
194
|
+
function combinedSummary(name) {
|
|
195
|
+
const base=index.summary(name),others=applicableExternal(name);
|
|
196
|
+
return {...base,fingerprint:sha(JSON.stringify([base.fingerprint,
|
|
197
|
+
...others.map(entry=>[entry.root,entry.index.fingerprint(name)])])),
|
|
198
|
+
revision:base.revision+others.reduce((sum,entry)=>sum+entry.index.summary(name).revision,0),
|
|
199
|
+
files:base.files+others.reduce((sum,entry)=>sum+entry.index.summary(name).files,0)};
|
|
200
|
+
}
|
|
201
|
+
function combinedFiles(name) {
|
|
202
|
+
const files=index.exportFiles(name);
|
|
203
|
+
for(const entry of applicableExternal(name))for(const [rel,hash] of
|
|
204
|
+
Object.entries(entry.index.exportFiles(name)))files[`external:${entry.root}:${rel}`]=hash;
|
|
205
|
+
return files;
|
|
206
|
+
}
|
|
207
|
+
function describeChangedInput(plan,file) {
|
|
208
|
+
const boundary=[...external.keys()].find(value=>file.startsWith(`external:${value}:`));
|
|
209
|
+
const physical=boundary?path.join(boundary,file.slice(`external:${boundary}:`.length)):
|
|
210
|
+
path.join(root,file);
|
|
211
|
+
const matches=[...(plan.installedInstances||[]),...(plan.installedMappings||[])]
|
|
212
|
+
.filter(mapping=>!mapping.logical?.startsWith('!')&&
|
|
213
|
+
(physical===mapping.physical||physical.startsWith(mapping.physical+path.sep)))
|
|
214
|
+
.map(mapping=>mapping.logical).slice(0,2);
|
|
215
|
+
return file+' changed'+(matches.length?` (installed via ${matches.join(', ')})`:'');
|
|
216
|
+
}
|
|
217
|
+
function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=false) {
|
|
218
|
+
if(!index)return [];
|
|
219
|
+
return [...index.checks].map(([name,row])=>{
|
|
220
|
+
const r=receipts[name], result=r?.result||null;
|
|
221
|
+
const item={name,result,freshness:'UNVERIFIED',reason:'no receipt',changed_inputs:[],
|
|
222
|
+
plan_id:row.plan.id,observed_at:lastObservation,
|
|
223
|
+
invocation:r?.invocation||null,target_provenance:r?.target||null,
|
|
224
|
+
coverage_at_run:r?.coverage||null,declared_inputs_match:null,reuse_eligible:false};
|
|
225
|
+
if(receiptStateError){item.reason=receiptStateError;return item;}
|
|
226
|
+
if(!healthy||planning||recovering||index.unavailable){item.reason=
|
|
227
|
+
(planning||recovering)?'input observation reconciliation pending':
|
|
228
|
+
reason||index.unavailable||'observation unavailable';return item;}
|
|
229
|
+
const outside=applicableExternal(name).find(entry=>!entry.healthy||entry.index.unavailable);
|
|
230
|
+
if(outside){item.reason=outside.reason||`external observation unavailable: ${outside.index.unavailable}`;
|
|
231
|
+
return item;}
|
|
232
|
+
if(pendingChecks.has(name)){item.reason='filesystem change pending content inspection';return item;}
|
|
233
|
+
if(r&&r.planId) item.declared_inputs_match=r.planId===row.plan.id&&
|
|
234
|
+
r.fingerprint===combinedSummary(name).fingerprint;
|
|
235
|
+
if(r&&r.fingerprintVersion!==1){item.reason='receipt uses earlier input-resolution format';return item;}
|
|
236
|
+
if(row.plan.unresolved.length){
|
|
237
|
+
const checkpoint=item.declared_inputs_match===false?
|
|
238
|
+
r.planId!==row.plan.id?'; observed input plan changed':
|
|
239
|
+
`; declared input checkpoint changed (${row.lastReason||'content or membership'})`:'';
|
|
240
|
+
const probeIssue=row.probeError?`; state probe unavailable: ${row.probeError}`:'';
|
|
241
|
+
item.reason='input coverage unresolved: '+row.plan.unresolved.join('; ')+checkpoint+probeIssue;
|
|
242
|
+
return item;
|
|
243
|
+
}
|
|
244
|
+
if(row.probeError||!row.probeAt||Date.now()-row.probeAt>10000){item.reason=row.probeError||'state probe observation expired';return item;}
|
|
245
|
+
if(row.plan.synchronizedInstalledRead&&!synchronizedInstalled){
|
|
246
|
+
item.reason='installed inputs require a synchronized content read';return item;}
|
|
247
|
+
if(process.platform==='win32'&&!synchronizedFilesystem){
|
|
248
|
+
item.reason='Windows cached observation requires a synchronized content read';return item;}
|
|
249
|
+
if(!r)return item;
|
|
250
|
+
if(!r.coverage?.qualified){item.reason='coverage unresolved when execution was recorded';return item;}
|
|
251
|
+
if(r.invocation?.status!=='exited'){
|
|
252
|
+
item.reason=`invocation ${r.invocation?.status||'capture unavailable'}`;return item;}
|
|
253
|
+
if(!['executed','failed','cache_hit','direct_executed'].includes(r.target?.status)){
|
|
254
|
+
item.reason=`target execution ${r.target?.status||'unavailable'}`;return item;}
|
|
255
|
+
if(r.target.status==='failed'&&result!=='FAIL'){
|
|
256
|
+
item.reason='target failure conflicts with outer invocation outcome';return item;}
|
|
257
|
+
if(!r.stable){item.reason='inputs changed during execution or observation gap';return item;}
|
|
258
|
+
const names=[...(row.plan.environment.variables||[]),
|
|
259
|
+
...(row.plan.environment.prefixes||[]).map(prefix=>
|
|
260
|
+
`__vstate_env_prefix_${prefix.toLowerCase()}__`),
|
|
261
|
+
...(row.plan.environment.pathExecutables||[]).map(name=>
|
|
262
|
+
`__vstate_path_executable_${name}__`),
|
|
263
|
+
...(row.plan.environment.executableIdentity?['__vstate_node_identity__']:[])];
|
|
264
|
+
if(names.length&&!contextHashes?.[name]){
|
|
265
|
+
item.reason='execution environment context not supplied';return item;}
|
|
266
|
+
item.freshness='STALE';
|
|
267
|
+
if(r.planId!==row.plan.id){
|
|
268
|
+
const before=new Map((r.resolutionLinks||[]).map(link=>[link.path,link.targetHash]));
|
|
269
|
+
const changed=(row.plan.resolutionLinks||[]).find(link=>before.get(link.path)!==link.targetHash);
|
|
270
|
+
item.reason=changed?`installed input link changed: ${changed.path}`:'input plan changed';
|
|
271
|
+
if(changed)item.changed_inputs=[changed.path];
|
|
272
|
+
}
|
|
273
|
+
else if(names.some(key=>contextHashes[name]?.[key]!==r.environmentHashes?.[key]))
|
|
274
|
+
item.reason='declared execution environment changed';
|
|
275
|
+
else if(r.fingerprint!==combinedSummary(name).fingerprint) {
|
|
276
|
+
const key=r.fingerprint+'\0'+combinedSummary(name).fingerprint;
|
|
277
|
+
if(row.staleReasonKey!==key){
|
|
278
|
+
let changed=row.lastReason;row.staleInputs=[];
|
|
279
|
+
const candidate=changed?.endsWith(' changed')?changed.slice(0,-8):null;
|
|
280
|
+
if(candidate&&r.files?.[candidate]!==index.files.get(candidate)){
|
|
281
|
+
changed=describeChangedInput(row.plan,candidate);row.staleInputs=[candidate];}
|
|
282
|
+
else {
|
|
283
|
+
const current=combinedFiles(name);
|
|
284
|
+
if(r.files)for(const f of new Set([...Object.keys(r.files),...Object.keys(current)]))
|
|
285
|
+
if(r.files[f]!==current[f]){changed=describeChangedInput(row.plan,f);row.staleInputs=[f];break;}
|
|
286
|
+
}
|
|
287
|
+
row.staleReason=changed||
|
|
288
|
+
(['typescript-noemit-v1','yarn-workspace-tsc-v1','pnpm-tsc-v1']
|
|
289
|
+
.includes(row.plan.qualification)?
|
|
290
|
+
'TypeScript input set, generated file, or toolchain context changed':
|
|
291
|
+
'declared input content or membership changed');
|
|
292
|
+
row.staleReasonKey=key;
|
|
293
|
+
}
|
|
294
|
+
item.reason=row.staleReason;item.changed_inputs=row.staleInputs||[];
|
|
295
|
+
} else {item.freshness='CURRENT';item.reason=result==='FAIL'?'last command failed':'declared inputs match';
|
|
296
|
+
item.reuse_eligible=result==='PASS';}
|
|
297
|
+
return item;
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
function status(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=false) {
|
|
301
|
+
const checks=rows(contextHashes,synchronizedInstalled,synchronizedFilesystem), current=checks.filter(r=>r.freshness==='CURRENT'&&r.result==='PASS').length,
|
|
302
|
+
failed=checks.filter(r=>r.freshness==='CURRENT'&&r.result==='FAIL').length,
|
|
303
|
+
observedFailed=checks.filter(r=>r.result==='FAIL').length,
|
|
304
|
+
stale=checks.filter(r=>r.freshness==='STALE').length,
|
|
305
|
+
unverified=checks.filter(r=>r.freshness==='UNVERIFIED').length||(!checks.length?1:0);
|
|
306
|
+
const stateName=failed?'failed':unverified?'unverified':stale?'stale':'current';
|
|
307
|
+
const now=Date.now();return {schema:1,state:stateName,current,failed,observed_failed:observedFailed,
|
|
308
|
+
stale,unverified,checks,
|
|
309
|
+
observation:{healthy:healthy&&!planning&&!recovering&&!index?.unavailable,
|
|
310
|
+
generation,observed_at:lastObservation,
|
|
311
|
+
events:(index?.eventCount||0)+[...external.values()].reduce((n,e)=>n+(e.index?.eventCount||0),0),
|
|
312
|
+
pending:pending.size+[...external.values()].reduce((n,e)=>n+e.pending.size,0),
|
|
313
|
+
reason:healthy?index?.unavailable:reason,
|
|
314
|
+
external:[...external.values()].filter(e=>e.index).map(e=>({root:e.root,
|
|
315
|
+
healthy:e.healthy,reason:e.reason})),
|
|
316
|
+
phase:recovering?'reconciling':metrics.planPhase||'initializing',scanned:metrics.planScannedFiles||0,
|
|
317
|
+
total:metrics.planTotalFiles},
|
|
318
|
+
updated_at:now,expires_at:now+3000,pid:process.pid};
|
|
319
|
+
}
|
|
320
|
+
function publish() {atomic(path.join(state,'status.json'),status());}
|
|
321
|
+
function hydrate(bundle,saved) {
|
|
322
|
+
const next=new InputIndex(bundle,{trackGit:bundle.root===root,
|
|
323
|
+
includeInstalled:bundle.root!==root});
|
|
324
|
+
next.files=new Map(saved.files);
|
|
325
|
+
for(const [alias,refs] of saved.aliasDependencies||[]){
|
|
326
|
+
next.aliasDependencies.set(alias,new Set(refs));
|
|
327
|
+
for(const reference of refs){
|
|
328
|
+
if(!next.symlinkRefs.has(reference))next.symlinkRefs.set(reference,new Set());
|
|
329
|
+
next.symlinkRefs.get(reference).add(alias);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
for(const rowData of saved.rows) {
|
|
333
|
+
const row=next.checks.get(rowData.name);
|
|
334
|
+
row.files=new Set(rowData.files);row.sum=Buffer.from(rowData.sum,'hex');
|
|
335
|
+
row.revision=rowData.revision;row.lastReason=rowData.lastReason;
|
|
336
|
+
for(const rel of row.files) {
|
|
337
|
+
if(!next.members.has(rel))next.members.set(rel,new Set());
|
|
338
|
+
next.members.get(rel).add(rowData.name);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
next.eventCount=saved.eventCount;next.rehashedFiles=saved.rehashedFiles;
|
|
342
|
+
next.rehashedBytes=saved.rehashedBytes;next.profile=saved.profile;
|
|
343
|
+
return next;
|
|
344
|
+
}
|
|
345
|
+
function dehydrate(value) {
|
|
346
|
+
return {files:[...value.files],
|
|
347
|
+
aliasDependencies:[...value.aliasDependencies].map(([name,refs])=>[name,[...refs]]),
|
|
348
|
+
rows:[...value.checks].map(([name,row])=>({name,files:[...row.files],
|
|
349
|
+
sum:row.sum.toString('hex'),revision:row.revision,lastReason:row.lastReason})),
|
|
350
|
+
eventCount:value.eventCount,rehashedFiles:value.rehashedFiles,
|
|
351
|
+
rehashedBytes:value.rehashedBytes,profile:value.profile};
|
|
352
|
+
}
|
|
353
|
+
function retainIndex() {
|
|
354
|
+
if(!index||!healthy||planning||pending.size||planDirty||historyDisabled||
|
|
355
|
+
!fs.existsSync(snapshotPath))return;
|
|
356
|
+
const outside={};
|
|
357
|
+
for(const entry of external.values()){
|
|
358
|
+
if(!entry.index||!entry.healthy||entry.pending.size||!fs.existsSync(entry.snapshot))return;
|
|
359
|
+
outside[entry.root]={cursor:sha(fs.readFileSync(entry.snapshot)),
|
|
360
|
+
index:dehydrate(entry.index)};
|
|
361
|
+
}
|
|
362
|
+
const current=fs.statSync(root,{bigint:true});
|
|
363
|
+
atomic(path.join(state,'index-v1.json'),{schema:1,root,
|
|
364
|
+
identity:[String(current.dev),String(current.ino)],
|
|
365
|
+
planIds:Object.fromEntries([...index.checks].map(([name,row])=>[name,row.plan.id])),
|
|
366
|
+
cursor:sha(fs.readFileSync(snapshotPath)),external:outside,index:dehydrate(index)});
|
|
367
|
+
}
|
|
368
|
+
function buildInWorker(forceCold=false,reuse=null) {
|
|
369
|
+
return new Promise((resolve,reject)=>{
|
|
370
|
+
const workerStarted=performance.now();
|
|
371
|
+
const file=path.join(path.dirname(fileURLToPath(import.meta.url)),'plan-worker.mjs');
|
|
372
|
+
const worker=new Worker(file,{workerData:{config:configFile,state,forceCold,reuse,
|
|
373
|
+
reconcileFaultFile:process.env.VSTATE_TEST_RECONCILE_FAULT_FILE}});
|
|
374
|
+
planningWorker=worker;let settled=false;
|
|
375
|
+
const deadline=setTimeout(()=>{
|
|
376
|
+
if(settled)return;settled=true;planningWorker=null;
|
|
377
|
+
worker.terminate().catch(()=>{});
|
|
378
|
+
reject(Error(`deterministic reconciliation exceeded ${reconciliationDeadlineMs} ms`));
|
|
379
|
+
},reconciliationDeadlineMs);
|
|
380
|
+
worker.on('message',value=>{
|
|
381
|
+
if(value.kind==='progress') {
|
|
382
|
+
if(stopping||identityChanged||gapDuringPlan)return;
|
|
383
|
+
metrics.planPhase=value.phase;metrics.planScannedFiles=value.scanned;
|
|
384
|
+
metrics.planTotalFiles=value.total;
|
|
385
|
+
if(value.discoveryMs!=null)metrics.lastDiscoveryMs=value.discoveryMs;
|
|
386
|
+
reason='input plan indexing';publish();return;
|
|
387
|
+
}
|
|
388
|
+
if(settled)return;settled=true;clearTimeout(deadline);planningWorker=null;
|
|
389
|
+
if(value.ok){value.workerRoundTripMs=performance.now()-workerStarted;
|
|
390
|
+
resolve(value);}else reject(Error(value.error));});
|
|
391
|
+
worker.once('error',error=>{if(!settled){settled=true;clearTimeout(deadline);
|
|
392
|
+
planningWorker=null;reject(error);}});
|
|
393
|
+
worker.once('exit',code=>{if(!settled){settled=true;clearTimeout(deadline);planningWorker=null;
|
|
394
|
+
reject(Error(`input-plan worker exited ${code}`));}});
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
async function refreshPlan({forceCold=false,reuse=false}={}) {
|
|
398
|
+
if(planning||stopping)return;
|
|
399
|
+
const totalStarted=performance.now();
|
|
400
|
+
const previousProbes=new Map([...(index?.checks||[])].map(([name,row])=>[name,row.probeCache]));
|
|
401
|
+
const reusable=reuse&&healthy&&!planDirty&&!external.size&&validatedPlan&&
|
|
402
|
+
[...index.checks.values()].every(row=>!row.plan.qualification||row.probeCache)?
|
|
403
|
+
{...validatedPlan,bundle:index.bundle,
|
|
404
|
+
queries:[...index.checks.values()].flatMap(row=>row.probeCache?.queries||[])}:null;
|
|
405
|
+
planning=true;planDirty=false;gapDuringPlan=false;pending.clear();pendingChecks.clear();
|
|
406
|
+
for(const entry of external.values())entry.gap=!entry.watcher;
|
|
407
|
+
metrics.planPhase='discovery';metrics.planScannedFiles=0;metrics.planTotalFiles=null;
|
|
408
|
+
healthy=false;reason='input plan rebuilding';publish();
|
|
409
|
+
try {const workerStarted=performance.now();
|
|
410
|
+
const built=await buildInWorker(forceCold,reusable);
|
|
411
|
+
timing('daemon.plan_worker',workerStarted,{forceCold:forceCold?1:0,
|
|
412
|
+
discoveryMs:Math.round(built.discoveryMs),indexingMs:Math.round(built.indexingMs),
|
|
413
|
+
mode:built.restartMode});
|
|
414
|
+
if(stopping)return;
|
|
415
|
+
if(!verifyRoot()) {planning=false;metrics.planPhase='unavailable';publish();return;}
|
|
416
|
+
if(gapDuringPlan){planning=false;observationGap('plan_gap');return;}
|
|
417
|
+
const {bundle}=built;
|
|
418
|
+
workspacePatterns=bundle.workspacePatterns||workspacePatterns;
|
|
419
|
+
const hydrationStarted=performance.now();
|
|
420
|
+
index=hydrate(bundle,built.index);
|
|
421
|
+
validatedPlan=built.guard?{guard:built.guard,inputs:built.inputKeys}:null;
|
|
422
|
+
if(built.planReused)for(const [name,row] of index.checks)
|
|
423
|
+
row.probeCache=previousProbes.get(name);
|
|
424
|
+
inputEventSerial=new Map([...index.checks.keys()].map(name=>
|
|
425
|
+
[name,inputEventSerial.get(name)||0]));
|
|
426
|
+
for(const entry of external.values()) {
|
|
427
|
+
const data=built.external?.[entry.root];
|
|
428
|
+
entry.index=data?hydrate({root:entry.root,plans:Object.fromEntries(
|
|
429
|
+
Object.entries(bundle.plans).filter(([,plan])=>(plan.externalObservationRoots||
|
|
430
|
+
Object.keys(plan.externalPatterns||{})).includes(entry.root))
|
|
431
|
+
.map(([name,plan])=>[name,{...plan,patterns:plan.externalPatterns[entry.root],
|
|
432
|
+
sourcePatterns:[],
|
|
433
|
+
additivePatterns:plan.externalPatterns[entry.root]||[],
|
|
434
|
+
installedPhysicalRoots:[]}]))},data):null;
|
|
435
|
+
entry.healthy=false;entry.reason='external reconciliation pending';
|
|
436
|
+
}
|
|
437
|
+
metrics.lastHydrationMs=performance.now()-hydrationStarted;
|
|
438
|
+
metrics.lastWorkerRoundTripMs=built.workerRoundTripMs;
|
|
439
|
+
metrics.lastDiscoveryMs=built.discoveryMs;metrics.lastIndexingMs=built.indexingMs;
|
|
440
|
+
metrics.restartMode=built.restartMode||'cold-index';
|
|
441
|
+
metrics.planPhase='reconciling';
|
|
442
|
+
if(!built.planReused)metrics.planRebuilds++;
|
|
443
|
+
metrics.reconciliations++;
|
|
444
|
+
// Linux attaches per-inode watches before rechecking installed hardlinks.
|
|
445
|
+
// The Windows backend has no per-inode watchFiles implementation; the
|
|
446
|
+
// just-completed cold index already read those bytes. Windows still
|
|
447
|
+
// withholds cached CURRENT and reconciles at each decision-grade read.
|
|
448
|
+
if(!hasHistory&&watcher&&process.platform!=='win32'){
|
|
449
|
+
const inodeStarted=performance.now();
|
|
450
|
+
const checked=[...index.checks].filter(([,row])=>
|
|
451
|
+
row.plan.synchronizedInstalledRead).map(([name])=>name);
|
|
452
|
+
const selected=new Set(checked);
|
|
453
|
+
const files=[...index.files.keys()].filter(rel=>index.installedPath(rel)&&
|
|
454
|
+
[...index.members.get(rel)||[]].some(name=>selected.has(name)))
|
|
455
|
+
.map(rel=>path.join(root,rel));
|
|
456
|
+
await watcher.watchFiles(files);
|
|
457
|
+
metrics.installedInodeWatches=files.length;
|
|
458
|
+
if(checked.length)metrics.initialInstalledRecheck=await index.recheckInstalled(checked);
|
|
459
|
+
timing('daemon.installed_inode_setup',inodeStarted,{watches:files.length,
|
|
460
|
+
recheckMs:Math.round(metrics.initialInstalledRecheck?.durationMs||0)});
|
|
461
|
+
}
|
|
462
|
+
triggerHashes=new Map([...bundle.workspaceManifests,...(bundle.configurationInputs||[]),
|
|
463
|
+
...Object.values(bundle.plans).flatMap(plan=>plan.resolutionTriggers||[]),
|
|
464
|
+
'package.json','package-lock.json'].map(rel=>[rel,triggerHash(rel)]));
|
|
465
|
+
candidateDescendants=new Map();
|
|
466
|
+
for(const plan of Object.values(bundle.plans))for(const candidate of
|
|
467
|
+
plan.resolutionCandidates||[])for(const observed of
|
|
468
|
+
new Set([candidate.path,candidate.observedPath].filter(Boolean))){
|
|
469
|
+
const parts=observed.split('/');
|
|
470
|
+
for(let n=1;n<=parts.length;n++){
|
|
471
|
+
const prefix=parts.slice(0,n).join('/');
|
|
472
|
+
if(!candidateDescendants.has(prefix))candidateDescendants.set(prefix,new Set());
|
|
473
|
+
candidateDescendants.get(prefix).add(observed);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const persistenceStarted=performance.now();
|
|
477
|
+
atomic(path.join(state,'plans-v1.json'),persistedPlan(bundle));
|
|
478
|
+
metrics.lastPlanPersistenceMs=performance.now()-persistenceStarted;
|
|
479
|
+
const probeStarted=performance.now();
|
|
480
|
+
for(const row of index.checks.values())probe(row,built.planReused);
|
|
481
|
+
timing('daemon.plan_probes',probeStarted,{checks:index.checks.size});
|
|
482
|
+
lastObservation=Date.now();
|
|
483
|
+
event(built.planReused?'plan_validated':'plan_rebuilt',{checks:Object.keys(bundle.plans),workspace_count:bundle.workspaceCount});
|
|
484
|
+
const catchUpStarted=performance.now();
|
|
485
|
+
if(watcher&&(!historyDisabled||!hasHistory))try{await catchUp();}
|
|
486
|
+
catch(e){planning=false;observationGap('historical_query',e);return;}
|
|
487
|
+
metrics.lastCatchUpMs=performance.now()-catchUpStarted;
|
|
488
|
+
if(gapDuringPlan){planning=false;observationGap('plan_gap');return;}
|
|
489
|
+
if(planDirty) {
|
|
490
|
+
pending.clear();planning=false;publish();setImmediate(refreshPlan);return;
|
|
491
|
+
}
|
|
492
|
+
planning=false;
|
|
493
|
+
if(pending.size||[...external.values()].some(e=>e.pending.size))flush();
|
|
494
|
+
if(!index.unavailable&&watcher&&!reason?.startsWith('input inspection unavailable')){
|
|
495
|
+
healthy=true;reason=null;lastObservation=Date.now();}
|
|
496
|
+
for(const entry of external.values())if(entry.index&&entry.watcher&&!entry.gap&&
|
|
497
|
+
!entry.index.unavailable){
|
|
498
|
+
entry.healthy=true;entry.reason=null;}
|
|
499
|
+
if(healthy){identityChanged=false;identityGapKey=null;
|
|
500
|
+
if(!recovering)recoveryAttempts=0;}
|
|
501
|
+
metrics.planPhase=healthy?'ready':'unavailable';
|
|
502
|
+
} catch(e) {if(!stopping){reason=`plan unavailable: ${e.message}`;
|
|
503
|
+
metrics.planPhase='unavailable';event('plan_failed',{classification:e.name||'Error',reason:e.message});}}
|
|
504
|
+
planning=false;publish();
|
|
505
|
+
timing('daemon.plan_total',totalStarted,{forceCold:forceCold?1:0,
|
|
506
|
+
healthy:healthy?1:0,files:index?.files.size||0});
|
|
507
|
+
}
|
|
508
|
+
function triggerHash(rel) {
|
|
509
|
+
try{const file=path.isAbsolute(rel)?rel:path.join(root,rel),st=fs.lstatSync(file);
|
|
510
|
+
if(st.isSymbolicLink())return sha('link:'+fs.readlinkSync(file));
|
|
511
|
+
if(st.isDirectory())return sha('directory:'+String(st.dev)+':'+String(st.ino));
|
|
512
|
+
return sha(fs.readFileSync(file));}
|
|
513
|
+
catch(e){if(e.code==='ENOENT')return '<missing>';throw e;}
|
|
514
|
+
}
|
|
515
|
+
function triggerChanged(rel) {
|
|
516
|
+
try{const current=triggerHash(rel),previous=triggerHashes.get(rel);
|
|
517
|
+
if(current===previous)return false;
|
|
518
|
+
triggerHashes.set(rel,current);return true;
|
|
519
|
+
}catch(e){observationGap('plan_trigger_unreadable',e);return true;}
|
|
520
|
+
}
|
|
521
|
+
function workspaceManifest(rel) {
|
|
522
|
+
return rel.endsWith('/package.json')&&
|
|
523
|
+
micromatch.isMatch(path.posix.dirname(rel),workspacePatterns,{dot:true});
|
|
524
|
+
}
|
|
525
|
+
function planTrigger(rel) {
|
|
526
|
+
return triggerHashes.has(rel)||rel==='package.json'||
|
|
527
|
+
rel==='package-lock.json'||workspaceManifest(rel);
|
|
528
|
+
}
|
|
529
|
+
function candidateTriggerChanged(rel) {
|
|
530
|
+
let changed=false;
|
|
531
|
+
const descendants=candidateDescendants.get(rel);
|
|
532
|
+
// A broad directory replacement can affect thousands of candidates. Fail
|
|
533
|
+
// closed and rebuild instead of blocking the control loop on that event.
|
|
534
|
+
if(descendants?.size>512)return true;
|
|
535
|
+
for(const candidate of descendants||[])
|
|
536
|
+
if(triggerChanged(candidate))changed=true;
|
|
537
|
+
const parts=rel.split('/');
|
|
538
|
+
for(let n=1;n<parts.length;n++){
|
|
539
|
+
const candidate=parts.slice(0,n).join('/');
|
|
540
|
+
if(triggerHashes.has(candidate)&&candidateDescendants.has(candidate)&&
|
|
541
|
+
triggerChanged(candidate))changed=true;
|
|
542
|
+
}
|
|
543
|
+
return changed;
|
|
544
|
+
}
|
|
545
|
+
function flush() {
|
|
546
|
+
clearTimeout(flushTimer);flushTimer=null;
|
|
547
|
+
if(planning)return;
|
|
548
|
+
if(!index||planDirty){pending.clear();for(const e of external.values())e.pending.clear();
|
|
549
|
+
refreshPlan();return;}
|
|
550
|
+
const wasHealthy=healthy;
|
|
551
|
+
const paths=[...pending];pending.clear();metrics.flushes++;
|
|
552
|
+
try {for(const rel of paths)index.updatePath(rel);
|
|
553
|
+
for(const entry of external.values()){
|
|
554
|
+
for(const rel of entry.pending)entry.index?.updatePath(rel);
|
|
555
|
+
entry.pending.clear();
|
|
556
|
+
if(entry.index?.unavailable){entry.healthy=false;entry.reason=entry.index.unavailable;
|
|
557
|
+
externalGap(entry,'input_inspection');}}
|
|
558
|
+
if(index.unavailable)throw Error(index.unavailable);
|
|
559
|
+
pendingChecks.clear();healthy=wasHealthy&&Boolean(watcher)&&
|
|
560
|
+
(historyDisabled||fs.existsSync(snapshotPath));
|
|
561
|
+
if(healthy)reason=null;
|
|
562
|
+
lastObservation=Date.now();
|
|
563
|
+
} catch(e) {healthy=false;reason=`input inspection unavailable: ${e.message}`;
|
|
564
|
+
metrics.gaps++;event('observation_gap',{classification:'input_inspection'});
|
|
565
|
+
setTimeout(()=>{if(!stopping)refreshPlan();},1000);}
|
|
566
|
+
publish();
|
|
567
|
+
}
|
|
568
|
+
function pnpmTopologyChanged(type,rel) {
|
|
569
|
+
if(!config.checks.some(check=>check.qualification==='pnpm-tsc-v1')||
|
|
570
|
+
!rel.split('/').includes('node_modules'))return false;
|
|
571
|
+
if(type==='create'||type==='delete')return true;
|
|
572
|
+
try{const stat=fs.lstatSync(path.join(root,rel));
|
|
573
|
+
return stat.isDirectory()||stat.isSymbolicLink();}
|
|
574
|
+
catch(e){return e.code==='ENOENT';}
|
|
575
|
+
}
|
|
576
|
+
function markInputEvent(name,rel,type) {
|
|
577
|
+
pendingChecks.add(name);
|
|
578
|
+
inputEventSerial.set(name,(inputEventSerial.get(name)||0)+1);
|
|
579
|
+
// Disposable public CI only. Never enabled for normal/local repositories.
|
|
580
|
+
if(process.env.REDUE_PUBLIC_INPUT_TRACE==='1'&&rel&&diagnosticInputEvents++<5000)
|
|
581
|
+
event('public_input_notification',{path:rel,type:type||'unknown',planning});
|
|
582
|
+
}
|
|
583
|
+
function notification(type,filename) {
|
|
584
|
+
metrics.notifications++;
|
|
585
|
+
metrics.lastNotificationAt=Date.now();
|
|
586
|
+
if(!filename){observationGap('unnamed_event');return;}
|
|
587
|
+
const rel=filename.toString().split(path.sep).join('/');
|
|
588
|
+
if(rel==='.git'||rel.startsWith('.git/'))return;
|
|
589
|
+
if(planning) {
|
|
590
|
+
if(index)for(const name of index.candidates(rel))
|
|
591
|
+
if(index.matches(index.checks.get(name),rel))markInputEvent(name,rel,type);
|
|
592
|
+
pending.add(rel);metrics.maxQueue=Math.max(metrics.maxQueue,pending.size);
|
|
593
|
+
if(planTrigger(rel)||candidateDescendants.has(rel)||pnpmTopologyChanged(type,rel))
|
|
594
|
+
planDirty=true;
|
|
595
|
+
if(pending.size>50000)observationGap('backlog_overflow');
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
try {if(fs.statSync(path.join(root,rel)).isDirectory()&&
|
|
599
|
+
micromatch.isMatch(rel,workspacePatterns,{dot:true})&&
|
|
600
|
+
fs.existsSync(path.join(root,rel,'package.json'))&&
|
|
601
|
+
triggerChanged(rel+'/package.json'))planDirty=true;}
|
|
602
|
+
catch(e){if(e.code!=='ENOENT') {healthy=false;reason='filesystem event path unavailable';publish();return;}}
|
|
603
|
+
if(index&&[...index.checks.values()].some(row=>row.plan.patterns.includes(rel+'/package.json'))&&
|
|
604
|
+
!fs.existsSync(path.join(root,rel))&&triggerChanged(rel+'/package.json'))planDirty=true;
|
|
605
|
+
if(planTrigger(rel))
|
|
606
|
+
if(triggerChanged(rel))planDirty=true;
|
|
607
|
+
if(candidateTriggerChanged(rel))planDirty=true;
|
|
608
|
+
// A newly installed package or nearer Node candidate may change a compiler
|
|
609
|
+
// file list even when it was absent from the previous input plan. The store
|
|
610
|
+
// outside this checkout is not observed and cannot cause this refresh.
|
|
611
|
+
if(pnpmTopologyChanged(type,rel))planDirty=true;
|
|
612
|
+
pending.add(rel);metrics.maxQueue=Math.max(metrics.maxQueue,pending.size);
|
|
613
|
+
if(index) {
|
|
614
|
+
for(const name of index.candidates(rel))
|
|
615
|
+
if(index.matches(index.checks.get(name),rel))markInputEvent(name,rel,type);
|
|
616
|
+
for(const [prefix,names] of index.prefixes)
|
|
617
|
+
if(prefix.startsWith(rel+'/'))for(const name of names)markInputEvent(name,rel,type);
|
|
618
|
+
}
|
|
619
|
+
if(pending.size>50000){healthy=false;reason='observation backlog overflow';metrics.gaps++;
|
|
620
|
+
event('observation_gap',{classification:'backlog_overflow'});publish();
|
|
621
|
+
pending.clear();setTimeout(()=>refreshPlan(),100);return;}
|
|
622
|
+
clearTimeout(flushTimer);flushTimer=setTimeout(flush,60);
|
|
623
|
+
if(planDirty){healthy=false;reason='input plan possibly changed';}
|
|
624
|
+
if(planDirty||pendingChecks.size)publish();
|
|
625
|
+
}
|
|
626
|
+
function externalNotification(entry,type,filename) {
|
|
627
|
+
metrics.notifications++;
|
|
628
|
+
metrics.lastNotificationAt=Date.now();
|
|
629
|
+
if(!filename){externalGap(entry,'unnamed_event');return;}
|
|
630
|
+
const rel=filename.toString().split(path.sep).join('/');
|
|
631
|
+
const absolute=path.join(entry.root,rel);
|
|
632
|
+
if(planning&&entry.index)for(const name of entry.index.candidates(rel))
|
|
633
|
+
if(entry.index.matches(entry.index.checks.get(name),rel))markInputEvent(name,rel,type);
|
|
634
|
+
if(planTrigger(absolute)&&triggerChanged(absolute))planDirty=true;
|
|
635
|
+
if(candidateTriggerChanged(absolute))planDirty=true;
|
|
636
|
+
entry.pending.add(rel);metrics.maxQueue=Math.max(metrics.maxQueue,
|
|
637
|
+
pending.size+[...external.values()].reduce((n,e)=>n+e.pending.size,0));
|
|
638
|
+
if(entry.index) {
|
|
639
|
+
for(const name of entry.index.candidates(rel))
|
|
640
|
+
if(entry.index.matches(entry.index.checks.get(name),rel))markInputEvent(name,rel,type);
|
|
641
|
+
for(const [prefix,names] of entry.index.prefixes)
|
|
642
|
+
if(prefix.startsWith(rel+'/'))for(const name of names)markInputEvent(name,rel,type);
|
|
643
|
+
}
|
|
644
|
+
if(planDirty){healthy=false;reason='input plan possibly changed';}
|
|
645
|
+
publish();clearTimeout(flushTimer);flushTimer=setTimeout(flush,60);
|
|
646
|
+
}
|
|
647
|
+
function externalGap(entry,classification,error) {
|
|
648
|
+
entry.healthy=false;entry.gap=true;
|
|
649
|
+
entry.reason=`external observation unavailable: ${classification}`;
|
|
650
|
+
entry.watcher?.unsubscribe().catch(()=>{});entry.watcher=null;
|
|
651
|
+
metrics.gaps++;event('observation_gap',{classification,external_root:entry.root,
|
|
652
|
+
error_code:error?.code||null});publish();
|
|
653
|
+
observationGap(`external_${classification}`,error);
|
|
654
|
+
}
|
|
655
|
+
function observationGap(classification,error) {
|
|
656
|
+
if(planning)gapDuringPlan=true;
|
|
657
|
+
// Once continuity is uncertain, a cursor is no longer authority. Do not
|
|
658
|
+
// retry native history in this observer generation after reconciliation.
|
|
659
|
+
historyDisabled=true;
|
|
660
|
+
healthy=false;reason=`observation unavailable: ${classification}; deterministic reconciliation pending`;
|
|
661
|
+
metrics.gaps++;
|
|
662
|
+
event('observation_gap',{classification,error_code:error?.code||null});publish();
|
|
663
|
+
// One bounded attempt per gap; a later independent gap may try again.
|
|
664
|
+
if(!stopping&&!recovering&&!recoveryTimer&&recoveryAttempts<2)
|
|
665
|
+
recoveryTimer=setTimeout(()=>recoverObservation(classification),100);
|
|
666
|
+
}
|
|
667
|
+
async function recoverObservation(classification) {
|
|
668
|
+
recoveryTimer=null;if(stopping||recovering)return;
|
|
669
|
+
recovering=true;recoveryAttempts++;
|
|
670
|
+
event('reconciliation_started',{classification,attempt:recoveryAttempts});
|
|
671
|
+
try {
|
|
672
|
+
const waitStarted=Date.now();
|
|
673
|
+
while(planning&&!stopping&&Date.now()-waitStarted<reconciliationDeadlineMs)
|
|
674
|
+
await new Promise(resolve=>setTimeout(resolve,50));
|
|
675
|
+
if(stopping)return;
|
|
676
|
+
if(planning)throw Error('prior indexing did not settle before reconciliation deadline');
|
|
677
|
+
const current=fs.statSync(root,{bigint:true});
|
|
678
|
+
if(current.dev!==rootIdentity.dev||current.ino!==rootIdentity.ino){
|
|
679
|
+
watcher?.unsubscribe().catch(()=>{});watcher=null;
|
|
680
|
+
rootIdentity=current;identityChanged=true;
|
|
681
|
+
event('observation_root_replaced');
|
|
682
|
+
}
|
|
683
|
+
if(!watcher)await attachWatch();
|
|
684
|
+
if(!watcher)throw Error(`root subscription unavailable: ${reason}`);
|
|
685
|
+
for(const entry of external.values()){
|
|
686
|
+
const identity=fs.statSync(entry.root,{bigint:true});
|
|
687
|
+
if(entry.watcher&&(identity.dev!==entry.identity?.dev||
|
|
688
|
+
identity.ino!==entry.identity?.ino)){
|
|
689
|
+
entry.watcher.unsubscribe().catch(()=>{});entry.watcher=null;
|
|
690
|
+
event('external_observation_root_replaced',{external_root:entry.root});
|
|
691
|
+
}
|
|
692
|
+
if(!entry.watcher)await attachExternal(entry);
|
|
693
|
+
if(!entry.watcher)throw Error(`external subscription unavailable: ${entry.root}`);
|
|
694
|
+
}
|
|
695
|
+
// The live subscriptions are attached before the fresh scan. Changes
|
|
696
|
+
// delivered during indexing are queued and rechecked before health returns.
|
|
697
|
+
await refreshPlan({forceCold:true});
|
|
698
|
+
if(process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_RECOVERY_FINISH_DELAY)
|
|
699
|
+
await new Promise(resolve=>setTimeout(resolve,Math.min(2000,
|
|
700
|
+
Number(process.env.VSTATE_TEST_RECOVERY_FINISH_DELAY)||0)));
|
|
701
|
+
if(process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_RECOVERY_FINALIZE_FAULT&&
|
|
702
|
+
fs.existsSync(process.env.VSTATE_TEST_RECOVERY_FINALIZE_FAULT))
|
|
703
|
+
throw Error('injected recovery finalization failure');
|
|
704
|
+
if(!healthy||[...external.values()].some(entry=>entry.index&&!entry.healthy))
|
|
705
|
+
throw Error(reason||'deterministic reconciliation unavailable');
|
|
706
|
+
rootIdentity=fs.statSync(root,{bigint:true});identityChanged=false;
|
|
707
|
+
recoveryAttempts=0;
|
|
708
|
+
event('reconciliation_completed',{classification});
|
|
709
|
+
}catch(e){healthy=false;reason=`deterministic reconciliation failed: ${e.message}; restart observer`;
|
|
710
|
+
event('reconciliation_failed',{classification,error_code:e.code||e.name||'Error',
|
|
711
|
+
reason:e.message,identityChanged,watcherAttached:Boolean(watcher),planPhase:metrics.planPhase});
|
|
712
|
+
publish();
|
|
713
|
+
}finally{recovering=false;publish();
|
|
714
|
+
if(!healthy&&!stopping&&recoveryAttempts<2&&!recoveryTimer)
|
|
715
|
+
recoveryTimer=setTimeout(()=>recoverObservation(classification),500);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
function verifyRoot() {
|
|
719
|
+
let observed='unavailable';
|
|
720
|
+
try {const current=fs.statSync(root,{bigint:true});
|
|
721
|
+
if(current.dev===rootIdentity.dev&¤t.ino===rootIdentity.ino)return true;
|
|
722
|
+
observed=String(current.dev)+':'+String(current.ino);
|
|
723
|
+
}catch(error){observed=error.code||'unavailable';}
|
|
724
|
+
// Suppress repeats for the same failed identity, not all future root
|
|
725
|
+
// replacements. A successful decision rebuild may follow a failed recovery.
|
|
726
|
+
if(identityGapKey!==observed){identityGapKey=observed;identityChanged=true;healthy=false;
|
|
727
|
+
if(!recovering)recoveryAttempts=0;
|
|
728
|
+
watcher?.unsubscribe().catch(()=>{});watcher=null;
|
|
729
|
+
observationGap('checkout_identity_changed');}
|
|
730
|
+
return false;
|
|
731
|
+
}
|
|
732
|
+
async function catchUp() {
|
|
733
|
+
if(!hasHistory){
|
|
734
|
+
if(!verifyRoot())throw Error('checkout identity changed');
|
|
735
|
+
if(!watcher)throw Error('filesystem observation unavailable');
|
|
736
|
+
await watcher.synchronize();
|
|
737
|
+
for(const entry of external.values())if(entry.index){
|
|
738
|
+
if(!entry.watcher)throw Error(`external observation unavailable: ${entry.root}`);
|
|
739
|
+
await entry.watcher.synchronize();
|
|
740
|
+
}
|
|
741
|
+
if(!planning&&(flushTimer||pending.size||planDirty||
|
|
742
|
+
[...external.values()].some(entry=>entry.pending.size)))flush();
|
|
743
|
+
lastObservation=Date.now();return;
|
|
744
|
+
}
|
|
745
|
+
if(historyDisabled)return;
|
|
746
|
+
if(synchronizing)return synchronizing;
|
|
747
|
+
synchronizing=(async()=>{
|
|
748
|
+
if(!verifyRoot())throw Error('checkout identity changed');
|
|
749
|
+
if(!watcher)throw Error('subscription unavailable');
|
|
750
|
+
if(!fs.existsSync(snapshotPath))throw Error('historical cursor missing');
|
|
751
|
+
{
|
|
752
|
+
const changes=await queryHistory(root,snapshotPath,'root replay');
|
|
753
|
+
for(const change of changes)notification(change.type,path.relative(root,change.path));
|
|
754
|
+
if(!planning&&(flushTimer||pending.size||planDirty))flush();
|
|
755
|
+
for(const entry of external.values())if(entry.index) {
|
|
756
|
+
try{
|
|
757
|
+
const identity=fs.statSync(entry.root,{bigint:true});
|
|
758
|
+
if(identity.dev!==entry.identity?.dev||identity.ino!==entry.identity?.ino)
|
|
759
|
+
throw Error('external observation root identity changed');
|
|
760
|
+
if(!entry.watcher||!fs.existsSync(entry.snapshot))throw Error('external cursor unavailable');
|
|
761
|
+
const changes=await queryHistory(entry.root,entry.snapshot,'external replay');
|
|
762
|
+
for(const change of changes)externalNotification(entry,change.type,
|
|
763
|
+
path.relative(entry.root,change.path));
|
|
764
|
+
}catch(e){if(e.code==='ETIMEDOUT')throw e;
|
|
765
|
+
externalGap(entry,'historical_replay_failed',e);}
|
|
766
|
+
}
|
|
767
|
+
if(!planning&&(flushTimer||pending.size||planDirty||
|
|
768
|
+
[...external.values()].some(e=>e.pending.size)))flush();
|
|
769
|
+
lastObservation=Date.now();
|
|
770
|
+
}
|
|
771
|
+
})();
|
|
772
|
+
try{return await synchronizing;}finally{synchronizing=null;}
|
|
773
|
+
}
|
|
774
|
+
function respond(client,value) {
|
|
775
|
+
if(client.destroyed){event('control_reply_unavailable',{classification:'client_destroyed'});return;}
|
|
776
|
+
try{const payload=JSON.stringify(value)+'\n';
|
|
777
|
+
client.end(payload);client.vstateReplied=true;
|
|
778
|
+
if(process.env.VSTATE_DEBUG_CONTROL==='1')event('control_reply_sent',{bytes:payload.length});}
|
|
779
|
+
catch(e){event('control_reply_unavailable',{classification:e.name||'serialization_error'});
|
|
780
|
+
if(!client.destroyed)client.end('{"error":"control response unavailable"}\n');}
|
|
781
|
+
}
|
|
782
|
+
async function request(message) {
|
|
783
|
+
if(message.action==='stop'){setImmediate(shutdown);return {ok:true};}
|
|
784
|
+
if(message.action==='metrics')return {generation,metrics,index:{files:index?.files.size,
|
|
785
|
+
rehashedFiles:index?.rehashedFiles,rehashedBytes:index?.rehashedBytes,
|
|
786
|
+
lastInputChangeAt:index?.lastChange,
|
|
787
|
+
profile:index?.profile},external:[...external.values()].map(e=>({root:e.root,
|
|
788
|
+
healthy:e.healthy,reason:e.reason,files:e.index?.files.size,
|
|
789
|
+
rehashedFiles:e.index?.rehashedFiles,rehashedBytes:e.index?.rehashedBytes})),
|
|
790
|
+
healthy,reason,planning,recovering,identityChanged,recoveryAttempts};
|
|
791
|
+
if(message.action==='reload'){loadReceipts();publish();return {ok:true};}
|
|
792
|
+
if(message.action==='sync'||message.action==='snapshot'||message.action==='prelaunch') {
|
|
793
|
+
const totalStarted=performance.now();
|
|
794
|
+
const decision=++decisionSequence;
|
|
795
|
+
timing('daemon.decision_reason',totalStarted,{decision,action:message.action,
|
|
796
|
+
plan:process.platform==='win32'?'validate_dependencies_before_reuse':'existing_plan',
|
|
797
|
+
probe:'reuse_only_with_identical_content_and_context',installed:'current_content_required',
|
|
798
|
+
phase:metrics.planPhase||'initializing',pending:pending.size,
|
|
799
|
+
notifications:metrics.notifications,planRebuilds:metrics.planRebuilds});
|
|
800
|
+
if(process.platform==='win32'&&process.env.VSTATE_TEST_FAULTS==='1'&&
|
|
801
|
+
process.env.VSTATE_TEST_WINDOWS_GAP_FILE&&
|
|
802
|
+
fs.existsSync(process.env.VSTATE_TEST_WINDOWS_GAP_FILE)){
|
|
803
|
+
fs.unlinkSync(process.env.VSTATE_TEST_WINDOWS_GAP_FILE);
|
|
804
|
+
observationGap('test_injected_windows_gap');
|
|
805
|
+
return status();
|
|
806
|
+
}
|
|
807
|
+
if(planning||recovering)return status();
|
|
808
|
+
// Without a usable continuity barrier, live-event delivery alone cannot
|
|
809
|
+
// establish a decision checkpoint. This also applies after macOS history
|
|
810
|
+
// has been disabled: a recovered subscription may still have queued events.
|
|
811
|
+
const needsContentReconciliation=process.platform==='win32'||(hasHistory&&historyDisabled);
|
|
812
|
+
if(needsContentReconciliation&&message.action!=='prelaunch'){
|
|
813
|
+
const started=performance.now();await refreshPlan({forceCold:true,reuse:true});
|
|
814
|
+
timing('daemon.request_content_validation',started,{healthy:healthy?1:0});}
|
|
815
|
+
if(healthy)try{const started=performance.now();await catchUp();
|
|
816
|
+
timing('daemon.request_catch_up',started);}catch(e){observationGap('historical_query',e);}
|
|
817
|
+
if(flushTimer||pending.size||planDirty||[...external.values()].some(e=>e.pending.size))flush();
|
|
818
|
+
let synchronizedInstalled=false;
|
|
819
|
+
// A successful forced reconciliation has already hashed the
|
|
820
|
+
// complete declared installed input set. A second full pass inside the
|
|
821
|
+
// same request duplicates that work without creating an atomic snapshot.
|
|
822
|
+
const validatedColdIndex=needsContentReconciliation&&message.action!=='prelaunch'&&
|
|
823
|
+
healthy&&!planning&&
|
|
824
|
+
!planDirty&&!index?.unavailable;
|
|
825
|
+
if(index&&healthy&&!planning&&message.action!=='prelaunch'){
|
|
826
|
+
const names=[...index.checks].filter(([name,row])=>
|
|
827
|
+
row.plan.synchronizedInstalledRead&&(!message.name||message.name===name))
|
|
828
|
+
.map(([name])=>name);
|
|
829
|
+
if(names.length&&validatedColdIndex){
|
|
830
|
+
synchronizedInstalled=true;
|
|
831
|
+
timing('daemon.request_installed_from_reconcile',performance.now(),
|
|
832
|
+
{checks:names.length,files:index.files.size});
|
|
833
|
+
}else if(names.length)try{
|
|
834
|
+
if(installedRecheck)await installedRecheck;
|
|
835
|
+
const observedIndex=index;
|
|
836
|
+
const recheckStarted=performance.now();
|
|
837
|
+
installedRecheck=observedIndex.recheckInstalled(names);
|
|
838
|
+
const result=await installedRecheck;
|
|
839
|
+
metrics.lastInstalledRecheck=result;
|
|
840
|
+
timing('daemon.request_installed_recheck',recheckStarted,
|
|
841
|
+
{files:result.files,bytes:result.bytes});
|
|
842
|
+
installedRecheck=null;
|
|
843
|
+
await catchUp();
|
|
844
|
+
if(flushTimer||pending.size||planDirty)flush();
|
|
845
|
+
synchronizedInstalled=healthy&&!planning&&!planDirty&&!index.unavailable&&
|
|
846
|
+
index===observedIndex;
|
|
847
|
+
}catch(e){installedRecheck=null;observationGap('installed_recheck',e);}
|
|
848
|
+
}
|
|
849
|
+
const probeStarted=performance.now();
|
|
850
|
+
if(index&&healthy&&!validatedColdIndex&&message.action!=='prelaunch')
|
|
851
|
+
for(const row of index.checks.values())probe(row);
|
|
852
|
+
timing('daemon.request_probes',probeStarted,{checks:index?.checks.size||0});
|
|
853
|
+
lastObservation=Date.now();publish();
|
|
854
|
+
const applicabilityStarted=performance.now();
|
|
855
|
+
const data=status(message.contextHashes,synchronizedInstalled,
|
|
856
|
+
process.platform==='win32'&&healthy&&!planning&&!planDirty);
|
|
857
|
+
if(message.action==='snapshot'||message.action==='prelaunch') {
|
|
858
|
+
data.snapshots={};for(const [name,row] of index?.checks||[])
|
|
859
|
+
if(!message.name||message.name===name)
|
|
860
|
+
data.snapshots[name]={...combinedSummary(name),files:combinedFiles(name),
|
|
861
|
+
planGeneration:row.plan.id,
|
|
862
|
+
inputEventSerial:inputEventSerial.get(name)||0,
|
|
863
|
+
probeHash:row.probeHash,
|
|
864
|
+
observationHealthy:healthy&&!planning&&!recovering&&applicableExternal(name).every(e=>e.healthy)};
|
|
865
|
+
}
|
|
866
|
+
timing('daemon.request_applicability',applicabilityStarted,{checks:data.checks.length});
|
|
867
|
+
timing('daemon.request_total',totalStarted,{action:message.action,
|
|
868
|
+
healthy:healthy?1:0});
|
|
869
|
+
return data;
|
|
870
|
+
}
|
|
871
|
+
return {error:'unknown action'};
|
|
872
|
+
}
|
|
873
|
+
async function shutdown() {
|
|
874
|
+
if(stopping)return;
|
|
875
|
+
const retainEligible=healthy&&!historyDisabled&&!index?.unavailable&&!planDirty;
|
|
876
|
+
stopping=true;healthy=false;reason='observer stopping';publish();
|
|
877
|
+
event('observer_stopped',{pid:process.pid});clearTimeout(flushTimer);
|
|
878
|
+
if(retainEligible&&index&&!planning&&watcher){
|
|
879
|
+
try {await catchUp();
|
|
880
|
+
if(pending.size||[...external.values()].some(e=>e.pending.size))flush();
|
|
881
|
+
// The process was healthy before the stop request only if its index and
|
|
882
|
+
// cursor can be retained. A failed catch-up leaves the old snapshot
|
|
883
|
+
// unusable for optimistic restart.
|
|
884
|
+
if(!index.unavailable&&fs.existsSync(snapshotPath)){
|
|
885
|
+
healthy=true;retainIndex();healthy=false;}
|
|
886
|
+
} catch(e){event('index_retention_unavailable',{classification:e.name||'Error'});}
|
|
887
|
+
}
|
|
888
|
+
planningWorker?.terminate().catch(()=>{});
|
|
889
|
+
clearInterval(heartbeat);clearInterval(probeTimer);clearTimeout(recoveryTimer);
|
|
890
|
+
watcher?.unsubscribe().catch(()=>{});server.close();
|
|
891
|
+
for(const entry of external.values()){
|
|
892
|
+
clearTimeout(entry.recovery);entry.watcher?.unsubscribe().catch(()=>{});}
|
|
893
|
+
if(endpoint.filesystem)try{fs.unlinkSync(socket);}catch{}
|
|
894
|
+
fs.rmSync(lock,{recursive:true,force:true});
|
|
895
|
+
// A native watcher handle may remain wedged even after unsubscribe. State
|
|
896
|
+
// and control ownership are already released; bound process shutdown.
|
|
897
|
+
setTimeout(()=>process.exit(0),1000).unref();
|
|
898
|
+
}
|
|
899
|
+
const server=net.createServer(client=>{
|
|
900
|
+
let text='',handled=false;
|
|
901
|
+
client.on('error',e=>event('control_client_error',{error_code:e.code||e.name}));
|
|
902
|
+
client.on('close',()=>{if(!client.vstateReplied)
|
|
903
|
+
event('control_reply_unavailable',{classification:handled?'closed_without_reply':
|
|
904
|
+
'closed_before_request',request_bytes:text.length});});
|
|
905
|
+
client.on('data',d=>{
|
|
906
|
+
if(handled)return;text+=d;if(text.length>4096){event('control_request_rejected',
|
|
907
|
+
{classification:'request_too_large',request_bytes:text.length});client.destroy();return;}
|
|
908
|
+
if(!text.includes('\n'))return;handled=true;
|
|
909
|
+
try{const message=JSON.parse(text);
|
|
910
|
+
const decision=message.action==='sync'||message.action==='snapshot';
|
|
911
|
+
if(decision)decisionRequests++;
|
|
912
|
+
Promise.resolve(request(message)).then(v=>respond(client,v))
|
|
913
|
+
.catch(e=>respond(client,{error:e.message}))
|
|
914
|
+
.finally(()=>{if(decision)decisionRequests--;});}
|
|
915
|
+
catch(e){respond(client,{error:e.message});}
|
|
916
|
+
});
|
|
917
|
+
});
|
|
918
|
+
async function attachWatch(){
|
|
919
|
+
try {watcher=await subscribe(root,(error,changes)=>{
|
|
920
|
+
if(error){observationGap(error.code||'subscription_error',error);
|
|
921
|
+
watcher?.unsubscribe().catch(()=>{});watcher=null;
|
|
922
|
+
return;}
|
|
923
|
+
if(process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_DROP_EVENTS_FILE&&
|
|
924
|
+
fs.existsSync(process.env.VSTATE_TEST_DROP_EVENTS_FILE))return;
|
|
925
|
+
for(const change of changes){const rel=observedRelative(root,change.path);
|
|
926
|
+
if(rel===null){observationGap('event_outside_checkout');break;}
|
|
927
|
+
notification(change.type,rel);}
|
|
928
|
+
});
|
|
929
|
+
} catch(e){watcher=null;observationGap(e.code||'subscription_start',e);}
|
|
930
|
+
}
|
|
931
|
+
async function attachExternal(entry) {
|
|
932
|
+
try{entry.identity=fs.statSync(entry.root,{bigint:true});
|
|
933
|
+
entry.watcher=await subscribe(entry.root,(error,changes)=>{
|
|
934
|
+
if(error){entry.watcher?.unsubscribe().catch(()=>{});entry.watcher=null;
|
|
935
|
+
externalGap(entry,error.code||'subscription_error',error);return;}
|
|
936
|
+
if(process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_DROP_EVENTS_FILE&&
|
|
937
|
+
fs.existsSync(process.env.VSTATE_TEST_DROP_EVENTS_FILE))return;
|
|
938
|
+
for(const change of changes){const rel=observedRelative(entry.root,change.path);
|
|
939
|
+
if(rel===null){externalGap(entry,'event_outside_root');break;}
|
|
940
|
+
externalNotification(entry,change.type,rel);}
|
|
941
|
+
});}
|
|
942
|
+
catch(e){entry.watcher=null;entry.reason=`external subscription unavailable: ${e.code||e.name}`;}
|
|
943
|
+
}
|
|
944
|
+
server.listen({path:socket,readableAll:false,writableAll:false},async()=>{
|
|
945
|
+
const setupStarted=performance.now();
|
|
946
|
+
if(endpoint.filesystem)fs.chmodSync(socket,0o600);
|
|
947
|
+
event('control_listening',{pid:process.pid});
|
|
948
|
+
loadReceipts();await attachWatch();
|
|
949
|
+
if(hasHistory&&watcher&&!fs.existsSync(snapshotPath))try{
|
|
950
|
+
await queryHistory(root,snapshotPath,'initial snapshot');}
|
|
951
|
+
catch(e){historyDisabled=true;event('history_unavailable',{phase:'initial snapshot',
|
|
952
|
+
error_code:e.code||e.name});}
|
|
953
|
+
for(const entry of external.values()){
|
|
954
|
+
await attachExternal(entry);
|
|
955
|
+
if(!historyDisabled&&entry.watcher&&!fs.existsSync(entry.snapshot))try{
|
|
956
|
+
await queryHistory(entry.root,entry.snapshot,'external initial snapshot');}
|
|
957
|
+
catch(e){historyDisabled=true;event('history_unavailable',
|
|
958
|
+
{phase:'external initial snapshot',error_code:e.code||e.name});}
|
|
959
|
+
}
|
|
960
|
+
metrics.initialObservationSetupMs=performance.now()-setupStarted;
|
|
961
|
+
refreshPlan({forceCold:historyDisabled});
|
|
962
|
+
event('observer_started',{pid:process.pid});publish();
|
|
963
|
+
});
|
|
964
|
+
const heartbeat=setInterval(()=>{
|
|
965
|
+
verifyRoot();
|
|
966
|
+
for(const entry of external.values())if(entry.watcher){
|
|
967
|
+
try{const identity=fs.statSync(entry.root,{bigint:true});
|
|
968
|
+
if(identity.dev!==entry.identity?.dev||identity.ino!==entry.identity?.ino)
|
|
969
|
+
externalGap(entry,'root_identity_changed');}
|
|
970
|
+
catch(e){externalGap(entry,'root_unavailable',e);}
|
|
971
|
+
}
|
|
972
|
+
publish();
|
|
973
|
+
},1000);
|
|
974
|
+
const probeTimer=setInterval(async()=>{
|
|
975
|
+
// Windows cached reads are conservatively UNVERIFIED until a full decision
|
|
976
|
+
// reconciliation. Periodic synchronous probes cannot strengthen that cache,
|
|
977
|
+
// but can block control requests behind several seconds of child work.
|
|
978
|
+
if(process.platform==='win32')return;
|
|
979
|
+
if(healthy&&index&&!planning&&!decisionRequests){
|
|
980
|
+
try{await catchUp();for(const row of index.checks.values())probe(row);publish();}
|
|
981
|
+
catch(e){observationGap('periodic_query',e);}
|
|
982
|
+
}},5000);
|
|
983
|
+
process.on('uncaughtExceptionMonitor',error=>{
|
|
984
|
+
try{event('observer_uncaught_exception',{pid:process.pid,code:error.code||null,
|
|
985
|
+
message:error.message,stack:error.stack});}catch{}
|
|
986
|
+
});
|
|
987
|
+
process.on('exit',code=>{try{event('observer_exit',{pid:process.pid,code,stopping,
|
|
988
|
+
healthy,planning,recovering});}catch{}});
|
|
989
|
+
process.on('SIGTERM',shutdown);process.on('SIGINT',shutdown);
|