@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
package/src/daemon.mjs
ADDED
|
@@ -0,0 +1,1236 @@
|
|
|
1
|
+
import {inflateSync,deflateSync} 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,readReceiptSnapshot,receiptRevision} from './state-store.mjs';
|
|
18
|
+
import {history,closeHistory} from './observation.mjs';
|
|
19
|
+
import {inputKey,planGuard,toolchainKey} from './decision-validation.mjs';
|
|
20
|
+
import {queriesHold} from './typescript-list.mjs';
|
|
21
|
+
import {directProject,directProbeValue} from './direct-typescript.mjs';
|
|
22
|
+
import {recordedCertificate} from './direct-run-certificate.mjs';
|
|
23
|
+
import {timing,mark,traceBase,traceContext,traceScope,traceEnvironment,traceId} from './decision-profile.mjs';
|
|
24
|
+
|
|
25
|
+
const configFile=path.resolve(process.argv[2]);
|
|
26
|
+
const config=JSON.parse(fs.readFileSync(configFile));
|
|
27
|
+
const root=realObservedPath(config.root), state=path.resolve(config.state);
|
|
28
|
+
assertOwnedStatePlacement(config);
|
|
29
|
+
let rootIdentity=fs.statSync(root,{bigint:true});
|
|
30
|
+
if(withinPath(state,root))throw Error('state must be outside checkout');
|
|
31
|
+
fs.mkdirSync(state,{recursive:true,mode:0o700});
|
|
32
|
+
const endpoint=controlEndpoint(state),socket=endpoint.address,
|
|
33
|
+
lock=path.join(state,'observer.lock');
|
|
34
|
+
fs.mkdirSync(lock); // Existing lock requires operator inspection; never steal it.
|
|
35
|
+
fs.writeFileSync(path.join(lock,'pid'),String(process.pid),{mode:0o600});
|
|
36
|
+
const generation=randomUUID(), startedAt=Date.now();
|
|
37
|
+
traceBase({generation,work:'startup'});
|
|
38
|
+
let index, receipts={}, watcher, healthy=false, reason='initial reconciliation', stopping=false;
|
|
39
|
+
let receiptStateError=null;
|
|
40
|
+
let receiptStateRevision=null;
|
|
41
|
+
const external=new Map();
|
|
42
|
+
for(const check of config.checks)for(const boundary of permittedRoots(root,check.allowedExternalRoots||[]))
|
|
43
|
+
if(!external.has(boundary))external.set(boundary,{root:boundary,watcher:null,index:null,
|
|
44
|
+
healthy:false,gap:false,reason:'external observation initializing',pending:new Set(),
|
|
45
|
+
recoveryDelay:1000,
|
|
46
|
+
snapshot:path.join(state,`external-${sha(boundary).slice(0,16)}.snapshot`)});
|
|
47
|
+
let pending=new Set(), pendingChecks=new Set(), flushTimer, planDirty=false, planning=false, lastObservation=0;
|
|
48
|
+
let decisionRequests=0;
|
|
49
|
+
let decisionSequence=0;
|
|
50
|
+
let inputEventSerial=new Map();
|
|
51
|
+
let diagnosticInputEvents=0;
|
|
52
|
+
let synchronizing=null;
|
|
53
|
+
let installedRecheck=null;
|
|
54
|
+
let historyDisabled=!hasHistory,recoveryTimer=null,recovering=false,recoveryAttempts=0;
|
|
55
|
+
let identityChanged=false;
|
|
56
|
+
let identityGapKey=null;
|
|
57
|
+
let triggerHashes=new Map();
|
|
58
|
+
let candidateDescendants=new Map();
|
|
59
|
+
let workspacePatterns=(()=>{const w=JSON.parse(fs.readFileSync(path.join(root,'package.json'))).workspaces;
|
|
60
|
+
return Array.isArray(w)?w:w?.packages||[];})();
|
|
61
|
+
let planningWorker=null;
|
|
62
|
+
let validatedPlan=null;
|
|
63
|
+
let gapDuringPlan=false;
|
|
64
|
+
const snapshotPath=path.join(state,'fs-events.snapshot');
|
|
65
|
+
const metrics={notifications:0,flushes:0,reconciliations:0,planRebuilds:0,gaps:0,
|
|
66
|
+
maxQueue:0,historyQueries:0,historyTimeouts:0,startupAt:startedAt};
|
|
67
|
+
const historyDeadlineMs=Math.max(100,Number(process.env.VSTATE_HISTORY_TIMEOUT_MS||5000));
|
|
68
|
+
const reconciliationDeadlineMs=Math.max(1000,
|
|
69
|
+
Number(process.env.VSTATE_RECONCILE_TIMEOUT_MS||120000));
|
|
70
|
+
async function queryHistory(observedRoot,snapshot,phase) {
|
|
71
|
+
try{const changes=await history(observedRoot,snapshot,backend,historyDeadlineMs,
|
|
72
|
+
process.env.VSTATE_TEST_HISTORY_FAULT_FILE);
|
|
73
|
+
metrics.historyQueries++;event('history_query_completed',{phase,events:changes.length});
|
|
74
|
+
return changes;}
|
|
75
|
+
catch(e){if(e.code==='ETIMEDOUT'){metrics.historyTimeouts++;
|
|
76
|
+
event('history_timeout',{phase,deadline_ms:historyDeadlineMs});}
|
|
77
|
+
throw e;}
|
|
78
|
+
}
|
|
79
|
+
const atomic=atomicJson;
|
|
80
|
+
function event(kind,fields={}) {
|
|
81
|
+
fs.appendFileSync(path.join(state,'events.jsonl'),JSON.stringify({at:Date.now(),kind,generation,
|
|
82
|
+
repository:root,...fields})+'\n');
|
|
83
|
+
}
|
|
84
|
+
function loadReceipts() {
|
|
85
|
+
try {const snapshot=readReceiptSnapshot(state);receipts=snapshot.value.checks;
|
|
86
|
+
receiptStateRevision=snapshot.revision;receiptStateError=null;}
|
|
87
|
+
catch(e){receiptStateRevision=null;
|
|
88
|
+
// A readable selector can still supply explicitly historical outcomes after
|
|
89
|
+
// restart. The failed snapshot guard remains authoritative for applicability.
|
|
90
|
+
try{receipts=readReceipts(state).checks;}catch{/* retain previous historical rows */}
|
|
91
|
+
const next=`latest receipt unavailable; prior outcome only: ${e.message}`;
|
|
92
|
+
if(receiptStateError!==next)event('receipt_state_unavailable',
|
|
93
|
+
{classification:e.name||'Error'});
|
|
94
|
+
receiptStateError=next;}
|
|
95
|
+
}
|
|
96
|
+
function refreshReceipts() {
|
|
97
|
+
try {if(receiptStateRevision===null||receiptRevision(state)!==receiptStateRevision)loadReceipts();}
|
|
98
|
+
catch {loadReceipts();}
|
|
99
|
+
}
|
|
100
|
+
function persistedPlan(bundle) {
|
|
101
|
+
return {schema:1,provider:bundle.provider,root:bundle.root,discoveredAt:bundle.discoveredAt,
|
|
102
|
+
workspaceCount:bundle.workspaceCount,configurationInputs:bundle.configurationInputs,
|
|
103
|
+
plans:Object.fromEntries(Object.entries(bundle.plans).map(([name,plan])=>{
|
|
104
|
+
const safe=structuredClone(plan);
|
|
105
|
+
safe.commandIdentity={argvHash:sha(JSON.stringify(plan.command)),argc:plan.command.length,
|
|
106
|
+
source:plan.executionProvenance};
|
|
107
|
+
delete safe.command;delete safe.taskCommand;
|
|
108
|
+
safe.probes=plan.probes.map(argv=>({argvHash:sha(JSON.stringify(argv)),argc:argv.length}));
|
|
109
|
+
safe.taskGraph=safe.taskGraph.map(row=>{
|
|
110
|
+
const item={...row,commandHash:sha(JSON.stringify(row.command??null)),
|
|
111
|
+
optionsHash:sha(JSON.stringify(row.options??null))};
|
|
112
|
+
delete item.command;delete item.options;return item;
|
|
113
|
+
});
|
|
114
|
+
return [name,safe];
|
|
115
|
+
}))};
|
|
116
|
+
}
|
|
117
|
+
// Continuity for in-process certificate reuse: every observed change is applied.
|
|
118
|
+
// macOS needs its history cursor; Linux has the synchronize barrier in catchUp.
|
|
119
|
+
// Windows reconciles content at each decision and keeps its own reuse path.
|
|
120
|
+
function continuityBarrier() {
|
|
121
|
+
return process.platform!=='win32'&&(process.platform!=='darwin'||hasHistory&&!historyDisabled)&&
|
|
122
|
+
healthy&&watcher&&!planning&&!recovering&&!planDirty&&!index?.unavailable&&
|
|
123
|
+
!pending.size&&!pendingChecks.size&&!external.size&&verifyRoot();
|
|
124
|
+
}
|
|
125
|
+
// The basis a full direct probe is checked against: plan guard, observation
|
|
126
|
+
// continuity, and the compiler tree and Node identity taken before the probe.
|
|
127
|
+
function certificateBasis(row) {
|
|
128
|
+
const argv=row.plan.probes[0],compiler=directProject(argv[2],argv[3],{verify:false});
|
|
129
|
+
return {guard:planGuard(index.bundle),gaps:metrics.gaps,
|
|
130
|
+
compiler:JSON.stringify([compiler.tsRoot,compiler.entry,compiler.digest]),
|
|
131
|
+
toolchain:toolchainKey(compiler.tsRoot,argv[0])};
|
|
132
|
+
}
|
|
133
|
+
// Returns null after reusing the last full probe, otherwise why a full probe is needed.
|
|
134
|
+
function reuseCertificate(row,key,allowReuse,forceFull,checked) {
|
|
135
|
+
const started=performance.now(),cache=row.probeCache;
|
|
136
|
+
if(forceFull)return 'forced_full_reference';
|
|
137
|
+
if(!allowReuse||!continuityBarrier())return 'continuity_unavailable';
|
|
138
|
+
if(row.plan.unresolved.length)return 'coverage_unresolved';
|
|
139
|
+
if(!cache?.queries?.length)return 'no_validated_result';
|
|
140
|
+
if(cache.input!==key)return 'inputs_changed';
|
|
141
|
+
if(cache.gaps!==metrics.gaps)return 'observation_gap';
|
|
142
|
+
let reason=null;
|
|
143
|
+
try{const basis=checked.basis=certificateBasis(row);
|
|
144
|
+
if(basis.guard!==cache.guard){planDirty=true;reason='plan_guard_changed';}
|
|
145
|
+
else if(basis.compiler!==cache.compiler||basis.toolchain!==cache.toolchain)reason='toolchain_changed';
|
|
146
|
+
else if(!queriesHold(cache.queries,cache.identities))reason='compiler_queries_changed';}
|
|
147
|
+
catch{reason='validation_unavailable';}
|
|
148
|
+
timing('daemon.probe_validation',started,{outcome:reason?'changed_or_unavailable':'reused',
|
|
149
|
+
reason:reason||'content_and_context_unchanged',queries:cache.queries.length});
|
|
150
|
+
if(reason)return reason;
|
|
151
|
+
row.probeHash=cache.hash;row.probeAt=Date.now();row.probeError=null;
|
|
152
|
+
metrics.probeReuses=(metrics.probeReuses||0)+1;
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
function probe(row,allowReuse=false,{launch=true}={}) {
|
|
156
|
+
const started=performance.now();
|
|
157
|
+
const supported=new Map([
|
|
158
|
+
['typescript-noemit-v1','typescript-contract-probe.mjs'],
|
|
159
|
+
['npm-typescript-direct-v1','direct-typescript-probe.mjs'],
|
|
160
|
+
['yarn-workspace-tsc-v1','yarn-workspace-typecheck-probe.mjs'],
|
|
161
|
+
['pnpm-tsc-v1','pnpm-typecheck-probe.mjs']]);
|
|
162
|
+
const expected=supported.get(row.plan.qualification);
|
|
163
|
+
const builtIn=row.plan.probes.length===1&&expected&&
|
|
164
|
+
row.plan.probes[0][1]===fileURLToPath(new URL(expected,import.meta.url));
|
|
165
|
+
const inProcess=builtIn&&process.platform!=='win32'&&row.plan.qualification==='npm-typescript-direct-v1';
|
|
166
|
+
const cacheable=builtIn&&(process.platform==='win32'||inProcess);
|
|
167
|
+
const key=inputKey(row);
|
|
168
|
+
const forceFull=process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_FULL_PROBES==='1';
|
|
169
|
+
let fullReason=null;const checked={};
|
|
170
|
+
if(inProcess){
|
|
171
|
+
fullReason=reuseCertificate(row,key,allowReuse,forceFull,checked);
|
|
172
|
+
if(!fullReason)return;
|
|
173
|
+
}
|
|
174
|
+
// Idle revalidation never launches a process. An unconfirmed result expires.
|
|
175
|
+
if(!launch&&row.plan.probes.length)return;
|
|
176
|
+
if(process.platform==='win32'&&cacheable&&allowReuse&&!forceFull&&row.probeCache?.input===key){
|
|
177
|
+
const argv=row.plan.probes[0];
|
|
178
|
+
mark('daemon.probe_launch',{mode:'certificate',deadlineMs:10000});
|
|
179
|
+
const out=spawnSync(argv[0],[...argv.slice(1),'--redue-context'],{
|
|
180
|
+
cwd:row.plan.cwd,timeout:10000,maxBuffer:1024*1024,env:traceEnvironment(),stdio:['ignore','pipe','pipe']});
|
|
181
|
+
mark('daemon.probe_complete',{mode:'certificate',childPid:out.pid,exit:out.status,signal:out.signal,code:out.error?.code});
|
|
182
|
+
metrics.probeContextProcesses=(metrics.probeContextProcesses||0)+1;
|
|
183
|
+
if(!out.error&&!out.signal&&out.status===0&&out.stdout.toString()===row.probeCache.context){
|
|
184
|
+
row.probeHash=row.probeCache.hash;row.probeAt=Date.now();row.probeError=null;
|
|
185
|
+
metrics.probeReuses=(metrics.probeReuses||0)+1;
|
|
186
|
+
timing('daemon.probe_context_validation',started,{outcome:'reused',reason:'content_and_context_unchanged'});
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
fullReason='context_or_compiler_queries_changed';
|
|
190
|
+
timing('daemon.probe_context_validation',started,{outcome:'changed_or_unavailable'});
|
|
191
|
+
}
|
|
192
|
+
fullReason??=forceFull?'forced_full_reference':!cacheable?'generic_probe':
|
|
193
|
+
!row.probeCache?'no_validated_result':row.probeCache.input!==key?'inputs_changed':'context_changed';
|
|
194
|
+
if(row.plan.probes.length){
|
|
195
|
+
timing('daemon.probe_reason',started,{reason:fullReason});
|
|
196
|
+
metrics.fullProbeReasons??={};metrics.fullProbeReasons[fullReason]=(metrics.fullProbeReasons[fullReason]||0)+1;
|
|
197
|
+
metrics.fullProbes=(metrics.fullProbes||0)+1;
|
|
198
|
+
}
|
|
199
|
+
// A failed or incomplete full probe must not leave an older certificate usable.
|
|
200
|
+
let basis=checked.basis||null;
|
|
201
|
+
if(inProcess){
|
|
202
|
+
if(!basis)try{basis=certificateBasis(row);}catch{}
|
|
203
|
+
if(row.probeCache?.guard&&basis&&basis.guard!==row.probeCache.guard)planDirty=true;
|
|
204
|
+
row.probeCache=null;
|
|
205
|
+
}
|
|
206
|
+
const values=[];let checkpoint=null;
|
|
207
|
+
for(const argv of row.plan.probes) {
|
|
208
|
+
// Supported TypeScript probes perform bounded input/toolchain hashing.
|
|
209
|
+
// Native NTFS runs have exceeded the generic 5 s cap under load, despite
|
|
210
|
+
// completing normally and with the duplicated work already removed.
|
|
211
|
+
const timeout=row.plan.qualification==='pnpm-tsc-v1'||
|
|
212
|
+
(process.platform==='win32'&&['typescript-noemit-v1','npm-typescript-direct-v1','yarn-workspace-tsc-v1']
|
|
213
|
+
.includes(row.plan.qualification))?10000:5000;
|
|
214
|
+
mark('daemon.probe_launch',{mode:'full',reason:fullReason,deadlineMs:timeout});
|
|
215
|
+
const out=spawnSync(argv[0],[...argv.slice(1),...(cacheable?['--redue-checkpoint']:[])],{cwd:row.plan.cwd,
|
|
216
|
+
timeout,maxBuffer:1024*1024,stdio:['ignore','pipe','pipe'],env:traceEnvironment()});
|
|
217
|
+
mark('daemon.probe_complete',{mode:'full',childPid:out.pid,exit:out.status,signal:out.signal,code:out.error?.code});
|
|
218
|
+
if(out.error||out.signal||out.status!==0) {
|
|
219
|
+
if(!row.probeError){row.revision++;row.lastReason='declared state probe became unavailable';}
|
|
220
|
+
row.probeHash=null;row.probeAt=0;
|
|
221
|
+
const code=['typescript-noemit-v1','npm-typescript-direct-v1','yarn-workspace-tsc-v1','pnpm-tsc-v1']
|
|
222
|
+
.includes(row.plan.qualification)?
|
|
223
|
+
/VSTATE_REASON:([a-z-]+)/.exec(out.stderr?.toString()||'')?.[1]:null;
|
|
224
|
+
const explanations={
|
|
225
|
+
'direct-typescript-implementation-unreviewed':'installed TypeScript bytes or membership differ from the reviewed compiler',
|
|
226
|
+
'direct-typescript-version-unsupported':'direct compiler recipe supports reviewed TypeScript 5.5.2, 5.6.3 and 5.9.3 only',
|
|
227
|
+
'direct-execution-environment-unsupported':'direct compiler rejects development helpers, Node cache/preloads, and unreviewed runtime overrides',
|
|
228
|
+
'direct-node-version-unsupported':'direct compiler recipe supports Node 22 and 24',
|
|
229
|
+
'npm-launcher-unobserved-inputs':'npm launcher uses unobserved notifier inputs; execution is recording-only',
|
|
230
|
+
'typecheck-script-changed':'typecheck script or lifecycle changed; rerun init or review it',
|
|
231
|
+
'execution-environment-unsupported':'Node/npm preload or configuration environment is unsupported',
|
|
232
|
+
'typescript-executable-changed':'local TypeScript executable changed or is missing',
|
|
233
|
+
'typescript-config-unavailable':'TypeScript configuration cannot be resolved',
|
|
234
|
+
'typescript-config-graph-unsupported':'TypeScript configuration extends, references, or uses compiler features outside this contract',
|
|
235
|
+
'typescript-incremental-unsupported':'TypeScript incremental or composite compilation needs explicit review',
|
|
236
|
+
'typescript-config-outside-repository':'TypeScript configuration extends outside the observed repository',
|
|
237
|
+
'typescript-plugin-unsupported':'TypeScript compiler plugin needs explicit review',
|
|
238
|
+
'typescript-input-list-unavailable':'TypeScript input listing did not complete',
|
|
239
|
+
'typescript-input-outside-repository':'TypeScript consumes files outside the observed repository',
|
|
240
|
+
'npm-installation-layout-unsupported':'npm executable or installation layout is unsupported',
|
|
241
|
+
'npm-installation-unobservable':'npm installation contains unobservable entries',
|
|
242
|
+
'npm-configuration-unsupported':'npm script configuration needs review',
|
|
243
|
+
'typescript-contract-unavailable':'TypeScript applicability probe could not establish inputs',
|
|
244
|
+
'npm-proxy-version-unsupported':'npm proxy context is reviewed only for npm 10.9.2',
|
|
245
|
+
'npm-proxy-context-ambiguous':'conflicting npm proxy environment keys require resolution',
|
|
246
|
+
'npm-proxy-value-unsupported':'npm proxy environment contains an unsupported value',
|
|
247
|
+
'workspace-execution-environment-unsupported':'Yarn/Node launcher environment override needs review',
|
|
248
|
+
'yarn-toolchain-unavailable':'pinned Yarn version or executable is unavailable',
|
|
249
|
+
'yarn-distribution-unobservable':'Corepack Yarn distribution cannot be inspected',
|
|
250
|
+
'typescript-input-outside-workspace-contract':'TypeScript reads outside the selected workspace, declared dependency outputs, or installed roots',
|
|
251
|
+
'workspace-typecheck-contract-unavailable':'workspace TypeScript applicability probe could not establish inputs'
|
|
252
|
+
,'pnpm-execution-environment-unsupported':'pnpm or Node launcher environment override needs review'
|
|
253
|
+
,'pnpm-distribution-unobservable':'pnpm executable contents cannot be observed'
|
|
254
|
+
,'pnpm-version-mismatch':'pnpm executable differs from the pinned version'
|
|
255
|
+
,'typescript-input-outside-pnpm-contract':'TypeScript reads outside the selected workspace, linked outputs, or installed inputs'
|
|
256
|
+
,'pnpm-input-coverage-unavailable':'pnpm installed-input coverage became unavailable; inspect check details'
|
|
257
|
+
,'pnpm-typecheck-contract-unavailable':'pnpm TypeScript input contract could not be established'
|
|
258
|
+
};
|
|
259
|
+
row.probeError=code?explanations[code]||`TypeScript applicability unavailable (${code})`:
|
|
260
|
+
`probe unavailable: ${argv[0]} (${out.error?.code||out.signal||out.status})`;
|
|
261
|
+
timing('daemon.probe',started,{outcome:out.error?.code||out.signal||out.status||'error',
|
|
262
|
+
commands:values.length+1});return;
|
|
263
|
+
}
|
|
264
|
+
if(cacheable){
|
|
265
|
+
try{checkpoint=JSON.parse(out.stdout);
|
|
266
|
+
checkpoint.queries=checkpoint.queryData?JSON.parse(inflateSync(
|
|
267
|
+
Buffer.from(checkpoint.queryData,'base64'),{maxOutputLength:8*1024*1024})):null;
|
|
268
|
+
// Optional: without a layout, a run after an edit falls back to a full probe.
|
|
269
|
+
try{checkpoint.layout=checkpoint.layoutData?JSON.parse(inflateSync(
|
|
270
|
+
Buffer.from(checkpoint.layoutData,'base64'),{maxOutputLength:8*1024*1024})):null;}
|
|
271
|
+
catch{checkpoint.layout=null;}
|
|
272
|
+
if(!/^[a-f0-9]{64}$/.test(checkpoint.output)||!/^[a-f0-9]{64}$/.test(checkpoint.context))
|
|
273
|
+
throw Error('invalid supported probe checkpoint');}
|
|
274
|
+
catch{row.probeHash=null;row.probeAt=0;row.probeError='probe checkpoint unavailable';return;}
|
|
275
|
+
}
|
|
276
|
+
values.push([argv,out.status,sha(cacheable?checkpoint.output:out.stdout),sha(out.stderr)]);
|
|
277
|
+
}
|
|
278
|
+
const value=sha(JSON.stringify(values));
|
|
279
|
+
if((row.probeHash!==null&&row.probeHash!==value)||row.probeError){
|
|
280
|
+
row.revision++;row.lastReason='declared state probe changed or recovered';}
|
|
281
|
+
row.probeHash=value;row.probeAt=Date.now();row.probeError=null;
|
|
282
|
+
if(checkpoint)row.probeCache=checkpoint.queries&&(!inProcess||basis)?{input:key,context:checkpoint.context,
|
|
283
|
+
hash:value,queries:checkpoint.queries,...(inProcess?{schema:1,...basis,
|
|
284
|
+
identities:new Map(),
|
|
285
|
+
output:checkpoint.output,queryData:checkpoint.queryData,layout:checkpoint.layout,
|
|
286
|
+
stderrHash:values[0][3]}: {})}:null;
|
|
287
|
+
timing('daemon.probe',started,{outcome:'ok',commands:values.length,
|
|
288
|
+
reusable:row.probeCache?1:0,queries:row.probeCache?.queries.length||0});
|
|
289
|
+
}
|
|
290
|
+
function applicableExternal(name) {return [...external.values()].filter(entry=>entry.index?.checks.has(name));}
|
|
291
|
+
function combinedSummary(name) {
|
|
292
|
+
const base=index.summary(name),others=applicableExternal(name);
|
|
293
|
+
return {...base,fingerprint:sha(JSON.stringify([base.fingerprint,
|
|
294
|
+
...others.map(entry=>[entry.root,entry.index.fingerprint(name)])])),
|
|
295
|
+
revision:base.revision+others.reduce((sum,entry)=>sum+entry.index.summary(name).revision,0),
|
|
296
|
+
files:base.files+others.reduce((sum,entry)=>sum+entry.index.summary(name).files,0)};
|
|
297
|
+
}
|
|
298
|
+
function combinedFiles(name) {
|
|
299
|
+
const files=index.exportFiles(name);
|
|
300
|
+
for(const entry of applicableExternal(name))for(const [rel,hash] of
|
|
301
|
+
Object.entries(entry.index.exportFiles(name)))files[`external:${entry.root}:${rel}`]=hash;
|
|
302
|
+
return files;
|
|
303
|
+
}
|
|
304
|
+
function describeChangedInput(plan,file) {
|
|
305
|
+
const boundary=[...external.keys()].find(value=>file.startsWith(`external:${value}:`));
|
|
306
|
+
const physical=boundary?path.join(boundary,file.slice(`external:${boundary}:`.length)):
|
|
307
|
+
path.join(root,file);
|
|
308
|
+
const matches=[...(plan.installedInstances||[]),...(plan.installedMappings||[])]
|
|
309
|
+
.filter(mapping=>!mapping.logical?.startsWith('!')&&
|
|
310
|
+
(physical===mapping.physical||physical.startsWith(mapping.physical+path.sep)))
|
|
311
|
+
.map(mapping=>mapping.logical).slice(0,2);
|
|
312
|
+
return file+' changed'+(matches.length?` (installed via ${matches.join(', ')})`:'');
|
|
313
|
+
}
|
|
314
|
+
function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=false) {
|
|
315
|
+
if(!index)return [];
|
|
316
|
+
return [...index.checks].map(([name,row])=>{
|
|
317
|
+
const r=receipts[name], result=r?.result||null;
|
|
318
|
+
const item={name,result,freshness:'UNVERIFIED',reason:'no receipt',changed_inputs:[],
|
|
319
|
+
plan_id:row.plan.id,observed_at:lastObservation,
|
|
320
|
+
invocation:r?.invocation||null,target_provenance:r?.target||null,
|
|
321
|
+
...(r?.verificationRecipe?{verification_recipe:r.verificationRecipe}:{}),
|
|
322
|
+
coverage_at_run:r?.coverage||null,declared_inputs_match:null,reuse_eligible:false};
|
|
323
|
+
if(receiptStateError){item.reason=receiptStateError;return item;}
|
|
324
|
+
if(!healthy||planning||recovering||index.unavailable){item.reason=
|
|
325
|
+
(planning||recovering)?'input observation reconciliation pending':
|
|
326
|
+
reason||index.unavailable||'observation unavailable';return item;}
|
|
327
|
+
const outside=applicableExternal(name).find(entry=>!entry.healthy||entry.index.unavailable);
|
|
328
|
+
if(outside){item.reason=outside.reason||`external observation unavailable: ${outside.index.unavailable}`;
|
|
329
|
+
return item;}
|
|
330
|
+
if(pendingChecks.has(name)){item.reason='filesystem change pending content inspection';return item;}
|
|
331
|
+
if(r&&r.planId) item.declared_inputs_match=r.planId===row.plan.id&&
|
|
332
|
+
r.fingerprint===combinedSummary(name).fingerprint;
|
|
333
|
+
if(r&&r.fingerprintVersion!==1){item.reason='receipt uses earlier input-resolution format';return item;}
|
|
334
|
+
if(row.plan.unresolved.length){
|
|
335
|
+
const checkpoint=item.declared_inputs_match===false?
|
|
336
|
+
r.planId!==row.plan.id?'; observed input plan changed':
|
|
337
|
+
`; declared input checkpoint changed (${row.lastReason||'content or membership'})`:'';
|
|
338
|
+
const probeIssue=row.probeError?`; state probe unavailable: ${row.probeError}`:'';
|
|
339
|
+
item.reason='input coverage unresolved: '+row.plan.unresolved.join('; ')+checkpoint+probeIssue;
|
|
340
|
+
return item;
|
|
341
|
+
}
|
|
342
|
+
if(row.probeError){item.reason=row.probeError;return item;}
|
|
343
|
+
// An expired probe or missing execution context cannot confirm CURRENT, but
|
|
344
|
+
// observed input content that already differs from the receipt is STALE.
|
|
345
|
+
let unconfirmed=!row.probeAt||Date.now()-row.probeAt>10000?'state probe observation expired':null;
|
|
346
|
+
if(row.plan.synchronizedInstalledRead&&!synchronizedInstalled){
|
|
347
|
+
item.reason='installed inputs require a synchronized content read';return item;}
|
|
348
|
+
if(process.platform==='win32'&&!synchronizedFilesystem){
|
|
349
|
+
item.reason='Windows cached observation requires a synchronized content read';return item;}
|
|
350
|
+
if(!r)return item;
|
|
351
|
+
if(!r.coverage?.qualified){item.reason='coverage unresolved when execution was recorded';return item;}
|
|
352
|
+
if(r.invocation?.status!=='exited'){
|
|
353
|
+
item.reason=`invocation ${r.invocation?.status||'capture unavailable'}`;return item;}
|
|
354
|
+
if(!['executed','failed','cache_hit','direct_executed'].includes(r.target?.status)){
|
|
355
|
+
item.reason=`target execution ${r.target?.status||'unavailable'}`;return item;}
|
|
356
|
+
if(r.target.status==='failed'&&result!=='FAIL'){
|
|
357
|
+
item.reason='target failure conflicts with outer invocation outcome';return item;}
|
|
358
|
+
if(!r.stable){const issues=r.checkpoint?.issues?.length?` (${r.checkpoint.issues.join('; ')})`:'';
|
|
359
|
+
item.reason=`inputs changed during execution or observation gap${issues}`;return item;}
|
|
360
|
+
const names=[...(row.plan.environment.variables||[]),
|
|
361
|
+
...(row.plan.environment.prefixes||[]).map(prefix=>
|
|
362
|
+
`__vstate_env_prefix_${prefix.toLowerCase()}__`),
|
|
363
|
+
...(row.plan.environment.pathExecutables||[]).map(name=>
|
|
364
|
+
`__vstate_path_executable_${name}__`),
|
|
365
|
+
...(row.plan.environment.executableIdentity?['__vstate_node_identity__']:[])];
|
|
366
|
+
if(names.length&&!contextHashes?.[name])unconfirmed??='execution environment context not supplied';
|
|
367
|
+
item.freshness='STALE';
|
|
368
|
+
if(r.planId!==row.plan.id){
|
|
369
|
+
const before=new Map((r.resolutionLinks||[]).map(link=>[link.path,link.targetHash]));
|
|
370
|
+
const changed=(row.plan.resolutionLinks||[]).find(link=>before.get(link.path)!==link.targetHash);
|
|
371
|
+
item.reason=changed?`installed input link changed: ${changed.path}`:'input plan changed';
|
|
372
|
+
if(changed)item.changed_inputs=[changed.path];
|
|
373
|
+
}
|
|
374
|
+
else if(contextHashes?.[name]&&names.some(key=>contextHashes[name][key]!==r.environmentHashes?.[key]))
|
|
375
|
+
item.reason='declared execution environment changed';
|
|
376
|
+
else if(r.fingerprint!==combinedSummary(name).fingerprint) {
|
|
377
|
+
const key=r.fingerprint+'\0'+combinedSummary(name).fingerprint;
|
|
378
|
+
if(row.staleReasonKey!==key){
|
|
379
|
+
let changed=row.lastReason;row.staleInputs=[];
|
|
380
|
+
const candidate=changed?.endsWith(' changed')?changed.slice(0,-8):null;
|
|
381
|
+
if(candidate&&r.files?.[candidate]!==index.files.get(candidate)){
|
|
382
|
+
changed=describeChangedInput(row.plan,candidate);row.staleInputs=[candidate];}
|
|
383
|
+
else {
|
|
384
|
+
const current=combinedFiles(name);
|
|
385
|
+
if(r.files)for(const f of new Set([...Object.keys(r.files),...Object.keys(current)]))
|
|
386
|
+
if(r.files[f]!==current[f]){changed=describeChangedInput(row.plan,f);row.staleInputs=[f];break;}
|
|
387
|
+
}
|
|
388
|
+
row.staleReason=changed||
|
|
389
|
+
(['typescript-noemit-v1','npm-typescript-direct-v1','yarn-workspace-tsc-v1','pnpm-tsc-v1']
|
|
390
|
+
.includes(row.plan.qualification)?
|
|
391
|
+
'TypeScript input set, generated file, or toolchain context changed':
|
|
392
|
+
'declared input content or membership changed');
|
|
393
|
+
row.staleReasonKey=key;
|
|
394
|
+
}
|
|
395
|
+
item.reason=row.staleReason;item.changed_inputs=row.staleInputs||[];
|
|
396
|
+
if(unconfirmed&&!(r.files&&row.staleInputs?.length)){item.freshness='UNVERIFIED';
|
|
397
|
+
item.reason=unconfirmed;item.changed_inputs=[];}
|
|
398
|
+
} else if(unconfirmed){item.freshness='UNVERIFIED';item.reason=unconfirmed;
|
|
399
|
+
} else {item.freshness='CURRENT';item.reason=result==='FAIL'?'last command failed':'declared inputs match';
|
|
400
|
+
item.reuse_eligible=result==='PASS';}
|
|
401
|
+
return item;
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
// The indexed inputs already differ from the latest receipt, so no probe result
|
|
405
|
+
// can make this check CURRENT. A sync answers STALE without a full probe. A run
|
|
406
|
+
// probes, or builds the certificate from its recording (adopt), and a revert
|
|
407
|
+
// makes the inputs match and probes again.
|
|
408
|
+
function knownStale(name,row) {
|
|
409
|
+
const r=receipts[name];
|
|
410
|
+
if(!r?.files||r.planId!==row.plan.id||row.probeError||pendingChecks.has(name)||
|
|
411
|
+
row.probeCache?.input===inputKey(row))return false;
|
|
412
|
+
const current=combinedFiles(name),files=Object.keys(current);
|
|
413
|
+
return files.length!==Object.keys(r.files).length||files.some(file=>r.files[file]!==current[file]);
|
|
414
|
+
}
|
|
415
|
+
// A run can rebuild a certificate that no longer applies only because indexed
|
|
416
|
+
// inputs changed: same plan guard, compiler, toolchain and observation.
|
|
417
|
+
function adoptable(row) {
|
|
418
|
+
const cache=row.probeCache;
|
|
419
|
+
if(process.platform==='win32'||row.plan.qualification!=='npm-typescript-direct-v1'||
|
|
420
|
+
row.plan.unresolved.length||row.probeError||cache?.schema!==1||!cache.layout||!cache.queries?.length||
|
|
421
|
+
cache.input===inputKey(row)||cache.gaps!==metrics.gaps||!continuityBarrier())return false;
|
|
422
|
+
try{const basis=certificateBasis(row);
|
|
423
|
+
return basis.guard===cache.guard&&basis.compiler===cache.compiler&&basis.toolchain===cache.toolchain;}
|
|
424
|
+
catch{return false;}
|
|
425
|
+
}
|
|
426
|
+
// The end checkpoint of a run that started without a certificate. Its recorded
|
|
427
|
+
// answers become the candidate certificate when nothing observed changed during
|
|
428
|
+
// the run. The probe that follows validates it, or runs in full as before.
|
|
429
|
+
function adopt(name,row,token) {
|
|
430
|
+
const started=performance.now(),pending=row.adoption,file=path.join(state,`run-record-${token}.json`);
|
|
431
|
+
row.adoption=null;
|
|
432
|
+
let text=null;
|
|
433
|
+
try{text=fs.readFileSync(file,'utf8');}catch{}
|
|
434
|
+
const done=issue=>{timing('daemon.recorded_certificate',started,{outcome:issue?'rejected':'accepted',
|
|
435
|
+
...(issue?{reason:issue}:{})});return issue;};
|
|
436
|
+
if(!pending||pending.token!==token||text===null)return done('recording unavailable');
|
|
437
|
+
if(inputEventSerial.get(name)!==pending.serial||metrics.gaps!==pending.gaps||inputKey(row)!==pending.input||
|
|
438
|
+
row.probeCache!==pending.cache||row.probeHash!==null)
|
|
439
|
+
return done('inputs or certificate changed during the run');
|
|
440
|
+
const cache=pending.cache,argv=row.plan.probes[0];
|
|
441
|
+
let result;
|
|
442
|
+
try{result=recordedCertificate(text,cache,directProject(argv[2],argv[3],{verify:false}));}
|
|
443
|
+
catch{return done('recording unreadable');}
|
|
444
|
+
if(result.issue)return done(result.issue);
|
|
445
|
+
const identities=new Map(cache.identities);
|
|
446
|
+
for(const changed of result.changed)identities.delete(changed);
|
|
447
|
+
row.probeCache={...cache,hash:directProbeValue(argv,result.output,cache.stderrHash),
|
|
448
|
+
queries:result.queries,identities,output:result.output,layout:result.layout,input:pending.input,recorded:token,
|
|
449
|
+
queryData:deflateSync(JSON.stringify(result.queries)).toString('base64')};
|
|
450
|
+
metrics.recordedCertificates=(metrics.recordedCertificates||0)+1;
|
|
451
|
+
return done(null);
|
|
452
|
+
}
|
|
453
|
+
function status(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=false) {
|
|
454
|
+
// Reload notifications accelerate visibility but are not required for it: a
|
|
455
|
+
// wrapper may persist its outcome even when its control connection is denied.
|
|
456
|
+
refreshReceipts();
|
|
457
|
+
const checks=rows(contextHashes,synchronizedInstalled,synchronizedFilesystem), current=checks.filter(r=>r.freshness==='CURRENT'&&r.result==='PASS').length,
|
|
458
|
+
failed=checks.filter(r=>r.freshness==='CURRENT'&&r.result==='FAIL').length,
|
|
459
|
+
observedFailed=checks.filter(r=>r.result==='FAIL').length,
|
|
460
|
+
stale=checks.filter(r=>r.freshness==='STALE').length,
|
|
461
|
+
unverified=checks.filter(r=>r.freshness==='UNVERIFIED').length||(!checks.length?1:0);
|
|
462
|
+
const stateName=failed?'failed':unverified?'unverified':stale?'stale':'current';
|
|
463
|
+
const now=Date.now();return {schema:1,receipt_revision:receiptStateRevision,state:stateName,current,failed,observed_failed:observedFailed,
|
|
464
|
+
stale,unverified,checks,
|
|
465
|
+
observation:{healthy:healthy&&!planning&&!recovering&&!index?.unavailable,
|
|
466
|
+
generation,observed_at:lastObservation,
|
|
467
|
+
events:(index?.eventCount||0)+[...external.values()].reduce((n,e)=>n+(e.index?.eventCount||0),0),
|
|
468
|
+
pending:pending.size+[...external.values()].reduce((n,e)=>n+e.pending.size,0),
|
|
469
|
+
reason:healthy?index?.unavailable:reason,
|
|
470
|
+
external:[...external.values()].filter(e=>e.index).map(e=>({root:e.root,
|
|
471
|
+
healthy:e.healthy,reason:e.reason})),
|
|
472
|
+
phase:recovering?'reconciling':metrics.planPhase||'initializing',scanned:metrics.planScannedFiles||0,
|
|
473
|
+
total:metrics.planTotalFiles},
|
|
474
|
+
updated_at:now,expires_at:now+3000,pid:process.pid};
|
|
475
|
+
}
|
|
476
|
+
function publish() {atomic(path.join(state,'status.json'),status());}
|
|
477
|
+
function hydrate(bundle,saved) {
|
|
478
|
+
const next=new InputIndex(bundle,{trackGit:bundle.root===root,
|
|
479
|
+
includeInstalled:bundle.root!==root});
|
|
480
|
+
next.files=new Map(saved.files);
|
|
481
|
+
for(const [alias,refs] of saved.aliasDependencies||[]){
|
|
482
|
+
next.aliasDependencies.set(alias,new Set(refs));
|
|
483
|
+
for(const reference of refs){
|
|
484
|
+
if(!next.symlinkRefs.has(reference))next.symlinkRefs.set(reference,new Set());
|
|
485
|
+
next.symlinkRefs.get(reference).add(alias);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
for(const rowData of saved.rows) {
|
|
489
|
+
const row=next.checks.get(rowData.name);
|
|
490
|
+
row.files=new Set(rowData.files);row.sum=Buffer.from(rowData.sum,'hex');
|
|
491
|
+
row.revision=rowData.revision;row.lastReason=rowData.lastReason;
|
|
492
|
+
for(const rel of row.files) {
|
|
493
|
+
if(!next.members.has(rel))next.members.set(rel,new Set());
|
|
494
|
+
next.members.get(rel).add(rowData.name);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
next.eventCount=saved.eventCount;next.rehashedFiles=saved.rehashedFiles;
|
|
498
|
+
next.rehashedBytes=saved.rehashedBytes;next.profile=saved.profile;
|
|
499
|
+
return next;
|
|
500
|
+
}
|
|
501
|
+
function dehydrate(value) {
|
|
502
|
+
return {files:[...value.files],
|
|
503
|
+
aliasDependencies:[...value.aliasDependencies].map(([name,refs])=>[name,[...refs]]),
|
|
504
|
+
rows:[...value.checks].map(([name,row])=>({name,files:[...row.files],
|
|
505
|
+
sum:row.sum.toString('hex'),revision:row.revision,lastReason:row.lastReason})),
|
|
506
|
+
eventCount:value.eventCount,rehashedFiles:value.rehashedFiles,
|
|
507
|
+
rehashedBytes:value.rehashedBytes,profile:value.profile};
|
|
508
|
+
}
|
|
509
|
+
function retainIndex() {
|
|
510
|
+
if(!index||!healthy||planning||pending.size||planDirty||historyDisabled||
|
|
511
|
+
!fs.existsSync(snapshotPath))return;
|
|
512
|
+
const outside={};
|
|
513
|
+
for(const entry of external.values()){
|
|
514
|
+
if(!entry.index||!entry.healthy||entry.pending.size||!fs.existsSync(entry.snapshot))return;
|
|
515
|
+
outside[entry.root]={cursor:sha(fs.readFileSync(entry.snapshot)),
|
|
516
|
+
index:dehydrate(entry.index)};
|
|
517
|
+
}
|
|
518
|
+
const current=fs.statSync(root,{bigint:true});
|
|
519
|
+
atomic(path.join(state,'index-v1.json'),{schema:1,root,
|
|
520
|
+
identity:[String(current.dev),String(current.ino)],
|
|
521
|
+
planIds:Object.fromEntries([...index.checks].map(([name,row])=>[name,row.plan.id])),
|
|
522
|
+
cursor:sha(fs.readFileSync(snapshotPath)),external:outside,index:dehydrate(index)});
|
|
523
|
+
}
|
|
524
|
+
function buildInWorker(forceCold=false,reuse=null) {
|
|
525
|
+
return new Promise((resolve,reject)=>{
|
|
526
|
+
const workerStarted=performance.now();
|
|
527
|
+
const file=path.join(path.dirname(fileURLToPath(import.meta.url)),'plan-worker.mjs');
|
|
528
|
+
const worker=new Worker(file,{workerData:{config:configFile,state,forceCold,reuse,diagnostic:traceContext(),
|
|
529
|
+
reconcileFaultFile:process.env.VSTATE_TEST_RECONCILE_FAULT_FILE}});
|
|
530
|
+
planningWorker=worker;let settled=false;
|
|
531
|
+
const deadline=setTimeout(()=>{
|
|
532
|
+
if(settled)return;settled=true;planningWorker=null;
|
|
533
|
+
worker.terminate().catch(()=>{});
|
|
534
|
+
reject(Error(`deterministic reconciliation exceeded ${reconciliationDeadlineMs} ms`));
|
|
535
|
+
},reconciliationDeadlineMs);
|
|
536
|
+
worker.on('message',value=>{
|
|
537
|
+
if(value.kind==='progress') {
|
|
538
|
+
if(stopping||identityChanged||gapDuringPlan)return;
|
|
539
|
+
metrics.planPhase=value.phase;metrics.planScannedFiles=value.scanned;
|
|
540
|
+
metrics.planTotalFiles=value.total;
|
|
541
|
+
if(value.discoveryMs!=null)metrics.lastDiscoveryMs=value.discoveryMs;
|
|
542
|
+
reason='input plan indexing';publish();return;
|
|
543
|
+
}
|
|
544
|
+
if(settled)return;settled=true;clearTimeout(deadline);planningWorker=null;
|
|
545
|
+
if(value.ok){value.workerRoundTripMs=performance.now()-workerStarted;
|
|
546
|
+
resolve(value);}else reject(Error(value.error));});
|
|
547
|
+
worker.once('error',error=>{if(!settled){settled=true;clearTimeout(deadline);
|
|
548
|
+
planningWorker=null;reject(error);}});
|
|
549
|
+
worker.once('exit',code=>{if(!settled){settled=true;clearTimeout(deadline);planningWorker=null;
|
|
550
|
+
reject(Error(`input-plan worker exited ${code}`));}});
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
async function refreshPlan({forceCold=false,reuse=false,skip=null}={}) {
|
|
554
|
+
if(planning||stopping)return;
|
|
555
|
+
const totalStarted=performance.now();
|
|
556
|
+
const previousProbes=new Map([...(index?.checks||[])].map(([name,row])=>[name,row.probeCache]));
|
|
557
|
+
const reusable=reuse&&healthy&&!planDirty&&!external.size&&validatedPlan&&
|
|
558
|
+
[...index.checks.values()].every(row=>!row.plan.qualification||row.probeCache)?
|
|
559
|
+
{...validatedPlan,bundle:index.bundle,
|
|
560
|
+
queries:[...index.checks.values()].flatMap(row=>row.probeCache?.queries||[])}:null;
|
|
561
|
+
planning=true;planDirty=false;gapDuringPlan=false;pending.clear();pendingChecks.clear();
|
|
562
|
+
for(const entry of external.values())entry.gap=!entry.watcher;
|
|
563
|
+
metrics.planPhase='discovery';metrics.planScannedFiles=0;metrics.planTotalFiles=null;
|
|
564
|
+
healthy=false;reason='input plan rebuilding';publish();
|
|
565
|
+
try {const workerStarted=performance.now();
|
|
566
|
+
const built=await buildInWorker(forceCold,reusable);
|
|
567
|
+
timing('daemon.plan_worker',workerStarted,{forceCold:forceCold?1:0,
|
|
568
|
+
discoveryMs:Math.round(built.discoveryMs),indexingMs:Math.round(built.indexingMs),
|
|
569
|
+
mode:built.restartMode});
|
|
570
|
+
if(stopping)return;
|
|
571
|
+
if(!verifyRoot()) {planning=false;metrics.planPhase='unavailable';publish();return;}
|
|
572
|
+
if(gapDuringPlan){planning=false;observationGap('plan_gap');return;}
|
|
573
|
+
const {bundle}=built;
|
|
574
|
+
workspacePatterns=bundle.workspacePatterns||workspacePatterns;
|
|
575
|
+
const hydrationStarted=performance.now();
|
|
576
|
+
index=hydrate(bundle,built.index);
|
|
577
|
+
validatedPlan=built.guard?{guard:built.guard,inputs:built.inputKeys}:null;
|
|
578
|
+
if(built.planReused)for(const [name,row] of index.checks)
|
|
579
|
+
row.probeCache=previousProbes.get(name);
|
|
580
|
+
inputEventSerial=new Map([...index.checks.keys()].map(name=>
|
|
581
|
+
[name,inputEventSerial.get(name)||0]));
|
|
582
|
+
for(const entry of external.values()) {
|
|
583
|
+
const data=built.external?.[entry.root];
|
|
584
|
+
entry.index=data?hydrate({root:entry.root,plans:Object.fromEntries(
|
|
585
|
+
Object.entries(bundle.plans).filter(([,plan])=>(plan.externalObservationRoots||
|
|
586
|
+
Object.keys(plan.externalPatterns||{})).includes(entry.root))
|
|
587
|
+
.map(([name,plan])=>[name,{...plan,patterns:plan.externalPatterns[entry.root],
|
|
588
|
+
sourcePatterns:[],
|
|
589
|
+
additivePatterns:plan.externalPatterns[entry.root]||[],
|
|
590
|
+
installedPhysicalRoots:[]}]))},data):null;
|
|
591
|
+
entry.healthy=false;entry.reason='external reconciliation pending';
|
|
592
|
+
}
|
|
593
|
+
metrics.lastHydrationMs=performance.now()-hydrationStarted;
|
|
594
|
+
metrics.lastWorkerRoundTripMs=built.workerRoundTripMs;
|
|
595
|
+
metrics.lastDiscoveryMs=built.discoveryMs;metrics.lastIndexingMs=built.indexingMs;
|
|
596
|
+
metrics.restartMode=built.restartMode||'cold-index';
|
|
597
|
+
metrics.planPhase='reconciling';
|
|
598
|
+
if(!built.planReused)metrics.planRebuilds++;
|
|
599
|
+
metrics.reconciliations++;
|
|
600
|
+
// Linux attaches per-inode watches before rechecking installed hardlinks.
|
|
601
|
+
// The Windows backend has no per-inode watchFiles implementation; the
|
|
602
|
+
// just-completed cold index already read those bytes. Windows still
|
|
603
|
+
// withholds cached CURRENT and reconciles at each decision-grade read.
|
|
604
|
+
if(!hasHistory&&watcher&&process.platform!=='win32'){
|
|
605
|
+
const inodeStarted=performance.now();
|
|
606
|
+
const checked=[...index.checks].filter(([,row])=>
|
|
607
|
+
row.plan.synchronizedInstalledRead).map(([name])=>name);
|
|
608
|
+
const selected=new Set(checked);
|
|
609
|
+
const files=[...index.files.keys()].filter(rel=>index.installedPath(rel)&&
|
|
610
|
+
[...index.members.get(rel)||[]].some(name=>selected.has(name)))
|
|
611
|
+
.map(rel=>path.join(root,rel));
|
|
612
|
+
await watcher.watchFiles(files);
|
|
613
|
+
metrics.installedInodeWatches=files.length;
|
|
614
|
+
if(checked.length)metrics.initialInstalledRecheck=await index.recheckInstalled(checked);
|
|
615
|
+
timing('daemon.installed_inode_setup',inodeStarted,{watches:files.length,
|
|
616
|
+
recheckMs:Math.round(metrics.initialInstalledRecheck?.durationMs||0)});
|
|
617
|
+
}
|
|
618
|
+
triggerHashes=new Map([...bundle.workspaceManifests,...(bundle.configurationInputs||[]),
|
|
619
|
+
...Object.values(bundle.plans).flatMap(plan=>plan.resolutionTriggers||[]),
|
|
620
|
+
'package.json','package-lock.json'].map(rel=>[rel,triggerHash(rel)]));
|
|
621
|
+
candidateDescendants=new Map();
|
|
622
|
+
for(const plan of Object.values(bundle.plans))for(const candidate of
|
|
623
|
+
plan.resolutionCandidates||[])for(const observed of
|
|
624
|
+
new Set([candidate.path,candidate.observedPath].filter(Boolean))){
|
|
625
|
+
const parts=observed.split('/');
|
|
626
|
+
for(let n=1;n<=parts.length;n++){
|
|
627
|
+
const prefix=parts.slice(0,n).join('/');
|
|
628
|
+
if(!candidateDescendants.has(prefix))candidateDescendants.set(prefix,new Set());
|
|
629
|
+
candidateDescendants.get(prefix).add(observed);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
const persistenceStarted=performance.now();
|
|
633
|
+
atomic(path.join(state,'plans-v1.json'),persistedPlan(bundle));
|
|
634
|
+
metrics.lastPlanPersistenceMs=performance.now()-persistenceStarted;
|
|
635
|
+
const probeStarted=performance.now();
|
|
636
|
+
if(skip)refreshReceipts();
|
|
637
|
+
for(const [name,row] of index.checks)if(!skip?.(name,row))probe(row,built.planReused);
|
|
638
|
+
timing('daemon.plan_probes',probeStarted,{checks:index.checks.size});
|
|
639
|
+
lastObservation=Date.now();
|
|
640
|
+
event(built.planReused?'plan_validated':'plan_rebuilt',{checks:Object.keys(bundle.plans),workspace_count:bundle.workspaceCount});
|
|
641
|
+
const catchUpStarted=performance.now();
|
|
642
|
+
if(watcher&&(!historyDisabled||!hasHistory))try{await catchUp();}
|
|
643
|
+
catch(e){planning=false;observationGap('historical_query',e);return;}
|
|
644
|
+
metrics.lastCatchUpMs=performance.now()-catchUpStarted;
|
|
645
|
+
if(gapDuringPlan){planning=false;observationGap('plan_gap');return;}
|
|
646
|
+
if(planDirty) {
|
|
647
|
+
pending.clear();planning=false;publish();setImmediate(refreshPlan);return;
|
|
648
|
+
}
|
|
649
|
+
planning=false;
|
|
650
|
+
if(pending.size||[...external.values()].some(e=>e.pending.size))flush();
|
|
651
|
+
if(!index.unavailable&&watcher&&!reason?.startsWith('input inspection unavailable')){
|
|
652
|
+
healthy=true;reason=null;lastObservation=Date.now();}
|
|
653
|
+
for(const entry of external.values())if(entry.index&&entry.watcher&&!entry.gap&&
|
|
654
|
+
!entry.index.unavailable){
|
|
655
|
+
entry.healthy=true;entry.reason=null;}
|
|
656
|
+
if(healthy){identityChanged=false;identityGapKey=null;
|
|
657
|
+
if(!recovering)recoveryAttempts=0;}
|
|
658
|
+
metrics.planPhase=healthy?'ready':'unavailable';
|
|
659
|
+
} catch(e) {if(!stopping){reason=`plan unavailable: ${e.message}`;
|
|
660
|
+
metrics.planPhase='unavailable';event('plan_failed',{classification:e.name||'Error',reason:e.message});}}
|
|
661
|
+
planning=false;publish();
|
|
662
|
+
timing('daemon.plan_total',totalStarted,{forceCold:forceCold?1:0,
|
|
663
|
+
healthy:healthy?1:0,files:index?.files.size||0});
|
|
664
|
+
}
|
|
665
|
+
function triggerHash(rel) {
|
|
666
|
+
try{const file=path.isAbsolute(rel)?rel:path.join(root,rel),st=fs.lstatSync(file);
|
|
667
|
+
if(st.isSymbolicLink())return sha('link:'+fs.readlinkSync(file));
|
|
668
|
+
if(st.isDirectory())return sha('directory:'+String(st.dev)+':'+String(st.ino));
|
|
669
|
+
return sha(fs.readFileSync(file));}
|
|
670
|
+
catch(e){if(e.code==='ENOENT')return '<missing>';throw e;}
|
|
671
|
+
}
|
|
672
|
+
function triggerChanged(rel) {
|
|
673
|
+
try{const current=triggerHash(rel),previous=triggerHashes.get(rel);
|
|
674
|
+
if(current===previous)return false;
|
|
675
|
+
triggerHashes.set(rel,current);return true;
|
|
676
|
+
}catch(e){observationGap('plan_trigger_unreadable',e);return true;}
|
|
677
|
+
}
|
|
678
|
+
function workspaceManifest(rel) {
|
|
679
|
+
return rel.endsWith('/package.json')&&
|
|
680
|
+
micromatch.isMatch(path.posix.dirname(rel),workspacePatterns,{dot:true});
|
|
681
|
+
}
|
|
682
|
+
function planTrigger(rel) {
|
|
683
|
+
return triggerHashes.has(rel)||rel==='package.json'||
|
|
684
|
+
rel==='package-lock.json'||workspaceManifest(rel);
|
|
685
|
+
}
|
|
686
|
+
function candidateTriggerChanged(rel) {
|
|
687
|
+
let changed=false;
|
|
688
|
+
const descendants=candidateDescendants.get(rel);
|
|
689
|
+
// A broad directory replacement can affect thousands of candidates. Fail
|
|
690
|
+
// closed and rebuild instead of blocking the control loop on that event.
|
|
691
|
+
if(descendants?.size>512)return true;
|
|
692
|
+
for(const candidate of descendants||[])
|
|
693
|
+
if(triggerChanged(candidate))changed=true;
|
|
694
|
+
const parts=rel.split('/');
|
|
695
|
+
for(let n=1;n<parts.length;n++){
|
|
696
|
+
const candidate=parts.slice(0,n).join('/');
|
|
697
|
+
if(triggerHashes.has(candidate)&&candidateDescendants.has(candidate)&&
|
|
698
|
+
triggerChanged(candidate))changed=true;
|
|
699
|
+
}
|
|
700
|
+
return changed;
|
|
701
|
+
}
|
|
702
|
+
function flush() {
|
|
703
|
+
clearTimeout(flushTimer);flushTimer=null;
|
|
704
|
+
if(planning)return;
|
|
705
|
+
if(!index||planDirty){pending.clear();for(const e of external.values())e.pending.clear();
|
|
706
|
+
refreshPlan();return;}
|
|
707
|
+
const wasHealthy=healthy;
|
|
708
|
+
const paths=[...pending];pending.clear();metrics.flushes++;
|
|
709
|
+
try {for(const rel of paths)index.updatePath(rel);
|
|
710
|
+
for(const entry of external.values()){
|
|
711
|
+
for(const rel of entry.pending)entry.index?.updatePath(rel);
|
|
712
|
+
entry.pending.clear();
|
|
713
|
+
if(entry.index?.unavailable){entry.healthy=false;entry.reason=entry.index.unavailable;
|
|
714
|
+
externalGap(entry,'input_inspection');}}
|
|
715
|
+
if(index.unavailable)throw Error(index.unavailable);
|
|
716
|
+
pendingChecks.clear();healthy=wasHealthy&&Boolean(watcher)&&
|
|
717
|
+
(historyDisabled||fs.existsSync(snapshotPath));
|
|
718
|
+
if(healthy)reason=null;
|
|
719
|
+
lastObservation=Date.now();
|
|
720
|
+
} catch(e) {healthy=false;reason=`input inspection unavailable: ${e.message}`;
|
|
721
|
+
metrics.gaps++;event('observation_gap',{classification:'input_inspection'});
|
|
722
|
+
setTimeout(()=>{if(!stopping)refreshPlan();},1000);}
|
|
723
|
+
publish();
|
|
724
|
+
}
|
|
725
|
+
function pnpmTopologyChanged(type,rel) {
|
|
726
|
+
if(!config.checks.some(check=>check.qualification==='pnpm-tsc-v1')||
|
|
727
|
+
!rel.split('/').includes('node_modules'))return false;
|
|
728
|
+
if(type==='create'||type==='delete')return true;
|
|
729
|
+
try{const stat=fs.lstatSync(path.join(root,rel));
|
|
730
|
+
return stat.isDirectory()||stat.isSymbolicLink();}
|
|
731
|
+
catch(e){return e.code==='ENOENT';}
|
|
732
|
+
}
|
|
733
|
+
function markInputEvent(name,rel,type,replay) {
|
|
734
|
+
pendingChecks.add(name);
|
|
735
|
+
// macOS delivers each edit twice: live, and again from history replay. The
|
|
736
|
+
// live copy can arrive late, inside a later run. With history, count only the
|
|
737
|
+
// replay copy: the history cursor places it in the right interval.
|
|
738
|
+
if(!hasHistory||historyDisabled||replay)
|
|
739
|
+
inputEventSerial.set(name,(inputEventSerial.get(name)||0)+1);
|
|
740
|
+
// Disposable public CI only. Never enabled for normal/local repositories.
|
|
741
|
+
if(process.env.REDUE_PUBLIC_INPUT_TRACE==='1'&&rel&&diagnosticInputEvents++<5000)
|
|
742
|
+
event('public_input_notification',{path:rel,type:type||'unknown',planning,replay});
|
|
743
|
+
}
|
|
744
|
+
function notification(type,filename,replay=false) {
|
|
745
|
+
metrics.notifications++;
|
|
746
|
+
metrics.lastNotificationAt=Date.now();
|
|
747
|
+
if(!filename){observationGap('unnamed_event');return;}
|
|
748
|
+
const rel=filename.toString().split(path.sep).join('/');
|
|
749
|
+
if(rel==='.git'||rel.startsWith('.git/'))return;
|
|
750
|
+
if(planning) {
|
|
751
|
+
if(index)for(const name of index.candidates(rel))
|
|
752
|
+
if(index.matches(index.checks.get(name),rel))markInputEvent(name,rel,type,replay);
|
|
753
|
+
pending.add(rel);metrics.maxQueue=Math.max(metrics.maxQueue,pending.size);
|
|
754
|
+
if(planTrigger(rel)||candidateDescendants.has(rel)||pnpmTopologyChanged(type,rel))
|
|
755
|
+
planDirty=true;
|
|
756
|
+
if(pending.size>50000)observationGap('backlog_overflow');
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
try {if(fs.statSync(path.join(root,rel)).isDirectory()&&
|
|
760
|
+
micromatch.isMatch(rel,workspacePatterns,{dot:true})&&
|
|
761
|
+
fs.existsSync(path.join(root,rel,'package.json'))&&
|
|
762
|
+
triggerChanged(rel+'/package.json'))planDirty=true;}
|
|
763
|
+
catch(e){if(e.code!=='ENOENT') {healthy=false;reason='filesystem event path unavailable';publish();return;}}
|
|
764
|
+
if(index&&[...index.checks.values()].some(row=>row.plan.patterns.includes(rel+'/package.json'))&&
|
|
765
|
+
!fs.existsSync(path.join(root,rel))&&triggerChanged(rel+'/package.json'))planDirty=true;
|
|
766
|
+
if(planTrigger(rel))
|
|
767
|
+
if(triggerChanged(rel))planDirty=true;
|
|
768
|
+
if(candidateTriggerChanged(rel))planDirty=true;
|
|
769
|
+
// A newly installed package or nearer Node candidate may change a compiler
|
|
770
|
+
// file list even when it was absent from the previous input plan. The store
|
|
771
|
+
// outside this checkout is not observed and cannot cause this refresh.
|
|
772
|
+
if(pnpmTopologyChanged(type,rel))planDirty=true;
|
|
773
|
+
pending.add(rel);metrics.maxQueue=Math.max(metrics.maxQueue,pending.size);
|
|
774
|
+
if(index) {
|
|
775
|
+
for(const name of index.candidates(rel))
|
|
776
|
+
if(index.matches(index.checks.get(name),rel))markInputEvent(name,rel,type,replay);
|
|
777
|
+
for(const [prefix,names] of index.prefixes)
|
|
778
|
+
if(prefix.startsWith(rel+'/'))for(const name of names)markInputEvent(name,rel,type,replay);
|
|
779
|
+
}
|
|
780
|
+
if(pending.size>50000){healthy=false;reason='observation backlog overflow';metrics.gaps++;
|
|
781
|
+
event('observation_gap',{classification:'backlog_overflow'});publish();
|
|
782
|
+
pending.clear();setTimeout(()=>refreshPlan(),100);return;}
|
|
783
|
+
clearTimeout(flushTimer);flushTimer=setTimeout(flush,60);
|
|
784
|
+
if(planDirty){healthy=false;reason='input plan possibly changed';}
|
|
785
|
+
if(planDirty||pendingChecks.size)publish();
|
|
786
|
+
}
|
|
787
|
+
function externalNotification(entry,type,filename,replay=false) {
|
|
788
|
+
metrics.notifications++;
|
|
789
|
+
metrics.lastNotificationAt=Date.now();
|
|
790
|
+
if(!filename){externalGap(entry,'unnamed_event');return;}
|
|
791
|
+
const rel=filename.toString().split(path.sep).join('/');
|
|
792
|
+
const absolute=path.join(entry.root,rel);
|
|
793
|
+
if(planning&&entry.index)for(const name of entry.index.candidates(rel))
|
|
794
|
+
if(entry.index.matches(entry.index.checks.get(name),rel))markInputEvent(name,rel,type,replay);
|
|
795
|
+
if(planTrigger(absolute)&&triggerChanged(absolute))planDirty=true;
|
|
796
|
+
if(candidateTriggerChanged(absolute))planDirty=true;
|
|
797
|
+
entry.pending.add(rel);metrics.maxQueue=Math.max(metrics.maxQueue,
|
|
798
|
+
pending.size+[...external.values()].reduce((n,e)=>n+e.pending.size,0));
|
|
799
|
+
if(entry.index) {
|
|
800
|
+
for(const name of entry.index.candidates(rel))
|
|
801
|
+
if(entry.index.matches(entry.index.checks.get(name),rel))markInputEvent(name,rel,type,replay);
|
|
802
|
+
for(const [prefix,names] of entry.index.prefixes)
|
|
803
|
+
if(prefix.startsWith(rel+'/'))for(const name of names)markInputEvent(name,rel,type,replay);
|
|
804
|
+
}
|
|
805
|
+
if(planDirty){healthy=false;reason='input plan possibly changed';}
|
|
806
|
+
publish();clearTimeout(flushTimer);flushTimer=setTimeout(flush,60);
|
|
807
|
+
}
|
|
808
|
+
function externalGap(entry,classification,error) {
|
|
809
|
+
entry.healthy=false;entry.gap=true;
|
|
810
|
+
entry.reason=`external observation unavailable: ${classification}`;
|
|
811
|
+
entry.watcher?.unsubscribe().catch(()=>{});entry.watcher=null;
|
|
812
|
+
metrics.gaps++;event('observation_gap',{classification,external_root:entry.root,
|
|
813
|
+
error_code:error?.code||null});publish();
|
|
814
|
+
observationGap(`external_${classification}`,error);
|
|
815
|
+
}
|
|
816
|
+
function observationGap(classification,error) {
|
|
817
|
+
if(planning)gapDuringPlan=true;
|
|
818
|
+
// Once continuity is uncertain, a cursor is no longer authority. Do not
|
|
819
|
+
// retry native history in this observer generation after reconciliation.
|
|
820
|
+
historyDisabled=true;
|
|
821
|
+
const cause=error?.message?` (${error.message})`:'';
|
|
822
|
+
healthy=false;reason=`observation unavailable: ${classification}${cause}; deterministic reconciliation pending`;
|
|
823
|
+
metrics.gaps++;
|
|
824
|
+
event('observation_gap',{classification,error_code:error?.code||null});publish();
|
|
825
|
+
// One bounded attempt per gap; a later independent gap may try again.
|
|
826
|
+
if(!stopping&&!recovering&&!recoveryTimer&&recoveryAttempts<2)
|
|
827
|
+
recoveryTimer=setTimeout(()=>recoverObservation(classification),100);
|
|
828
|
+
}
|
|
829
|
+
async function recoverObservation(classification) {
|
|
830
|
+
recoveryTimer=null;if(stopping||recovering)return;
|
|
831
|
+
recovering=true;recoveryAttempts++;
|
|
832
|
+
event('reconciliation_started',{classification,attempt:recoveryAttempts});
|
|
833
|
+
try {
|
|
834
|
+
const waitStarted=Date.now();
|
|
835
|
+
while(planning&&!stopping&&Date.now()-waitStarted<reconciliationDeadlineMs)
|
|
836
|
+
await new Promise(resolve=>setTimeout(resolve,50));
|
|
837
|
+
if(stopping)return;
|
|
838
|
+
if(planning)throw Error('prior indexing did not settle before reconciliation deadline');
|
|
839
|
+
const current=fs.statSync(root,{bigint:true});
|
|
840
|
+
if(current.dev!==rootIdentity.dev||current.ino!==rootIdentity.ino){
|
|
841
|
+
watcher?.unsubscribe().catch(()=>{});watcher=null;
|
|
842
|
+
rootIdentity=current;identityChanged=true;
|
|
843
|
+
event('observation_root_replaced');
|
|
844
|
+
}
|
|
845
|
+
if(!watcher)await attachWatch();
|
|
846
|
+
if(!watcher)throw Error(`root subscription unavailable: ${reason}`);
|
|
847
|
+
for(const entry of external.values()){
|
|
848
|
+
const identity=fs.statSync(entry.root,{bigint:true});
|
|
849
|
+
if(entry.watcher&&(identity.dev!==entry.identity?.dev||
|
|
850
|
+
identity.ino!==entry.identity?.ino)){
|
|
851
|
+
entry.watcher.unsubscribe().catch(()=>{});entry.watcher=null;
|
|
852
|
+
event('external_observation_root_replaced',{external_root:entry.root});
|
|
853
|
+
}
|
|
854
|
+
if(!entry.watcher)await attachExternal(entry);
|
|
855
|
+
if(!entry.watcher)throw Error(`external subscription unavailable: ${entry.root}`);
|
|
856
|
+
}
|
|
857
|
+
// The live subscriptions are attached before the fresh scan. Changes
|
|
858
|
+
// delivered during indexing are queued and rechecked before health returns.
|
|
859
|
+
await refreshPlan({forceCold:true});
|
|
860
|
+
if(process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_RECOVERY_FINISH_DELAY)
|
|
861
|
+
await new Promise(resolve=>setTimeout(resolve,Math.min(2000,
|
|
862
|
+
Number(process.env.VSTATE_TEST_RECOVERY_FINISH_DELAY)||0)));
|
|
863
|
+
if(process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_RECOVERY_FINALIZE_FAULT&&
|
|
864
|
+
fs.existsSync(process.env.VSTATE_TEST_RECOVERY_FINALIZE_FAULT))
|
|
865
|
+
throw Error('injected recovery finalization failure');
|
|
866
|
+
if(!healthy||[...external.values()].some(entry=>entry.index&&!entry.healthy))
|
|
867
|
+
throw Error(reason||'deterministic reconciliation unavailable');
|
|
868
|
+
rootIdentity=fs.statSync(root,{bigint:true});identityChanged=false;
|
|
869
|
+
recoveryAttempts=0;
|
|
870
|
+
event('reconciliation_completed',{classification});
|
|
871
|
+
}catch(e){healthy=false;reason=`deterministic reconciliation failed: ${e.message}; restart observer`;
|
|
872
|
+
event('reconciliation_failed',{classification,error_code:e.code||e.name||'Error',
|
|
873
|
+
reason:e.message,identityChanged,watcherAttached:Boolean(watcher),planPhase:metrics.planPhase});
|
|
874
|
+
publish();
|
|
875
|
+
}finally{recovering=false;publish();
|
|
876
|
+
if(!healthy&&!stopping&&recoveryAttempts<2&&!recoveryTimer)
|
|
877
|
+
recoveryTimer=setTimeout(()=>recoverObservation(classification),500);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
function verifyRoot() {
|
|
881
|
+
let observed='unavailable';
|
|
882
|
+
try {const current=fs.statSync(root,{bigint:true});
|
|
883
|
+
if(current.dev===rootIdentity.dev&¤t.ino===rootIdentity.ino)return true;
|
|
884
|
+
observed=String(current.dev)+':'+String(current.ino);
|
|
885
|
+
}catch(error){observed=error.code||'unavailable';}
|
|
886
|
+
// Suppress repeats for the same failed identity, not all future root
|
|
887
|
+
// replacements. A successful decision rebuild may follow a failed recovery.
|
|
888
|
+
if(identityGapKey!==observed){identityGapKey=observed;identityChanged=true;healthy=false;
|
|
889
|
+
if(!recovering)recoveryAttempts=0;
|
|
890
|
+
watcher?.unsubscribe().catch(()=>{});watcher=null;
|
|
891
|
+
observationGap('checkout_identity_changed');}
|
|
892
|
+
return false;
|
|
893
|
+
}
|
|
894
|
+
async function catchUp() {
|
|
895
|
+
if(!hasHistory){
|
|
896
|
+
if(!verifyRoot())throw Error('checkout identity changed');
|
|
897
|
+
if(!watcher)throw Error('filesystem observation unavailable');
|
|
898
|
+
await watcher.synchronize();
|
|
899
|
+
for(const entry of external.values())if(entry.index){
|
|
900
|
+
if(!entry.watcher)throw Error(`external observation unavailable: ${entry.root}`);
|
|
901
|
+
await entry.watcher.synchronize();
|
|
902
|
+
}
|
|
903
|
+
if(!planning&&(flushTimer||pending.size||planDirty||
|
|
904
|
+
[...external.values()].some(entry=>entry.pending.size)))flush();
|
|
905
|
+
lastObservation=Date.now();return;
|
|
906
|
+
}
|
|
907
|
+
if(historyDisabled)return;
|
|
908
|
+
if(synchronizing){mark('daemon.catch_up_join');return synchronizing;}
|
|
909
|
+
synchronizing=(async()=>{
|
|
910
|
+
if(!verifyRoot())throw Error('checkout identity changed');
|
|
911
|
+
if(!watcher)throw Error('subscription unavailable');
|
|
912
|
+
if(!fs.existsSync(snapshotPath))throw Error('historical cursor missing');
|
|
913
|
+
{
|
|
914
|
+
const changes=await queryHistory(root,snapshotPath,'root replay');
|
|
915
|
+
for(const change of changes)notification(change.type,path.relative(root,change.path),true);
|
|
916
|
+
if(!planning&&(flushTimer||pending.size||planDirty))flush();
|
|
917
|
+
for(const entry of external.values())if(entry.index) {
|
|
918
|
+
try{
|
|
919
|
+
const identity=fs.statSync(entry.root,{bigint:true});
|
|
920
|
+
if(identity.dev!==entry.identity?.dev||identity.ino!==entry.identity?.ino)
|
|
921
|
+
throw Error('external observation root identity changed');
|
|
922
|
+
if(!entry.watcher||!fs.existsSync(entry.snapshot))throw Error('external cursor unavailable');
|
|
923
|
+
const changes=await queryHistory(entry.root,entry.snapshot,'external replay');
|
|
924
|
+
for(const change of changes)externalNotification(entry,change.type,
|
|
925
|
+
path.relative(entry.root,change.path),true);
|
|
926
|
+
}catch(e){if(e.code==='ETIMEDOUT')throw e;
|
|
927
|
+
externalGap(entry,'historical_replay_failed',e);}
|
|
928
|
+
}
|
|
929
|
+
if(!planning&&(flushTimer||pending.size||planDirty||
|
|
930
|
+
[...external.values()].some(e=>e.pending.size)))flush();
|
|
931
|
+
lastObservation=Date.now();
|
|
932
|
+
}
|
|
933
|
+
})();
|
|
934
|
+
try{return await synchronizing;}finally{synchronizing=null;}
|
|
935
|
+
}
|
|
936
|
+
function respond(client,value) {
|
|
937
|
+
if(client.destroyed){event('control_reply_unavailable',{classification:'client_destroyed'});return;}
|
|
938
|
+
try{const payload=JSON.stringify(value)+'\n';
|
|
939
|
+
mark('server.response_constructed',{bytes:payload.length});
|
|
940
|
+
client.end(payload,()=>mark('server.response_delivered'));client.vstateReplied=true;
|
|
941
|
+
if(process.env.VSTATE_DEBUG_CONTROL==='1')event('control_reply_sent',{bytes:payload.length});}
|
|
942
|
+
catch(e){event('control_reply_unavailable',{classification:e.name||'serialization_error'});
|
|
943
|
+
if(!client.destroyed)client.end('{"error":"control response unavailable"}\n');}
|
|
944
|
+
}
|
|
945
|
+
async function request(message) {
|
|
946
|
+
if(message.action==='stop'){setImmediate(shutdown);return {ok:true};}
|
|
947
|
+
if(message.action==='metrics')return {generation,metrics,index:{files:index?.files.size,
|
|
948
|
+
rehashedFiles:index?.rehashedFiles,rehashedBytes:index?.rehashedBytes,
|
|
949
|
+
lastInputChangeAt:index?.lastChange,
|
|
950
|
+
profile:index?.profile},external:[...external.values()].map(e=>({root:e.root,
|
|
951
|
+
healthy:e.healthy,reason:e.reason,files:e.index?.files.size,
|
|
952
|
+
rehashedFiles:e.index?.rehashedFiles,rehashedBytes:e.index?.rehashedBytes})),
|
|
953
|
+
healthy,reason,planning,recovering,identityChanged,recoveryAttempts};
|
|
954
|
+
if(message.action==='reload'){loadReceipts();publish();return {ok:true};}
|
|
955
|
+
if(message.action==='sync'||message.action==='snapshot'||message.action==='prelaunch') {
|
|
956
|
+
const totalStarted=performance.now();
|
|
957
|
+
const decision=++decisionSequence;
|
|
958
|
+
mark('server.decision_start',{decision,pending:pending.size,planDirty,planning,recovering,healthy,historyDisabled,decisionRequests,historyDeadlineMs,reconciliationDeadlineMs});
|
|
959
|
+
timing('daemon.decision_reason',totalStarted,{decision,action:message.action,
|
|
960
|
+
plan:process.platform==='win32'?'validate_dependencies_before_reuse':'existing_plan',
|
|
961
|
+
probe:'reuse_only_with_identical_content_and_context',installed:'current_content_required',
|
|
962
|
+
planPhase:metrics.planPhase||'initializing',pending:pending.size,
|
|
963
|
+
notifications:metrics.notifications,planRebuilds:metrics.planRebuilds});
|
|
964
|
+
if(process.platform==='win32'&&process.env.VSTATE_TEST_FAULTS==='1'&&
|
|
965
|
+
process.env.VSTATE_TEST_WINDOWS_GAP_FILE&&
|
|
966
|
+
fs.existsSync(process.env.VSTATE_TEST_WINDOWS_GAP_FILE)){
|
|
967
|
+
fs.unlinkSync(process.env.VSTATE_TEST_WINDOWS_GAP_FILE);
|
|
968
|
+
observationGap('test_injected_windows_gap');
|
|
969
|
+
return status();
|
|
970
|
+
}
|
|
971
|
+
if(planning||recovering)return status();
|
|
972
|
+
// Without a usable continuity barrier, live-event delivery alone cannot
|
|
973
|
+
// establish a decision checkpoint. This also applies after macOS history
|
|
974
|
+
// has been disabled: a recovered subscription may still have queued events.
|
|
975
|
+
const needsContentReconciliation=process.platform==='win32'||(hasHistory&&historyDisabled);
|
|
976
|
+
if(needsContentReconciliation&&message.action!=='prelaunch'){
|
|
977
|
+
// Windows probes inside the reconciliation, so a known-STALE sync skips it there.
|
|
978
|
+
const started=performance.now();
|
|
979
|
+
await refreshPlan({forceCold:true,reuse:true,skip:message.action==='sync'?knownStale:null});
|
|
980
|
+
timing('daemon.request_content_validation',started,{healthy:healthy?1:0});}
|
|
981
|
+
// Without synchronized installed reads, the continuity barrier runs once,
|
|
982
|
+
// after probing: in-process validation reads the live filesystem, and the
|
|
983
|
+
// barrier then applies every change before the response is computed.
|
|
984
|
+
const deferBarrier=process.platform!=='win32'&&!needsContentReconciliation&&
|
|
985
|
+
message.action!=='prelaunch'&&index&&healthy&&!planning&&![...index.checks].some(([name,row])=>
|
|
986
|
+
row.plan.synchronizedInstalledRead&&(!message.name||message.name===name));
|
|
987
|
+
if(healthy&&!deferBarrier)try{const started=performance.now();await catchUp();
|
|
988
|
+
timing('daemon.request_catch_up',started);}catch(e){observationGap('historical_query',e);}
|
|
989
|
+
if(flushTimer||pending.size||planDirty||[...external.values()].some(e=>e.pending.size))flush();
|
|
990
|
+
let synchronizedInstalled=false;
|
|
991
|
+
// A successful forced reconciliation has already hashed the
|
|
992
|
+
// complete declared installed input set. A second full pass inside the
|
|
993
|
+
// same request duplicates that work without creating an atomic snapshot.
|
|
994
|
+
const validatedColdIndex=needsContentReconciliation&&message.action!=='prelaunch'&&
|
|
995
|
+
healthy&&!planning&&
|
|
996
|
+
!planDirty&&!index?.unavailable;
|
|
997
|
+
if(index&&healthy&&!planning&&message.action!=='prelaunch'){
|
|
998
|
+
const names=[...index.checks].filter(([name,row])=>
|
|
999
|
+
row.plan.synchronizedInstalledRead&&(!message.name||message.name===name))
|
|
1000
|
+
.map(([name])=>name);
|
|
1001
|
+
if(names.length&&validatedColdIndex){
|
|
1002
|
+
synchronizedInstalled=true;
|
|
1003
|
+
timing('daemon.request_installed_from_reconcile',performance.now(),
|
|
1004
|
+
{checks:names.length,files:index.files.size});
|
|
1005
|
+
}else if(names.length)try{
|
|
1006
|
+
if(installedRecheck)await installedRecheck;
|
|
1007
|
+
const observedIndex=index;
|
|
1008
|
+
const recheckStarted=performance.now();
|
|
1009
|
+
installedRecheck=observedIndex.recheckInstalled(names);
|
|
1010
|
+
const result=await installedRecheck;
|
|
1011
|
+
metrics.lastInstalledRecheck=result;
|
|
1012
|
+
timing('daemon.request_installed_recheck',recheckStarted,
|
|
1013
|
+
{files:result.files,bytes:result.bytes});
|
|
1014
|
+
installedRecheck=null;
|
|
1015
|
+
await catchUp();
|
|
1016
|
+
if(flushTimer||pending.size||planDirty)flush();
|
|
1017
|
+
synchronizedInstalled=healthy&&!planning&&!planDirty&&!index.unavailable&&
|
|
1018
|
+
index===observedIndex;
|
|
1019
|
+
}catch(e){installedRecheck=null;observationGap('installed_recheck',e);}
|
|
1020
|
+
}
|
|
1021
|
+
const probeStarted=performance.now();
|
|
1022
|
+
// A run's start checkpoint skips the full probe when its recording can
|
|
1023
|
+
// replace the certificate; the end checkpoint offers that recording.
|
|
1024
|
+
let adoptIntent=false,recorded;const adopting=new Set();
|
|
1025
|
+
if(index&&healthy&&!validatedColdIndex&&message.action!=='prelaunch'){
|
|
1026
|
+
const continuous=continuityBarrier(),probed=index,gaps=metrics.gaps,decided=Date.now();
|
|
1027
|
+
const selected=[...index.checks].filter(([name])=>!message.name||message.name===name);
|
|
1028
|
+
if(message.action==='snapshot'&&typeof message.adopt==='string'&&/^[0-9a-f-]{36}$/.test(message.adopt)){
|
|
1029
|
+
const row=index.checks.get(message.name);
|
|
1030
|
+
recorded=row?adopt(message.name,row,message.adopt):'recording unavailable';
|
|
1031
|
+
}
|
|
1032
|
+
adoptIntent=message.action==='snapshot'&&message.runCertificate===1&&!message.adopt&&continuous;
|
|
1033
|
+
const keys=new Map(selected.map(([name,row])=>[name,inputKey(row)]));
|
|
1034
|
+
const skip=message.action==='sync'?knownStale:()=>false;
|
|
1035
|
+
if(message.action==='sync')refreshReceipts();
|
|
1036
|
+
// With a deferred barrier, validate in-process first and launch any full
|
|
1037
|
+
// probe only after the barrier, so a queued edit costs one probe, not two.
|
|
1038
|
+
for(const [name,row] of selected)if(!skip(name,row))probe(row,continuous,{launch:!deferBarrier&&!adoptIntent});
|
|
1039
|
+
if(continuous&&process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_MAC_PROBE_SIGNAL){
|
|
1040
|
+
fs.writeFileSync(process.env.VSTATE_TEST_MAC_PROBE_SIGNAL,'validated');
|
|
1041
|
+
await new Promise(resolve=>setTimeout(resolve,200));
|
|
1042
|
+
}
|
|
1043
|
+
// Validation and probes block JS event delivery. Finish with a continuity
|
|
1044
|
+
// barrier so queued edits/gaps cannot be published using the pre-probe index.
|
|
1045
|
+
if(continuous||deferBarrier)try{const started=performance.now();
|
|
1046
|
+
await catchUp();if(planDirty||pending.size||flushTimer)flush();
|
|
1047
|
+
timing('daemon.request_catch_up',started,{after:'probes'});
|
|
1048
|
+
// Rows not yet confirmed, or whose inputs moved meanwhile, are probed
|
|
1049
|
+
// from the barrier, as an undeferred decision would have done.
|
|
1050
|
+
// Decided once, after the barrier. The start probe value is already out
|
|
1051
|
+
// of date; clearing it means whichever probe comes next sets the value
|
|
1052
|
+
// for this unchanged interval without counting as a change during the run.
|
|
1053
|
+
if(adoptIntent&&index===probed&&healthy&&!planning)for(const [name,row] of selected)
|
|
1054
|
+
if(row.probeAt<decided&&adoptable(row)){
|
|
1055
|
+
adopting.add(name);row.probeHash=null;
|
|
1056
|
+
row.adoption={token:randomUUID(),serial:inputEventSerial.get(name)||0,gaps:metrics.gaps,
|
|
1057
|
+
input:inputKey(row),cache:row.probeCache};
|
|
1058
|
+
}
|
|
1059
|
+
const rest=index===probed&&healthy&&!planning?selected.filter(([name,row])=>!skip(name,row)&&
|
|
1060
|
+
!adopting.has(name)&&(row.probeAt<decided||metrics.gaps!==gaps||inputKey(row)!==keys.get(name))):[];
|
|
1061
|
+
const launched=metrics.fullProbes||0;
|
|
1062
|
+
for(const [,row] of rest)probe(row,continuityBarrier());
|
|
1063
|
+
if(continuous&&(metrics.fullProbes||0)>launched){await catchUp();if(planDirty||pending.size||flushTimer)flush();}
|
|
1064
|
+
}catch(e){observationGap('post_probe_history',e);}
|
|
1065
|
+
}
|
|
1066
|
+
timing('daemon.request_probes',probeStarted,{checks:index?.checks.size||0});
|
|
1067
|
+
lastObservation=Date.now();publish();
|
|
1068
|
+
const applicabilityStarted=performance.now();
|
|
1069
|
+
const data=status(message.contextHashes,synchronizedInstalled,
|
|
1070
|
+
process.platform==='win32'&&healthy&&!planning&&!planDirty);
|
|
1071
|
+
if(message.action==='snapshot'||message.action==='prelaunch') {
|
|
1072
|
+
data.snapshots={};for(const [name,row] of index?.checks||[])
|
|
1073
|
+
if(!message.name||message.name===name)
|
|
1074
|
+
data.snapshots[name]={...combinedSummary(name),files:combinedFiles(name),
|
|
1075
|
+
planGeneration:row.plan.id,
|
|
1076
|
+
inputEventSerial:inputEventSerial.get(name)||0,
|
|
1077
|
+
probeHash:row.probeHash,inputKey:inputKey(row),observationGaps:metrics.gaps,
|
|
1078
|
+
observationHealthy:healthy&&!planning&&!recovering&&applicableExternal(name).every(e=>e.healthy)};
|
|
1079
|
+
// Export only a currently guarded, check-specific discovery certificate.
|
|
1080
|
+
// The caller compares it with the answers its own compile received. Schema 2
|
|
1081
|
+
// certifies the realpath of every directory readDirectory visits. The
|
|
1082
|
+
// snapshot stays the start of the observed interval, including validation.
|
|
1083
|
+
if(message.runCertificate===1&&continuityBarrier()){
|
|
1084
|
+
const row=index?.checks.get(message.name),cache=row?.probeCache;
|
|
1085
|
+
// The daemon checked the recording against the certificate it then validated.
|
|
1086
|
+
if(recorded===null&&cache?.recorded===message.adopt&&cache.hash===row.probeHash&&
|
|
1087
|
+
cache.input===inputKey(row)&&data.snapshots[message.name])
|
|
1088
|
+
data.snapshots[message.name].recordingAccepted=true;
|
|
1089
|
+
else if(row?.plan.qualification==='npm-typescript-direct-v1'&&
|
|
1090
|
+
!row.plan.unresolved.length&&cache?.schema===1&&cache.input===inputKey(row)&&
|
|
1091
|
+
cache.hash===row.probeHash&&cache.guard===planGuard(index.bundle)&&
|
|
1092
|
+
cache.output&&cache.queryData&&data.snapshots[message.name])
|
|
1093
|
+
data.snapshots[message.name].discoveryCertificate={schema:2,planId:row.plan.id,
|
|
1094
|
+
cwd:row.plan.cwd,probeDefinition:sha(JSON.stringify(row.plan.probes)),
|
|
1095
|
+
context:cache.context,output:cache.output,stderrHash:cache.stderrHash,
|
|
1096
|
+
queryData:cache.queryData};
|
|
1097
|
+
}
|
|
1098
|
+
const row=index?.checks.get(message.name);
|
|
1099
|
+
if(adopting.has(message.name)&&row?.adoption&&data.snapshots[message.name])
|
|
1100
|
+
data.snapshots[message.name].recordedCertificate=row.adoption.token;
|
|
1101
|
+
if(typeof recorded==='string'&&data.snapshots[message.name])data.snapshots[message.name].recordingIssue=recorded;
|
|
1102
|
+
}
|
|
1103
|
+
timing('daemon.request_applicability',applicabilityStarted,{checks:data.checks.length});
|
|
1104
|
+
timing('daemon.request_total',totalStarted,{action:message.action,
|
|
1105
|
+
healthy:healthy?1:0});
|
|
1106
|
+
return data;
|
|
1107
|
+
}
|
|
1108
|
+
return {error:'unknown action'};
|
|
1109
|
+
}
|
|
1110
|
+
async function shutdown() {
|
|
1111
|
+
if(stopping)return;
|
|
1112
|
+
const retainEligible=healthy&&!historyDisabled&&!index?.unavailable&&!planDirty;
|
|
1113
|
+
stopping=true;healthy=false;reason='observer stopping';publish();
|
|
1114
|
+
event('observer_stopped',{pid:process.pid});clearTimeout(flushTimer);
|
|
1115
|
+
if(retainEligible&&index&&!planning&&watcher){
|
|
1116
|
+
try {await catchUp();
|
|
1117
|
+
if(pending.size||[...external.values()].some(e=>e.pending.size))flush();
|
|
1118
|
+
// The process was healthy before the stop request only if its index and
|
|
1119
|
+
// cursor can be retained. A failed catch-up leaves the old snapshot
|
|
1120
|
+
// unusable for optimistic restart.
|
|
1121
|
+
if(!index.unavailable&&fs.existsSync(snapshotPath)){
|
|
1122
|
+
healthy=true;retainIndex();healthy=false;}
|
|
1123
|
+
} catch(e){event('index_retention_unavailable',{classification:e.name||'Error'});}
|
|
1124
|
+
}
|
|
1125
|
+
closeHistory();planningWorker?.terminate().catch(()=>{});
|
|
1126
|
+
clearInterval(heartbeat);clearInterval(probeTimer);clearTimeout(recoveryTimer);
|
|
1127
|
+
watcher?.unsubscribe().catch(()=>{});server.close();
|
|
1128
|
+
for(const entry of external.values()){
|
|
1129
|
+
clearTimeout(entry.recovery);entry.watcher?.unsubscribe().catch(()=>{});}
|
|
1130
|
+
if(endpoint.filesystem)try{fs.unlinkSync(socket);}catch{}
|
|
1131
|
+
fs.rmSync(lock,{recursive:true,force:true});
|
|
1132
|
+
// A native watcher handle may remain wedged even after unsubscribe. State
|
|
1133
|
+
// and control ownership are already released; bound process shutdown.
|
|
1134
|
+
setTimeout(()=>process.exit(0),1000).unref();
|
|
1135
|
+
}
|
|
1136
|
+
const server=net.createServer(client=>{
|
|
1137
|
+
let text='',handled=false;
|
|
1138
|
+
client.on('error',e=>event('control_client_error',{error_code:e.code||e.name}));
|
|
1139
|
+
client.on('close',()=>{if(!client.vstateReplied)
|
|
1140
|
+
event('control_reply_unavailable',{classification:handled?'closed_without_reply':
|
|
1141
|
+
'closed_before_request',request_bytes:text.length});});
|
|
1142
|
+
client.on('data',d=>{
|
|
1143
|
+
if(handled)return;text+=d;if(text.length>4096){event('control_request_rejected',
|
|
1144
|
+
{classification:'request_too_large',request_bytes:text.length});client.destroy();return;}
|
|
1145
|
+
if(!text.includes('\n'))return;handled=true;
|
|
1146
|
+
try{const message=JSON.parse(text);
|
|
1147
|
+
const decision=message.action==='sync'||message.action==='snapshot';
|
|
1148
|
+
if(decision)decisionRequests++;
|
|
1149
|
+
traceScope({request:message.diagnostic?.request||traceId(),work:'request'},()=>{
|
|
1150
|
+
mark('server.request_received',{activeDecisions:decisionRequests});
|
|
1151
|
+
Promise.resolve(request(message)).then(v=>respond(client,v))
|
|
1152
|
+
.catch(e=>respond(client,{error:e.message}))
|
|
1153
|
+
.finally(()=>{mark('server.request_complete');if(decision)decisionRequests--;});});}
|
|
1154
|
+
catch(e){respond(client,{error:e.message});}
|
|
1155
|
+
});
|
|
1156
|
+
});
|
|
1157
|
+
async function attachWatch(){
|
|
1158
|
+
try {watcher=await subscribe(root,(error,changes)=>{
|
|
1159
|
+
if(error){observationGap(error.code||'subscription_error',error);
|
|
1160
|
+
watcher?.unsubscribe().catch(()=>{});watcher=null;
|
|
1161
|
+
return;}
|
|
1162
|
+
if(process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_DROP_EVENTS_FILE&&
|
|
1163
|
+
fs.existsSync(process.env.VSTATE_TEST_DROP_EVENTS_FILE))return;
|
|
1164
|
+
for(const change of changes){const rel=observedRelative(root,change.path);
|
|
1165
|
+
if(rel===null){observationGap('event_outside_checkout');break;}
|
|
1166
|
+
notification(change.type,rel);}
|
|
1167
|
+
});
|
|
1168
|
+
} catch(e){watcher=null;observationGap(e.code||'subscription_start',e);}
|
|
1169
|
+
}
|
|
1170
|
+
async function attachExternal(entry) {
|
|
1171
|
+
try{entry.identity=fs.statSync(entry.root,{bigint:true});
|
|
1172
|
+
entry.watcher=await subscribe(entry.root,(error,changes)=>{
|
|
1173
|
+
if(error){entry.watcher?.unsubscribe().catch(()=>{});entry.watcher=null;
|
|
1174
|
+
externalGap(entry,error.code||'subscription_error',error);return;}
|
|
1175
|
+
if(process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_DROP_EVENTS_FILE&&
|
|
1176
|
+
fs.existsSync(process.env.VSTATE_TEST_DROP_EVENTS_FILE))return;
|
|
1177
|
+
for(const change of changes){const rel=observedRelative(entry.root,change.path);
|
|
1178
|
+
if(rel===null){externalGap(entry,'event_outside_root');break;}
|
|
1179
|
+
externalNotification(entry,change.type,rel);}
|
|
1180
|
+
});}
|
|
1181
|
+
catch(e){entry.watcher=null;entry.reason=`external subscription unavailable: ${e.code||e.name}`;}
|
|
1182
|
+
}
|
|
1183
|
+
server.listen({path:socket,readableAll:false,writableAll:false},async()=>{
|
|
1184
|
+
const setupStarted=performance.now();
|
|
1185
|
+
if(endpoint.filesystem)fs.chmodSync(socket,0o600);
|
|
1186
|
+
event('control_listening',{pid:process.pid});
|
|
1187
|
+
loadReceipts();await attachWatch();
|
|
1188
|
+
if(hasHistory&&watcher&&!fs.existsSync(snapshotPath))try{
|
|
1189
|
+
await queryHistory(root,snapshotPath,'initial snapshot');}
|
|
1190
|
+
catch(e){historyDisabled=true;event('history_unavailable',{phase:'initial snapshot',
|
|
1191
|
+
error_code:e.code||e.name});}
|
|
1192
|
+
for(const entry of external.values()){
|
|
1193
|
+
await attachExternal(entry);
|
|
1194
|
+
if(!historyDisabled&&entry.watcher&&!fs.existsSync(entry.snapshot))try{
|
|
1195
|
+
await queryHistory(entry.root,entry.snapshot,'external initial snapshot');}
|
|
1196
|
+
catch(e){historyDisabled=true;event('history_unavailable',
|
|
1197
|
+
{phase:'external initial snapshot',error_code:e.code||e.name});}
|
|
1198
|
+
}
|
|
1199
|
+
metrics.initialObservationSetupMs=performance.now()-setupStarted;
|
|
1200
|
+
refreshPlan({forceCold:historyDisabled});
|
|
1201
|
+
event('observer_started',{pid:process.pid});publish();
|
|
1202
|
+
});
|
|
1203
|
+
const heartbeat=setInterval(()=>{
|
|
1204
|
+
verifyRoot();
|
|
1205
|
+
for(const entry of external.values())if(entry.watcher){
|
|
1206
|
+
try{const identity=fs.statSync(entry.root,{bigint:true});
|
|
1207
|
+
if(identity.dev!==entry.identity?.dev||identity.ino!==entry.identity?.ino)
|
|
1208
|
+
externalGap(entry,'root_identity_changed');}
|
|
1209
|
+
catch(e){externalGap(entry,'root_unavailable',e);}
|
|
1210
|
+
}
|
|
1211
|
+
publish();
|
|
1212
|
+
},1000);
|
|
1213
|
+
// Idle revalidation keeps probe results fresh for cached status without
|
|
1214
|
+
// launching a process: certificates are replayed in-process, and one that no
|
|
1215
|
+
// longer holds expires so the next decision runs the full probe. macOS history
|
|
1216
|
+
// replay would fork, so cached status there relies on live notifications.
|
|
1217
|
+
const probeTimer=setInterval(()=>traceScope({request:traceId(),work:'periodic'},async()=>{
|
|
1218
|
+
mark('server.periodic_start',{pending:pending.size,planDirty,planning,decisionRequests});
|
|
1219
|
+
// Windows cached reads are conservatively UNVERIFIED until a full decision
|
|
1220
|
+
// reconciliation, so idle revalidation cannot strengthen them.
|
|
1221
|
+
if(process.platform==='win32')return;
|
|
1222
|
+
if(healthy&&index&&!planning&&!recovering&&!decisionRequests){
|
|
1223
|
+
try{if(!hasHistory)await catchUp();}catch(e){observationGap('periodic_query',e);}
|
|
1224
|
+
if(healthy&&index&&!planning&&!recovering)
|
|
1225
|
+
for(const row of index.checks.values())probe(row,true,{launch:false});
|
|
1226
|
+
if(planDirty)flush();else publish();
|
|
1227
|
+
}
|
|
1228
|
+
mark('server.periodic_complete');
|
|
1229
|
+
}),5000);
|
|
1230
|
+
process.on('uncaughtExceptionMonitor',error=>{
|
|
1231
|
+
try{event('observer_uncaught_exception',{pid:process.pid,code:error.code||null,
|
|
1232
|
+
message:error.message,stack:error.stack});}catch{}
|
|
1233
|
+
});
|
|
1234
|
+
process.on('exit',code=>{try{event('observer_exit',{pid:process.pid,code,stopping,
|
|
1235
|
+
healthy,planning,recovering});}catch{}});
|
|
1236
|
+
process.on('SIGTERM',shutdown);process.on('SIGINT',shutdown);
|