@redue/cli 0.1.0-alpha.3 → 0.1.0-alpha.30
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 +806 -0
- package/README.md +157 -99
- package/bin/redue.mjs +267 -61
- package/docs/README.md +3 -1
- package/docs/user/agents.md +333 -15
- package/docs/user/configuration.md +28 -2
- package/docs/user/direct-typescript.md +219 -0
- package/docs/user/getting-started.md +49 -7
- package/docs/user/json.md +4 -2
- package/docs/user/node-script.md +177 -0
- package/docs/user/npm-launcher-notice.md +42 -0
- package/docs/user/pilot.md +146 -0
- package/docs/user/sandbox.md +312 -0
- package/docs/user/shell.md +3 -3
- package/docs/user/supported-projects.md +69 -23
- package/docs/user/tests-direct.md +143 -0
- package/package.json +4 -3
- package/src/agent-integration.mjs +467 -0
- package/src/agent-policy.md +28 -0
- package/src/cached-state.mjs +24 -1
- package/src/claude-hook.mjs +616 -0
- package/src/cli.mjs +449 -54
- package/src/daemon.mjs +442 -93
- package/src/decision-profile.mjs +15 -1
- package/src/decision-validation.mjs +12 -1
- package/src/declared-plan.mjs +38 -6
- package/src/direct-run-certificate.mjs +254 -0
- package/src/direct-typescript-probe.mjs +27 -0
- package/src/direct-typescript.mjs +205 -0
- package/src/glob.mjs +8 -0
- package/src/history-worker.mjs +21 -13
- package/src/index.mjs +108 -21
- package/src/installed-inputs.mjs +7 -1
- package/src/installed-observation.mjs +23 -0
- package/src/landlock.py +527 -0
- package/src/linux-inotify.py +39 -10
- package/src/node-onboarding.mjs +203 -54
- package/src/node-script.mjs +406 -0
- package/src/npm-integrity.mjs +124 -0
- package/src/npm-typecheck-environment.mjs +36 -0
- package/src/npmrc.mjs +16 -0
- package/src/observation.mjs +34 -12
- package/src/path-identity.mjs +8 -0
- package/src/plan-worker.mjs +17 -17
- package/src/pnpm-inputs.mjs +122 -57
- package/src/pnpm-shim-templates.json +180 -0
- package/src/pnpm-shims.mjs +54 -0
- package/src/pnpm-typecheck-probe.mjs +11 -6
- package/src/presentation.mjs +13 -4
- package/src/probe-deadline.mjs +37 -0
- package/src/probe-result.mjs +7 -3
- package/src/project-location.mjs +34 -0
- package/src/python-onboarding.mjs +89 -0
- package/src/run-recorder.cjs +91 -0
- package/src/sandbox.mjs +653 -0
- package/src/setup.mjs +205 -0
- package/src/stats.mjs +72 -0
- package/src/test-direct.mjs +608 -0
- package/src/test-recorder.cjs +435 -0
- package/src/typescript-contract-probe.mjs +4 -187
- package/src/typescript-input-facts.mjs +92 -0
- package/src/typescript-invocation.mjs +92 -0
- package/src/typescript-list.mjs +118 -13
- package/src/typescript-qualification.mjs +29 -49
- package/src/workspace-manifests.mjs +24 -0
- package/src/yarn-workspace-inputs.mjs +3 -0
- package/src/yarn-workspace-typecheck-probe.mjs +2 -1
package/src/daemon.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {inflateSync} from 'node:zlib';
|
|
1
|
+
import {inflateSync,deflateSync} from 'node:zlib';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import net from 'node:net';
|
|
@@ -8,16 +8,24 @@ import {Worker} from 'node:worker_threads';
|
|
|
8
8
|
import {fileURLToPath} from 'node:url';
|
|
9
9
|
import {subscribe,hasHistory,backend} from './platform-observation.mjs';
|
|
10
10
|
import {controlEndpoint} from './control-endpoint.mjs';
|
|
11
|
-
import {observedRelative,withinPath,realObservedPath} from './path-identity.mjs';
|
|
11
|
+
import {observedRelative,withinPath,realObservedPath,racyWindowMs} from './path-identity.mjs';
|
|
12
12
|
import micromatch from './glob.mjs';
|
|
13
13
|
import {sha} from './plan.mjs';
|
|
14
|
-
import {InputIndex} from './index.mjs';
|
|
14
|
+
import {InputIndex,dehydrateIndex} from './index.mjs';
|
|
15
15
|
import {permittedRoots} from './installed-inputs.mjs';
|
|
16
16
|
import {assertOwnedStatePlacement} from './owned-state.mjs';
|
|
17
17
|
import {atomicJson,readReceipts,readReceiptSnapshot,receiptRevision} from './state-store.mjs';
|
|
18
|
-
import {history} from './observation.mjs';
|
|
19
|
-
import {inputKey} from './decision-validation.mjs';
|
|
20
|
-
import {
|
|
18
|
+
import {history,closeHistory} from './observation.mjs';
|
|
19
|
+
import {inputKey,planGuard,toolchainKey} from './decision-validation.mjs';
|
|
20
|
+
import {queriesHold,runFactReason as runFactChanged} from './typescript-list.mjs';
|
|
21
|
+
import {nextProbeHint,probeDeadline,probeFloorMs} from './probe-deadline.mjs';
|
|
22
|
+
import {statusSummary} from './cached-state.mjs';
|
|
23
|
+
import {directProject,directProbeValue} from './direct-typescript.mjs';
|
|
24
|
+
import {npmCacheDir} from './npm-integrity.mjs';
|
|
25
|
+
import {runFromArgv} from './typescript-invocation.mjs';
|
|
26
|
+
import {buildInfoPath,recordedCertificate} from './direct-run-certificate.mjs';
|
|
27
|
+
import {timing,mark,traceBase,traceContext,traceScope,traceEnvironment,traceId} from './decision-profile.mjs';
|
|
28
|
+
import {isSandboxed,sandboxChange,visibleChange as sandboxVisibleChange} from './sandbox.mjs';
|
|
21
29
|
|
|
22
30
|
const configFile=path.resolve(process.argv[2]);
|
|
23
31
|
const config=JSON.parse(fs.readFileSync(configFile));
|
|
@@ -31,6 +39,7 @@ const endpoint=controlEndpoint(state),socket=endpoint.address,
|
|
|
31
39
|
fs.mkdirSync(lock); // Existing lock requires operator inspection; never steal it.
|
|
32
40
|
fs.writeFileSync(path.join(lock,'pid'),String(process.pid),{mode:0o600});
|
|
33
41
|
const generation=randomUUID(), startedAt=Date.now();
|
|
42
|
+
traceBase({generation,work:'startup'});
|
|
34
43
|
let index, receipts={}, watcher, healthy=false, reason='initial reconciliation', stopping=false;
|
|
35
44
|
let receiptStateError=null;
|
|
36
45
|
let receiptStateRevision=null;
|
|
@@ -44,6 +53,9 @@ let pending=new Set(), pendingChecks=new Set(), flushTimer, planDirty=false, pla
|
|
|
44
53
|
let decisionRequests=0;
|
|
45
54
|
let decisionSequence=0;
|
|
46
55
|
let inputEventSerial=new Map();
|
|
56
|
+
// The last input-event paths per check, with their serials, so a test run can
|
|
57
|
+
// show that every event during it was one of its own outputs.
|
|
58
|
+
const inputEventPaths=new Map(),eventPathLimit=10000;
|
|
47
59
|
let diagnosticInputEvents=0;
|
|
48
60
|
let synchronizing=null;
|
|
49
61
|
let installedRecheck=null;
|
|
@@ -52,7 +64,9 @@ let identityChanged=false;
|
|
|
52
64
|
let identityGapKey=null;
|
|
53
65
|
let triggerHashes=new Map();
|
|
54
66
|
let candidateDescendants=new Map();
|
|
55
|
-
|
|
67
|
+
// A Python project may have no package.json.
|
|
68
|
+
let workspacePatterns=(()=>{let w;try{w=JSON.parse(fs.readFileSync(path.join(root,'package.json'))).workspaces;}
|
|
69
|
+
catch(e){if(e.code==='ENOENT')return [];throw e;}
|
|
56
70
|
return Array.isArray(w)?w:w?.packages||[];})();
|
|
57
71
|
let planningWorker=null;
|
|
58
72
|
let validatedPlan=null;
|
|
@@ -110,62 +124,162 @@ function persistedPlan(bundle) {
|
|
|
110
124
|
return [name,safe];
|
|
111
125
|
}))};
|
|
112
126
|
}
|
|
113
|
-
|
|
127
|
+
// Continuity for in-process certificate reuse: every observed change is applied.
|
|
128
|
+
// macOS needs its history cursor; Linux has the synchronize barrier in catchUp.
|
|
129
|
+
// Windows reconciles content at each decision and keeps its own reuse path.
|
|
130
|
+
function continuityBarrier() {
|
|
131
|
+
return process.platform!=='win32'&&(process.platform!=='darwin'||hasHistory&&!historyDisabled)&&
|
|
132
|
+
healthy&&watcher&&!planning&&!recovering&&!planDirty&&!index?.unavailable&&
|
|
133
|
+
!pending.size&&!pendingChecks.size&&!external.size&&verifyRoot();
|
|
134
|
+
}
|
|
135
|
+
// The basis a full direct probe is checked against: plan guard, observation
|
|
136
|
+
// continuity, and the compiler tree and Node identity taken before the probe.
|
|
137
|
+
function certificateBasis(row) {
|
|
138
|
+
const argv=row.plan.probes[0],compiler=directProject(argv[2],runFromArgv(argv.slice(3)),{verify:false});
|
|
139
|
+
return {guard:planGuard(index.bundle),gaps:metrics.gaps,
|
|
140
|
+
compiler:JSON.stringify([compiler.tsRoot,compiler.entry,compiler.digest]),
|
|
141
|
+
toolchain:toolchainKey(compiler.tsRoot,argv[0])};
|
|
142
|
+
}
|
|
143
|
+
// Returns null after reusing the last full probe, otherwise why a full probe is needed.
|
|
144
|
+
function reuseCertificate(row,key,allowReuse,forceFull,checked) {
|
|
145
|
+
const started=performance.now(),cache=row.probeCache;
|
|
146
|
+
if(forceFull)return 'forced_full_reference';
|
|
147
|
+
if(!allowReuse||!continuityBarrier())return 'continuity_unavailable';
|
|
148
|
+
if(row.plan.unresolved.length)return 'coverage_unresolved';
|
|
149
|
+
if(!cache?.queries?.length)return 'no_validated_result';
|
|
150
|
+
if(cache.input!==key)return 'inputs_changed';
|
|
151
|
+
if(cache.gaps!==metrics.gaps)return 'observation_gap';
|
|
152
|
+
let reason=null;
|
|
153
|
+
try{const basis=checked.basis=certificateBasis(row);
|
|
154
|
+
if(basis.guard!==cache.guard){planDirty=true;reason='plan_guard_changed';}
|
|
155
|
+
else if(basis.compiler!==cache.compiler||basis.toolchain!==cache.toolchain)reason='toolchain_changed';
|
|
156
|
+
else if(!queriesHold(cache.queries,cache.identities))reason='compiler_queries_changed';}
|
|
157
|
+
catch{reason='validation_unavailable';}
|
|
158
|
+
timing('daemon.probe_validation',started,{outcome:reason?'changed_or_unavailable':'reused',
|
|
159
|
+
reason:reason||'content_and_context_unchanged',queries:cache.queries.length});
|
|
160
|
+
if(reason)return reason;
|
|
161
|
+
row.probeHash=cache.hash;row.probeAt=Date.now();row.probeError=null;
|
|
162
|
+
metrics.probeReuses=(metrics.probeReuses||0)+1;
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
// The current full-probe deadline for a check; see probe-deadline.mjs. Hints
|
|
166
|
+
// are kept per check name, so a plan rebuild (each Windows decision) keeps them.
|
|
167
|
+
const probeHints=new Map();
|
|
168
|
+
function fullProbeDeadline(row) {
|
|
169
|
+
const run=receipts[row.plan.name]?.invocation;
|
|
170
|
+
return probeDeadline({floor:probeFloorMs(row.plan.qualification),hint:probeHints.get(row.plan.name),
|
|
171
|
+
runMs:run?.status==='exited'?run.durationMs:0});
|
|
172
|
+
}
|
|
173
|
+
function probe(row,allowReuse=false,{launch=true}={}) {
|
|
114
174
|
const started=performance.now();
|
|
115
175
|
const supported=new Map([
|
|
116
176
|
['typescript-noemit-v1','typescript-contract-probe.mjs'],
|
|
177
|
+
['npm-typescript-direct-v1','direct-typescript-probe.mjs'],
|
|
117
178
|
['yarn-workspace-tsc-v1','yarn-workspace-typecheck-probe.mjs'],
|
|
118
179
|
['pnpm-tsc-v1','pnpm-typecheck-probe.mjs']]);
|
|
119
180
|
const expected=supported.get(row.plan.qualification);
|
|
120
|
-
const
|
|
181
|
+
const builtIn=row.plan.probes.length===1&&expected&&
|
|
121
182
|
row.plan.probes[0][1]===fileURLToPath(new URL(expected,import.meta.url));
|
|
183
|
+
const inProcess=builtIn&&process.platform!=='win32'&&row.plan.qualification==='npm-typescript-direct-v1';
|
|
184
|
+
const cacheable=builtIn&&(process.platform==='win32'||inProcess);
|
|
122
185
|
const key=inputKey(row);
|
|
123
|
-
|
|
186
|
+
const forceFull=process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_FULL_PROBES==='1';
|
|
187
|
+
let fullReason=null;const checked={};
|
|
188
|
+
if(inProcess){
|
|
189
|
+
fullReason=reuseCertificate(row,key,allowReuse,forceFull,checked);
|
|
190
|
+
if(!fullReason)return;
|
|
191
|
+
}
|
|
192
|
+
// Idle revalidation never launches a process. An unconfirmed result expires.
|
|
193
|
+
if(!launch&&row.plan.probes.length)return;
|
|
194
|
+
if(process.platform==='win32'&&cacheable&&allowReuse&&!forceFull&&row.probeCache?.input===key){
|
|
124
195
|
const argv=row.plan.probes[0];
|
|
196
|
+
mark('daemon.probe_launch',{mode:'certificate',deadlineMs:10000});
|
|
125
197
|
const out=spawnSync(argv[0],[...argv.slice(1),'--redue-context'],{
|
|
126
|
-
cwd:row.plan.cwd,timeout:10000,maxBuffer:1024*1024,stdio:['ignore','pipe','pipe']});
|
|
127
|
-
|
|
128
|
-
|
|
198
|
+
cwd:row.plan.cwd,timeout:10000,maxBuffer:1024*1024,env:traceEnvironment(),stdio:['ignore','pipe','pipe']});
|
|
199
|
+
mark('daemon.probe_complete',{mode:'certificate',childPid:out.pid,exit:out.status,signal:out.signal,code:out.error?.code});
|
|
200
|
+
metrics.probeContextProcesses=(metrics.probeContextProcesses||0)+1;
|
|
201
|
+
if(!out.error&&!out.signal&&out.status===0&&out.stdout.toString()===row.probeCache.context){
|
|
129
202
|
row.probeHash=row.probeCache.hash;row.probeAt=Date.now();row.probeError=null;
|
|
130
203
|
metrics.probeReuses=(metrics.probeReuses||0)+1;
|
|
131
204
|
timing('daemon.probe_context_validation',started,{outcome:'reused',reason:'content_and_context_unchanged'});
|
|
132
205
|
return;
|
|
133
206
|
}
|
|
207
|
+
fullReason='context_or_compiler_queries_changed';
|
|
134
208
|
timing('daemon.probe_context_validation',started,{outcome:'changed_or_unavailable'});
|
|
135
209
|
}
|
|
136
|
-
|
|
137
|
-
!row.probeCache?'no_validated_result':row.probeCache.input!==key?'inputs_changed':'context_changed'
|
|
210
|
+
fullReason??=forceFull?'forced_full_reference':!cacheable?'generic_probe':
|
|
211
|
+
!row.probeCache?'no_validated_result':row.probeCache.input!==key?'inputs_changed':'context_changed';
|
|
212
|
+
if(row.plan.probes.length){
|
|
213
|
+
timing('daemon.probe_reason',started,{reason:fullReason});
|
|
214
|
+
metrics.fullProbeReasons??={};metrics.fullProbeReasons[fullReason]=(metrics.fullProbeReasons[fullReason]||0)+1;
|
|
215
|
+
metrics.fullProbes=(metrics.fullProbes||0)+1;
|
|
216
|
+
}
|
|
217
|
+
// A failed or incomplete full probe must not leave an older certificate usable.
|
|
218
|
+
let basis=checked.basis||null;
|
|
219
|
+
if(inProcess){
|
|
220
|
+
if(!basis)try{basis=certificateBasis(row);}catch{}
|
|
221
|
+
if(row.probeCache?.guard&&basis&&basis.guard!==row.probeCache.guard)planDirty=true;
|
|
222
|
+
row.probeCache=null;
|
|
223
|
+
}
|
|
138
224
|
const values=[];let checkpoint=null;
|
|
225
|
+
// The slowest of this check's probes sets the hint for all of them.
|
|
226
|
+
const deadline=fullProbeDeadline(row);let hint=0;
|
|
227
|
+
const learn=()=>{if(hint)probeHints.set(row.plan.name,hint);};
|
|
139
228
|
for(const argv of row.plan.probes) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
//
|
|
143
|
-
const
|
|
144
|
-
(
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
229
|
+
const timeout=deadline,launched=performance.now();
|
|
230
|
+
mark('daemon.probe_launch',{mode:'full',reason:fullReason,deadlineMs:timeout});
|
|
231
|
+
// A built-in probe gets its deadline, so its inner compiler calls share it.
|
|
232
|
+
const out=spawnSync(argv[0],[...argv.slice(1),...(cacheable?['--redue-checkpoint']:[]),
|
|
233
|
+
...(builtIn?[`--redue-deadline=${timeout}`]:[])],{cwd:row.plan.cwd,
|
|
234
|
+
timeout,maxBuffer:1024*1024,stdio:['ignore','pipe','pipe'],env:traceEnvironment()});
|
|
235
|
+
mark('daemon.probe_complete',{mode:'full',childPid:out.pid,exit:out.status,signal:out.signal,code:out.error?.code});
|
|
236
|
+
const timedOut=out.error?.code==='ETIMEDOUT';
|
|
237
|
+
if(!out.error||timedOut)hint=Math.max(hint,nextProbeHint({durationMs:performance.now()-launched,deadlineMs:timeout,timedOut}));
|
|
148
238
|
if(out.error||out.signal||out.status!==0) {
|
|
239
|
+
learn();
|
|
149
240
|
if(!row.probeError){row.revision++;row.lastReason='declared state probe became unavailable';}
|
|
150
241
|
row.probeHash=null;row.probeAt=0;
|
|
151
|
-
const code=['typescript-noemit-v1','yarn-workspace-tsc-v1','pnpm-tsc-v1']
|
|
242
|
+
const code=['typescript-noemit-v1','npm-typescript-direct-v1','yarn-workspace-tsc-v1','pnpm-tsc-v1']
|
|
152
243
|
.includes(row.plan.qualification)?
|
|
153
244
|
/VSTATE_REASON:([a-z-]+)/.exec(out.stderr?.toString()||'')?.[1]:null;
|
|
154
245
|
const explanations={
|
|
246
|
+
'direct-typescript-implementation-unreviewed':'installed TypeScript files differ from the reviewed compiler or from the tarball package-lock.json names',
|
|
247
|
+
'direct-typescript-version-unsupported':'direct compiler recipe supports TypeScript 5.x and 6.0',
|
|
248
|
+
'direct-typescript-lockfile-source-unsupported':'package-lock.json installs TypeScript from somewhere other than the registry\'s typescript package (an alias, a file or a git source)',
|
|
249
|
+
'npm-shrinkwrap-unsupported':'npm-shrinkwrap.json is not read; the reviewed TypeScript releases still qualify',
|
|
250
|
+
'direct-typescript-lockfile-integrity-missing':'package-lock.json records no integrity for the installed TypeScript',
|
|
251
|
+
'direct-typescript-lockfile-version-differs':'installed TypeScript version differs from package-lock.json; run npm ci',
|
|
252
|
+
'npm-lockfile-unreadable':'package-lock.json cannot be read',
|
|
253
|
+
'npm-lockfile-integrity-unsupported':'package-lock.json records TypeScript integrity with neither sha512 nor sha1',
|
|
254
|
+
'npm-tarball-uncached':`the TypeScript tarball package-lock.json names is not in npm's cache (${npmCacheDir(root)}); run npm ci or npm cache add`,
|
|
255
|
+
'npm-tarball-corrupt':"npm's cached TypeScript tarball does not match its integrity",
|
|
256
|
+
'npm-tarball-invalid':"npm's cached TypeScript tarball cannot be read",
|
|
257
|
+
'npm-tarball-link-unsupported':"npm's cached TypeScript tarball holds links",
|
|
258
|
+
'direct-execution-environment-unsupported':'direct compiler rejects development helpers, Node cache/preloads, and unreviewed runtime overrides',
|
|
259
|
+
'direct-node-version-unsupported':'direct compiler recipe supports Node 22 and 24',
|
|
260
|
+
'npm-launcher-unobserved-inputs':'npm launcher uses unobserved notifier inputs; execution is recording-only',
|
|
155
261
|
'typecheck-script-changed':'typecheck script or lifecycle changed; rerun init or review it',
|
|
156
262
|
'execution-environment-unsupported':'Node/npm preload or configuration environment is unsupported',
|
|
157
263
|
'typescript-executable-changed':'local TypeScript executable changed or is missing',
|
|
158
264
|
'typescript-config-unavailable':'TypeScript configuration cannot be resolved',
|
|
159
265
|
'typescript-config-graph-unsupported':'TypeScript configuration extends, references, or uses compiler features outside this contract',
|
|
160
|
-
'typescript-incremental-unsupported':'TypeScript
|
|
266
|
+
'typescript-incremental-unsupported':'TypeScript composite compilation needs explicit review',
|
|
267
|
+
'typescript-buildinfo-outside-repository':'TypeScript incremental build-info file is outside the observed repository',
|
|
268
|
+
'typescript-incremental-unsound':'TypeScript assumeChangesOnlyAffectDirectDependencies makes an incremental result depend on build history',
|
|
269
|
+
'typescript-buildinfo-name-unsupported':'TypeScript build-info file needs a plain name ending in .tsbuildinfo',
|
|
270
|
+
'typescript-project-reference-consumed':'TypeScript program uses files of a referenced project, which plain tsc does not build',
|
|
161
271
|
'typescript-config-outside-repository':'TypeScript configuration extends outside the observed repository',
|
|
162
272
|
'typescript-plugin-unsupported':'TypeScript compiler plugin needs explicit review',
|
|
163
273
|
'typescript-input-list-unavailable':'TypeScript input listing did not complete',
|
|
164
274
|
'typescript-input-outside-repository':'TypeScript consumes files outside the observed repository',
|
|
275
|
+
'typescript-vite-cache-consumed':'TypeScript asks about node_modules/.vite or lists node_modules; Vitest rewrites files there and this recipe does not observe them',
|
|
165
276
|
'npm-installation-layout-unsupported':'npm executable or installation layout is unsupported',
|
|
166
277
|
'npm-installation-unobservable':'npm installation contains unobservable entries',
|
|
167
278
|
'npm-configuration-unsupported':'npm script configuration needs review',
|
|
168
279
|
'typescript-contract-unavailable':'TypeScript applicability probe could not establish inputs',
|
|
280
|
+
'npm-proxy-version-unsupported':'npm proxy context is reviewed only for npm 10.9.2',
|
|
281
|
+
'npm-proxy-context-ambiguous':'conflicting npm proxy environment keys require resolution',
|
|
282
|
+
'npm-proxy-value-unsupported':'npm proxy environment contains an unsupported value',
|
|
169
283
|
'workspace-execution-environment-unsupported':'Yarn/Node launcher environment override needs review',
|
|
170
284
|
'yarn-toolchain-unavailable':'pinned Yarn version or executable is unavailable',
|
|
171
285
|
'yarn-distribution-unobservable':'Corepack Yarn distribution cannot be inspected',
|
|
@@ -173,7 +287,7 @@ function probe(row,allowReuse=false) {
|
|
|
173
287
|
'workspace-typecheck-contract-unavailable':'workspace TypeScript applicability probe could not establish inputs'
|
|
174
288
|
,'pnpm-execution-environment-unsupported':'pnpm or Node launcher environment override needs review'
|
|
175
289
|
,'pnpm-distribution-unobservable':'pnpm executable contents cannot be observed'
|
|
176
|
-
,'pnpm-version-mismatch':'pnpm executable differs from the
|
|
290
|
+
,'pnpm-version-mismatch':'pnpm executable differs from the installed version'
|
|
177
291
|
,'typescript-input-outside-pnpm-contract':'TypeScript reads outside the selected workspace, linked outputs, or installed inputs'
|
|
178
292
|
,'pnpm-input-coverage-unavailable':'pnpm installed-input coverage became unavailable; inspect check details'
|
|
179
293
|
,'pnpm-typecheck-contract-unavailable':'pnpm TypeScript input contract could not be established'
|
|
@@ -187,18 +301,26 @@ function probe(row,allowReuse=false) {
|
|
|
187
301
|
try{checkpoint=JSON.parse(out.stdout);
|
|
188
302
|
checkpoint.queries=checkpoint.queryData?JSON.parse(inflateSync(
|
|
189
303
|
Buffer.from(checkpoint.queryData,'base64'),{maxOutputLength:8*1024*1024})):null;
|
|
304
|
+
// Optional: without a layout, a run after an edit falls back to a full probe.
|
|
305
|
+
try{checkpoint.layout=checkpoint.layoutData?JSON.parse(inflateSync(
|
|
306
|
+
Buffer.from(checkpoint.layoutData,'base64'),{maxOutputLength:8*1024*1024})):null;}
|
|
307
|
+
catch{checkpoint.layout=null;}
|
|
190
308
|
if(!/^[a-f0-9]{64}$/.test(checkpoint.output)||!/^[a-f0-9]{64}$/.test(checkpoint.context))
|
|
191
309
|
throw Error('invalid supported probe checkpoint');}
|
|
192
310
|
catch{row.probeHash=null;row.probeAt=0;row.probeError='probe checkpoint unavailable';return;}
|
|
193
311
|
}
|
|
194
312
|
values.push([argv,out.status,sha(cacheable?checkpoint.output:out.stdout),sha(out.stderr)]);
|
|
195
313
|
}
|
|
314
|
+
learn();
|
|
196
315
|
const value=sha(JSON.stringify(values));
|
|
197
316
|
if((row.probeHash!==null&&row.probeHash!==value)||row.probeError){
|
|
198
317
|
row.revision++;row.lastReason='declared state probe changed or recovered';}
|
|
199
318
|
row.probeHash=value;row.probeAt=Date.now();row.probeError=null;
|
|
200
|
-
if(checkpoint)row.probeCache=checkpoint.queries?{input:key,context:checkpoint.context,
|
|
201
|
-
hash:value,queries:checkpoint.queries
|
|
319
|
+
if(checkpoint)row.probeCache=checkpoint.queries&&(!inProcess||basis)?{input:key,context:checkpoint.context,
|
|
320
|
+
hash:value,queries:checkpoint.queries,...(inProcess?{schema:1,...basis,
|
|
321
|
+
identities:new Map(),
|
|
322
|
+
output:checkpoint.output,queryData:checkpoint.queryData,layout:checkpoint.layout,
|
|
323
|
+
stderrHash:values[0][3]}: {})}:null;
|
|
202
324
|
timing('daemon.probe',started,{outcome:'ok',commands:values.length,
|
|
203
325
|
reusable:row.probeCache?1:0,queries:row.probeCache?.queries.length||0});
|
|
204
326
|
}
|
|
@@ -226,6 +348,18 @@ function describeChangedInput(plan,file) {
|
|
|
226
348
|
.map(mapping=>mapping.logical).slice(0,2);
|
|
227
349
|
return file+' changed'+(matches.length?` (installed via ${matches.join(', ')})`:'');
|
|
228
350
|
}
|
|
351
|
+
// A run that hid the checkout's git-ignored entries (sandbox@2), and maybe
|
|
352
|
+
// documentation files (sandbox@3, the receipt's sandbox.docs): the first
|
|
353
|
+
// changed input it could see (sandbox.mjs's visibleChange), or null; once
|
|
354
|
+
// per receipt and pair of fingerprints.
|
|
355
|
+
const hiddenRun=r=>Array.isArray(r?.sandbox?.hidden)&&Boolean(r.files);
|
|
356
|
+
function visibleChange(row,r,name) {
|
|
357
|
+
const key=(r.invocation?.runId||'')+'\0'+r.fingerprint+'\0'+combinedSummary(name).fingerprint;
|
|
358
|
+
if(row.visibleKey!==key){const current=combinedFiles(name);
|
|
359
|
+
row.visibleKey=key;row.visibleChange=sandboxVisibleChange(root,r.sandbox.hidden,r.files,current,[...Object.keys(r.files),...Object.keys(current)],
|
|
360
|
+
{docs:Array.isArray(r.sandbox.docs)?r.sandbox.docs:null});}
|
|
361
|
+
return row.visibleChange;
|
|
362
|
+
}
|
|
229
363
|
function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=false) {
|
|
230
364
|
if(!index)return [];
|
|
231
365
|
return [...index.checks].map(([name,row])=>{
|
|
@@ -233,7 +367,10 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
233
367
|
const item={name,result,freshness:'UNVERIFIED',reason:'no receipt',changed_inputs:[],
|
|
234
368
|
plan_id:row.plan.id,observed_at:lastObservation,
|
|
235
369
|
invocation:r?.invocation||null,target_provenance:r?.target||null,
|
|
236
|
-
|
|
370
|
+
...(r?.verificationRecipe?{verification_recipe:r.verificationRecipe}:{}),
|
|
371
|
+
...(r?.recipe?{recipe:r.recipe}:{}),
|
|
372
|
+
coverage_at_run:r?.coverage||null,declared_inputs_match:null,reuse_eligible:false,
|
|
373
|
+
...(r?.runFacts?.length?{run_facts:r.runFacts.length}:{})};
|
|
237
374
|
if(receiptStateError){item.reason=receiptStateError;return item;}
|
|
238
375
|
if(!healthy||planning||recovering||index.unavailable){item.reason=
|
|
239
376
|
(planning||recovering)?'input observation reconciliation pending':
|
|
@@ -243,8 +380,9 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
243
380
|
return item;}
|
|
244
381
|
if(pendingChecks.has(name)){item.reason='filesystem change pending content inspection';return item;}
|
|
245
382
|
if(r&&r.planId) item.declared_inputs_match=r.planId===row.plan.id&&
|
|
246
|
-
r.fingerprint===combinedSummary(name).fingerprint;
|
|
247
|
-
if(r
|
|
383
|
+
(r.fingerprint===combinedSummary(name).fingerprint||hiddenRun(r)&&visibleChange(row,r,name)===null);
|
|
384
|
+
if(r&&!(r.fingerprintVersion===1||r.fingerprintVersion===2&&Array.isArray(r.runFacts))){
|
|
385
|
+
item.reason='receipt uses earlier input-resolution format';return item;}
|
|
248
386
|
if(row.plan.unresolved.length){
|
|
249
387
|
const checkpoint=item.declared_inputs_match===false?
|
|
250
388
|
r.planId!==row.plan.id?'; observed input plan changed':
|
|
@@ -253,7 +391,13 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
253
391
|
item.reason='input coverage unresolved: '+row.plan.unresolved.join('; ')+checkpoint+probeIssue;
|
|
254
392
|
return item;
|
|
255
393
|
}
|
|
256
|
-
if(row.probeError
|
|
394
|
+
if(row.probeError){item.reason=row.probeError;return item;}
|
|
395
|
+
// The plan leaves out the build-info file it saw at start; the probe sees the current one.
|
|
396
|
+
if(row.probeCache?.layout&&(row.probeCache.layout.buildInfo||null)!==(row.plan.buildInfo||null)){
|
|
397
|
+
item.reason='the incremental build-info setting changed after the observer started; restart the observer';return item;}
|
|
398
|
+
// An expired probe or missing execution context cannot confirm CURRENT, but
|
|
399
|
+
// observed input content that already differs from the receipt is STALE.
|
|
400
|
+
let unconfirmed=!row.probeAt||Date.now()-row.probeAt>10000?'state probe observation expired':null;
|
|
257
401
|
if(row.plan.synchronizedInstalledRead&&!synchronizedInstalled){
|
|
258
402
|
item.reason='installed inputs require a synchronized content read';return item;}
|
|
259
403
|
if(process.platform==='win32'&&!synchronizedFilesystem){
|
|
@@ -266,15 +410,21 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
266
410
|
item.reason=`target execution ${r.target?.status||'unavailable'}`;return item;}
|
|
267
411
|
if(r.target.status==='failed'&&result!=='FAIL'){
|
|
268
412
|
item.reason='target failure conflicts with outer invocation outcome';return item;}
|
|
269
|
-
if(!r.stable){
|
|
413
|
+
if(!r.stable){const issues=r.checkpoint?.issues?.length?` (${r.checkpoint.issues.join('; ')})`:'';
|
|
414
|
+
// A sandboxed run that could not start, whose result the sandbox changed, or whose rerun outside it did not finish, says so alone.
|
|
415
|
+
const sandbox=r.checkpoint?.issues?.length&&r.checkpoint.issues.every(issue=>/^the (?:sandbox changed|run was not sandboxed|run outside the sandbox|network supervisor)/.test(issue));
|
|
416
|
+
item.reason=sandbox?r.checkpoint.issues.join('; '):`inputs changed during execution or observation gap${issues}`;return item;}
|
|
270
417
|
const names=[...(row.plan.environment.variables||[]),
|
|
271
418
|
...(row.plan.environment.prefixes||[]).map(prefix=>
|
|
272
419
|
`__vstate_env_prefix_${prefix.toLowerCase()}__`),
|
|
273
420
|
...(row.plan.environment.pathExecutables||[]).map(name=>
|
|
274
421
|
`__vstate_path_executable_${name}__`),
|
|
275
422
|
...(row.plan.environment.executableIdentity?['__vstate_node_identity__']:[])];
|
|
276
|
-
|
|
277
|
-
|
|
423
|
+
// A sandboxed run's variables, toolchain, PATH directories and places
|
|
424
|
+
// (sandbox.mjs): every entry either side has, and the first change named.
|
|
425
|
+
const sandboxChanged=isSandboxed(row.plan.qualification)&&contextHashes?.[name]?
|
|
426
|
+
sandboxChange(r?.environmentHashes,contextHashes[name]):null;
|
|
427
|
+
if(names.length&&!contextHashes?.[name])unconfirmed??='execution environment context not supplied';
|
|
278
428
|
item.freshness='STALE';
|
|
279
429
|
if(r.planId!==row.plan.id){
|
|
280
430
|
const before=new Map((r.resolutionLinks||[]).map(link=>[link.path,link.targetHash]));
|
|
@@ -282,10 +432,21 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
282
432
|
item.reason=changed?`installed input link changed: ${changed.path}`:'input plan changed';
|
|
283
433
|
if(changed)item.changed_inputs=[changed.path];
|
|
284
434
|
}
|
|
285
|
-
else if(
|
|
435
|
+
else if(sandboxChanged)item.reason=sandboxChanged;
|
|
436
|
+
else if(contextHashes?.[name]&&names.some(key=>contextHashes[name][key]!==r.environmentHashes?.[key]))
|
|
286
437
|
item.reason='declared execution environment changed';
|
|
287
|
-
|
|
438
|
+
// Every variable a test run read directly must have the same value. The
|
|
439
|
+
// caller compares them and names the list it used; another list confirms nothing.
|
|
440
|
+
else if(r.environmentReads&&contextHashes?.[name]&&
|
|
441
|
+
(contextHashes[name].__redue_env_reads__!==sha(JSON.stringify(r.environmentReads))||contextHashes[name].__redue_env_changed__)){
|
|
442
|
+
if(contextHashes[name].__redue_env_reads__!==sha(JSON.stringify(r.environmentReads))){
|
|
443
|
+
item.freshness='UNVERIFIED';item.reason='environment variables the last run read were not compared';}
|
|
444
|
+
else item.reason=`environment variable ${contextHashes[name].__redue_env_changed__} differs from the last run`;
|
|
445
|
+
}
|
|
446
|
+
else if(r.fingerprint!==combinedSummary(name).fingerprint&&!(hiddenRun(r)&&visibleChange(row,r,name)===null)) {
|
|
288
447
|
const key=r.fingerprint+'\0'+combinedSummary(name).fingerprint;
|
|
448
|
+
if(row.staleReasonKey!==key&&hiddenRun(r)){
|
|
449
|
+
row.staleInputs=[visibleChange(row,r,name)];row.staleReason=describeChangedInput(row.plan,row.staleInputs[0]);row.staleReasonKey=key;}
|
|
289
450
|
if(row.staleReasonKey!==key){
|
|
290
451
|
let changed=row.lastReason;row.staleInputs=[];
|
|
291
452
|
const candidate=changed?.endsWith(' changed')?changed.slice(0,-8):null;
|
|
@@ -297,30 +458,98 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
297
458
|
if(r.files[f]!==current[f]){changed=describeChangedInput(row.plan,f);row.staleInputs=[f];break;}
|
|
298
459
|
}
|
|
299
460
|
row.staleReason=changed||
|
|
300
|
-
(['typescript-noemit-v1','yarn-workspace-tsc-v1','pnpm-tsc-v1']
|
|
461
|
+
(['typescript-noemit-v1','npm-typescript-direct-v1','yarn-workspace-tsc-v1','pnpm-tsc-v1']
|
|
301
462
|
.includes(row.plan.qualification)?
|
|
302
463
|
'TypeScript input set, generated file, or toolchain context changed':
|
|
303
464
|
'declared input content or membership changed');
|
|
304
465
|
row.staleReasonKey=key;
|
|
305
466
|
}
|
|
306
467
|
item.reason=row.staleReason;item.changed_inputs=row.staleInputs||[];
|
|
468
|
+
if(unconfirmed&&!(r.files&&row.staleInputs?.length)){item.freshness='UNVERIFIED';
|
|
469
|
+
item.reason=unconfirmed;item.changed_inputs=[];}
|
|
470
|
+
} else if(unconfirmed){item.freshness='UNVERIFIED';item.reason=unconfirmed;
|
|
471
|
+
// Lookups the run made outside the certificate are asked again for every
|
|
472
|
+
// answer, cached or synchronized, before it can say CURRENT.
|
|
473
|
+
// A direct run recorded before run facts may have made lookups no probe
|
|
474
|
+
// asks; only Windows, which never records direct runs, keeps reusing them.
|
|
475
|
+
} else if(r.fingerprintVersion===1&&r.verificationRecipe==='npm-direct-typescript@1'&&process.platform!=='win32'){
|
|
476
|
+
item.freshness='UNVERIFIED';item.reason='receipt predates run facts; run the check again';
|
|
477
|
+
} else if(r.runFacts&&(item.reason=runFactReason(r.runFacts))){item.changed_inputs=[];
|
|
478
|
+
// A recorded test run that used something REDUE does not observe, or
|
|
479
|
+
// whose result differed from an earlier run on the same inputs.
|
|
480
|
+
} else if(r.guardIssues?.length){item.freshness='UNVERIFIED';
|
|
481
|
+
item.reason=`the run used inputs REDUE does not observe: ${r.guardIssues.slice(0,3).join('; ')}`+
|
|
482
|
+
(r.guardIssues.length>3?`; and ${r.guardIssues.length-3} more`:'');
|
|
483
|
+
} else if(r.nondeterministic){item.freshness='UNVERIFIED';item.reason='runs on these exact inputs had different results';
|
|
307
484
|
} else {item.freshness='CURRENT';item.reason=result==='FAIL'?'last command failed':'declared inputs match';
|
|
308
485
|
item.reuse_eligible=result==='PASS';}
|
|
309
486
|
return item;
|
|
310
487
|
});
|
|
311
488
|
}
|
|
489
|
+
// Null when every run fact in a receipt answers as it did during the run.
|
|
490
|
+
function runFactReason(facts) {
|
|
491
|
+
const started=performance.now(),reason=runFactChanged(facts);
|
|
492
|
+
timing('daemon.run_facts',started,{facts:facts.length,changed:reason?1:0});
|
|
493
|
+
return reason;
|
|
494
|
+
}
|
|
495
|
+
// The indexed inputs already differ from the latest receipt, so no probe result
|
|
496
|
+
// can make this check CURRENT. A sync answers STALE without a full probe. A run
|
|
497
|
+
// probes, or builds the certificate from its recording (adopt), and a revert
|
|
498
|
+
// makes the inputs match and probes again.
|
|
499
|
+
function knownStale(name,row) {
|
|
500
|
+
const r=receipts[name];
|
|
501
|
+
if(!r?.files||r.planId!==row.plan.id||row.probeError||pendingChecks.has(name)||
|
|
502
|
+
row.probeCache?.input===inputKey(row))return false;
|
|
503
|
+
const current=combinedFiles(name),files=Object.keys(current);
|
|
504
|
+
return files.length!==Object.keys(r.files).length||files.some(file=>r.files[file]!==current[file]);
|
|
505
|
+
}
|
|
506
|
+
// A run can rebuild a certificate that no longer applies only because indexed
|
|
507
|
+
// inputs changed: same plan guard, compiler, toolchain and observation.
|
|
508
|
+
function adoptable(row) {
|
|
509
|
+
const cache=row.probeCache;
|
|
510
|
+
if(process.platform==='win32'||row.plan.qualification!=='npm-typescript-direct-v1'||
|
|
511
|
+
row.plan.unresolved.length||row.probeError||cache?.schema!==1||!cache.layout||!cache.queries?.length||
|
|
512
|
+
cache.input===inputKey(row)||cache.gaps!==metrics.gaps||!continuityBarrier())return false;
|
|
513
|
+
try{const basis=certificateBasis(row);
|
|
514
|
+
return basis.guard===cache.guard&&basis.compiler===cache.compiler&&basis.toolchain===cache.toolchain;}
|
|
515
|
+
catch{return false;}
|
|
516
|
+
}
|
|
517
|
+
// The end checkpoint of a run that started without a certificate. Its recorded
|
|
518
|
+
// answers become the candidate certificate when nothing observed changed during
|
|
519
|
+
// the run. The probe that follows validates it, or runs in full as before.
|
|
520
|
+
function adopt(name,row,token) {
|
|
521
|
+
const started=performance.now(),pending=row.adoption,file=path.join(state,`run-record-${token}.json`);
|
|
522
|
+
row.adoption=null;
|
|
523
|
+
let text=null;
|
|
524
|
+
try{text=fs.readFileSync(file,'utf8');}catch{}
|
|
525
|
+
const done=issue=>{timing('daemon.recorded_certificate',started,{outcome:issue?'rejected':'accepted',
|
|
526
|
+
...(issue?{reason:issue}:{})});return issue;};
|
|
527
|
+
if(!pending||pending.token!==token||text===null)return done('recording unavailable');
|
|
528
|
+
if(inputEventSerial.get(name)!==pending.serial||metrics.gaps!==pending.gaps||inputKey(row)!==pending.input||
|
|
529
|
+
row.probeCache!==pending.cache||row.probeHash!==null)
|
|
530
|
+
return done('inputs or certificate changed during the run');
|
|
531
|
+
const cache=pending.cache,argv=row.plan.probes[0];
|
|
532
|
+
let result;
|
|
533
|
+
try{result=recordedCertificate(text,cache,directProject(argv[2],runFromArgv(argv.slice(3)),{verify:false}),buildInfoPath(row.plan));}
|
|
534
|
+
catch{return done('recording unreadable');}
|
|
535
|
+
if(result.issue)return done(result.issue);
|
|
536
|
+
const identities=new Map(cache.identities);
|
|
537
|
+
for(const changed of result.changed)identities.delete(changed);
|
|
538
|
+
row.probeCache={...cache,hash:directProbeValue(argv,result.output,cache.stderrHash),
|
|
539
|
+
queries:result.queries,identities,output:result.output,layout:result.layout,input:pending.input,recorded:token,
|
|
540
|
+
queryData:deflateSync(JSON.stringify(result.queries)).toString('base64')};
|
|
541
|
+
metrics.recordedCertificates=(metrics.recordedCertificates||0)+1;
|
|
542
|
+
return done(null);
|
|
543
|
+
}
|
|
312
544
|
function status(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=false) {
|
|
313
545
|
// Reload notifications accelerate visibility but are not required for it: a
|
|
314
546
|
// wrapper may persist its outcome even when its control connection is denied.
|
|
315
547
|
refreshReceipts();
|
|
316
|
-
const checks=rows(contextHashes,synchronizedInstalled,synchronizedFilesystem)
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const stateName=failed?'failed':unverified?'unverified':stale?'stale':'current';
|
|
322
|
-
const now=Date.now();return {schema:1,receipt_revision:receiptStateRevision,state:stateName,current,failed,observed_failed:observedFailed,
|
|
323
|
-
stale,unverified,checks,
|
|
548
|
+
const checks=rows(contextHashes,synchronizedInstalled,synchronizedFilesystem);
|
|
549
|
+
// A decision may run every check's probes in turn: callers wait for all of them.
|
|
550
|
+
const probing=[...(index?.checks.values()||[])].filter(row=>row.plan.probes.length);
|
|
551
|
+
const now=Date.now();return {schema:1,receipt_revision:receiptStateRevision,...statusSummary(checks),checks,
|
|
552
|
+
...(probing.length?{probe_deadline_ms:probing.reduce((sum,row)=>sum+fullProbeDeadline(row)*row.plan.probes.length,0)}:{}),
|
|
324
553
|
observation:{healthy:healthy&&!planning&&!recovering&&!index?.unavailable,
|
|
325
554
|
generation,observed_at:lastObservation,
|
|
326
555
|
events:(index?.eventCount||0)+[...external.values()].reduce((n,e)=>n+(e.index?.eventCount||0),0),
|
|
@@ -355,16 +584,10 @@ function hydrate(bundle,saved) {
|
|
|
355
584
|
}
|
|
356
585
|
next.eventCount=saved.eventCount;next.rehashedFiles=saved.rehashedFiles;
|
|
357
586
|
next.rehashedBytes=saved.rehashedBytes;next.profile=saved.profile;
|
|
587
|
+
next.racy=new Map(saved.racy||[]);
|
|
358
588
|
return next;
|
|
359
589
|
}
|
|
360
|
-
|
|
361
|
-
return {files:[...value.files],
|
|
362
|
-
aliasDependencies:[...value.aliasDependencies].map(([name,refs])=>[name,[...refs]]),
|
|
363
|
-
rows:[...value.checks].map(([name,row])=>({name,files:[...row.files],
|
|
364
|
-
sum:row.sum.toString('hex'),revision:row.revision,lastReason:row.lastReason})),
|
|
365
|
-
eventCount:value.eventCount,rehashedFiles:value.rehashedFiles,
|
|
366
|
-
rehashedBytes:value.rehashedBytes,profile:value.profile};
|
|
367
|
-
}
|
|
590
|
+
const dehydrate=dehydrateIndex;
|
|
368
591
|
function retainIndex() {
|
|
369
592
|
if(!index||!healthy||planning||pending.size||planDirty||historyDisabled||
|
|
370
593
|
!fs.existsSync(snapshotPath))return;
|
|
@@ -384,7 +607,7 @@ function buildInWorker(forceCold=false,reuse=null) {
|
|
|
384
607
|
return new Promise((resolve,reject)=>{
|
|
385
608
|
const workerStarted=performance.now();
|
|
386
609
|
const file=path.join(path.dirname(fileURLToPath(import.meta.url)),'plan-worker.mjs');
|
|
387
|
-
const worker=new Worker(file,{workerData:{config:configFile,state,forceCold,reuse,
|
|
610
|
+
const worker=new Worker(file,{workerData:{config:configFile,state,forceCold,reuse,diagnostic:traceContext(),
|
|
388
611
|
reconcileFaultFile:process.env.VSTATE_TEST_RECONCILE_FAULT_FILE}});
|
|
389
612
|
planningWorker=worker;let settled=false;
|
|
390
613
|
const deadline=setTimeout(()=>{
|
|
@@ -409,7 +632,7 @@ function buildInWorker(forceCold=false,reuse=null) {
|
|
|
409
632
|
reject(Error(`input-plan worker exited ${code}`));}});
|
|
410
633
|
});
|
|
411
634
|
}
|
|
412
|
-
async function refreshPlan({forceCold=false,reuse=false}={}) {
|
|
635
|
+
async function refreshPlan({forceCold=false,reuse=false,skip=null}={}) {
|
|
413
636
|
if(planning||stopping)return;
|
|
414
637
|
const totalStarted=performance.now();
|
|
415
638
|
const previousProbes=new Map([...(index?.checks||[])].map(([name,row])=>[name,row.probeCache]));
|
|
@@ -492,7 +715,8 @@ async function refreshPlan({forceCold=false,reuse=false}={}) {
|
|
|
492
715
|
atomic(path.join(state,'plans-v1.json'),persistedPlan(bundle));
|
|
493
716
|
metrics.lastPlanPersistenceMs=performance.now()-persistenceStarted;
|
|
494
717
|
const probeStarted=performance.now();
|
|
495
|
-
|
|
718
|
+
if(skip)refreshReceipts();
|
|
719
|
+
for(const [name,row] of index.checks)if(!skip?.(name,row))probe(row,built.planReused);
|
|
496
720
|
timing('daemon.plan_probes',probeStarted,{checks:index.checks.size});
|
|
497
721
|
lastObservation=Date.now();
|
|
498
722
|
event(built.planReused?'plan_validated':'plan_rebuilt',{checks:Object.keys(bundle.plans),workspace_count:bundle.workspaceCount});
|
|
@@ -588,14 +812,25 @@ function pnpmTopologyChanged(type,rel) {
|
|
|
588
812
|
return stat.isDirectory()||stat.isSymbolicLink();}
|
|
589
813
|
catch(e){return e.code==='ENOENT';}
|
|
590
814
|
}
|
|
591
|
-
function markInputEvent(name,rel,type) {
|
|
815
|
+
function markInputEvent(name,rel,type,replay) {
|
|
592
816
|
pendingChecks.add(name);
|
|
593
|
-
|
|
817
|
+
// macOS delivers each edit twice: live, and again from history replay. The
|
|
818
|
+
// live copy can arrive late, inside a later run. With history, count only the
|
|
819
|
+
// replay copy: the history cursor places it in the right interval.
|
|
820
|
+
if(!hasHistory||historyDisabled||replay){
|
|
821
|
+
const serial=(inputEventSerial.get(name)||0)+1;
|
|
822
|
+
inputEventSerial.set(name,serial);
|
|
823
|
+
// Kept only for recorded test and script checks and sandboxed ones; trimmed in batches.
|
|
824
|
+
if(['npm-tests-direct-v1','node-script-v1'].includes(index?.checks.get(name)?.plan.qualification)||isSandboxed(index?.checks.get(name)?.plan.qualification)){
|
|
825
|
+
const list=inputEventPaths.get(name)||[];list.push([serial,rel]);
|
|
826
|
+
if(list.length>2*eventPathLimit)list.splice(0,list.length-eventPathLimit);inputEventPaths.set(name,list);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
594
829
|
// Disposable public CI only. Never enabled for normal/local repositories.
|
|
595
830
|
if(process.env.REDUE_PUBLIC_INPUT_TRACE==='1'&&rel&&diagnosticInputEvents++<5000)
|
|
596
|
-
event('public_input_notification',{path:rel,type:type||'unknown',planning});
|
|
831
|
+
event('public_input_notification',{path:rel,type:type||'unknown',planning,replay});
|
|
597
832
|
}
|
|
598
|
-
function notification(type,filename) {
|
|
833
|
+
function notification(type,filename,replay=false) {
|
|
599
834
|
metrics.notifications++;
|
|
600
835
|
metrics.lastNotificationAt=Date.now();
|
|
601
836
|
if(!filename){observationGap('unnamed_event');return;}
|
|
@@ -603,7 +838,7 @@ function notification(type,filename) {
|
|
|
603
838
|
if(rel==='.git'||rel.startsWith('.git/'))return;
|
|
604
839
|
if(planning) {
|
|
605
840
|
if(index)for(const name of index.candidates(rel))
|
|
606
|
-
if(index.matches(index.checks.get(name),rel))markInputEvent(name,rel,type);
|
|
841
|
+
if(index.matches(index.checks.get(name),rel))markInputEvent(name,rel,type,replay);
|
|
607
842
|
pending.add(rel);metrics.maxQueue=Math.max(metrics.maxQueue,pending.size);
|
|
608
843
|
if(planTrigger(rel)||candidateDescendants.has(rel)||pnpmTopologyChanged(type,rel))
|
|
609
844
|
planDirty=true;
|
|
@@ -627,9 +862,9 @@ function notification(type,filename) {
|
|
|
627
862
|
pending.add(rel);metrics.maxQueue=Math.max(metrics.maxQueue,pending.size);
|
|
628
863
|
if(index) {
|
|
629
864
|
for(const name of index.candidates(rel))
|
|
630
|
-
if(index.matches(index.checks.get(name),rel))markInputEvent(name,rel,type);
|
|
865
|
+
if(index.matches(index.checks.get(name),rel))markInputEvent(name,rel,type,replay);
|
|
631
866
|
for(const [prefix,names] of index.prefixes)
|
|
632
|
-
if(prefix.startsWith(rel+'/'))for(const name of names)markInputEvent(name,rel,type);
|
|
867
|
+
if(prefix.startsWith(rel+'/'))for(const name of names)markInputEvent(name,rel,type,replay);
|
|
633
868
|
}
|
|
634
869
|
if(pending.size>50000){healthy=false;reason='observation backlog overflow';metrics.gaps++;
|
|
635
870
|
event('observation_gap',{classification:'backlog_overflow'});publish();
|
|
@@ -638,23 +873,23 @@ function notification(type,filename) {
|
|
|
638
873
|
if(planDirty){healthy=false;reason='input plan possibly changed';}
|
|
639
874
|
if(planDirty||pendingChecks.size)publish();
|
|
640
875
|
}
|
|
641
|
-
function externalNotification(entry,type,filename) {
|
|
876
|
+
function externalNotification(entry,type,filename,replay=false) {
|
|
642
877
|
metrics.notifications++;
|
|
643
878
|
metrics.lastNotificationAt=Date.now();
|
|
644
879
|
if(!filename){externalGap(entry,'unnamed_event');return;}
|
|
645
880
|
const rel=filename.toString().split(path.sep).join('/');
|
|
646
881
|
const absolute=path.join(entry.root,rel);
|
|
647
882
|
if(planning&&entry.index)for(const name of entry.index.candidates(rel))
|
|
648
|
-
if(entry.index.matches(entry.index.checks.get(name),rel))markInputEvent(name
|
|
883
|
+
if(entry.index.matches(entry.index.checks.get(name),rel))markInputEvent(name,`external:${entry.root}:${rel}`,type,replay);
|
|
649
884
|
if(planTrigger(absolute)&&triggerChanged(absolute))planDirty=true;
|
|
650
885
|
if(candidateTriggerChanged(absolute))planDirty=true;
|
|
651
886
|
entry.pending.add(rel);metrics.maxQueue=Math.max(metrics.maxQueue,
|
|
652
887
|
pending.size+[...external.values()].reduce((n,e)=>n+e.pending.size,0));
|
|
653
888
|
if(entry.index) {
|
|
654
889
|
for(const name of entry.index.candidates(rel))
|
|
655
|
-
if(entry.index.matches(entry.index.checks.get(name),rel))markInputEvent(name
|
|
890
|
+
if(entry.index.matches(entry.index.checks.get(name),rel))markInputEvent(name,`external:${entry.root}:${rel}`,type,replay);
|
|
656
891
|
for(const [prefix,names] of entry.index.prefixes)
|
|
657
|
-
if(prefix.startsWith(rel+'/'))for(const name of names)markInputEvent(name
|
|
892
|
+
if(prefix.startsWith(rel+'/'))for(const name of names)markInputEvent(name,`external:${entry.root}:${rel}`,type,replay);
|
|
658
893
|
}
|
|
659
894
|
if(planDirty){healthy=false;reason='input plan possibly changed';}
|
|
660
895
|
publish();clearTimeout(flushTimer);flushTimer=setTimeout(flush,60);
|
|
@@ -672,7 +907,8 @@ function observationGap(classification,error) {
|
|
|
672
907
|
// Once continuity is uncertain, a cursor is no longer authority. Do not
|
|
673
908
|
// retry native history in this observer generation after reconciliation.
|
|
674
909
|
historyDisabled=true;
|
|
675
|
-
|
|
910
|
+
const cause=error?.message?` (${error.message})`:'';
|
|
911
|
+
healthy=false;reason=`observation unavailable: ${classification}${cause}; deterministic reconciliation pending`;
|
|
676
912
|
metrics.gaps++;
|
|
677
913
|
event('observation_gap',{classification,error_code:error?.code||null});publish();
|
|
678
914
|
// One bounded attempt per gap; a later independent gap may try again.
|
|
@@ -758,14 +994,14 @@ async function catchUp() {
|
|
|
758
994
|
lastObservation=Date.now();return;
|
|
759
995
|
}
|
|
760
996
|
if(historyDisabled)return;
|
|
761
|
-
if(synchronizing)return synchronizing;
|
|
997
|
+
if(synchronizing){mark('daemon.catch_up_join');return synchronizing;}
|
|
762
998
|
synchronizing=(async()=>{
|
|
763
999
|
if(!verifyRoot())throw Error('checkout identity changed');
|
|
764
1000
|
if(!watcher)throw Error('subscription unavailable');
|
|
765
1001
|
if(!fs.existsSync(snapshotPath))throw Error('historical cursor missing');
|
|
766
1002
|
{
|
|
767
1003
|
const changes=await queryHistory(root,snapshotPath,'root replay');
|
|
768
|
-
for(const change of changes)notification(change.type,path.relative(root,change.path));
|
|
1004
|
+
for(const change of changes)notification(change.type,path.relative(root,change.path),true);
|
|
769
1005
|
if(!planning&&(flushTimer||pending.size||planDirty))flush();
|
|
770
1006
|
for(const entry of external.values())if(entry.index) {
|
|
771
1007
|
try{
|
|
@@ -775,7 +1011,7 @@ async function catchUp() {
|
|
|
775
1011
|
if(!entry.watcher||!fs.existsSync(entry.snapshot))throw Error('external cursor unavailable');
|
|
776
1012
|
const changes=await queryHistory(entry.root,entry.snapshot,'external replay');
|
|
777
1013
|
for(const change of changes)externalNotification(entry,change.type,
|
|
778
|
-
path.relative(entry.root,change.path));
|
|
1014
|
+
path.relative(entry.root,change.path),true);
|
|
779
1015
|
}catch(e){if(e.code==='ETIMEDOUT')throw e;
|
|
780
1016
|
externalGap(entry,'historical_replay_failed',e);}
|
|
781
1017
|
}
|
|
@@ -789,7 +1025,8 @@ async function catchUp() {
|
|
|
789
1025
|
function respond(client,value) {
|
|
790
1026
|
if(client.destroyed){event('control_reply_unavailable',{classification:'client_destroyed'});return;}
|
|
791
1027
|
try{const payload=JSON.stringify(value)+'\n';
|
|
792
|
-
|
|
1028
|
+
mark('server.response_constructed',{bytes:payload.length});
|
|
1029
|
+
client.end(payload,()=>mark('server.response_delivered'));client.vstateReplied=true;
|
|
793
1030
|
if(process.env.VSTATE_DEBUG_CONTROL==='1')event('control_reply_sent',{bytes:payload.length});}
|
|
794
1031
|
catch(e){event('control_reply_unavailable',{classification:e.name||'serialization_error'});
|
|
795
1032
|
if(!client.destroyed)client.end('{"error":"control response unavailable"}\n');}
|
|
@@ -807,10 +1044,11 @@ async function request(message) {
|
|
|
807
1044
|
if(message.action==='sync'||message.action==='snapshot'||message.action==='prelaunch') {
|
|
808
1045
|
const totalStarted=performance.now();
|
|
809
1046
|
const decision=++decisionSequence;
|
|
1047
|
+
mark('server.decision_start',{decision,pending:pending.size,planDirty,planning,recovering,healthy,historyDisabled,decisionRequests,historyDeadlineMs,reconciliationDeadlineMs});
|
|
810
1048
|
timing('daemon.decision_reason',totalStarted,{decision,action:message.action,
|
|
811
1049
|
plan:process.platform==='win32'?'validate_dependencies_before_reuse':'existing_plan',
|
|
812
|
-
probe:'reuse_only_with_identical_content_and_context',installed:'
|
|
813
|
-
|
|
1050
|
+
probe:'reuse_only_with_identical_content_and_context',installed:'current_identity_required',
|
|
1051
|
+
planPhase:metrics.planPhase||'initializing',pending:pending.size,
|
|
814
1052
|
notifications:metrics.notifications,planRebuilds:metrics.planRebuilds});
|
|
815
1053
|
if(process.platform==='win32'&&process.env.VSTATE_TEST_FAULTS==='1'&&
|
|
816
1054
|
process.env.VSTATE_TEST_WINDOWS_GAP_FILE&&
|
|
@@ -825,9 +1063,17 @@ async function request(message) {
|
|
|
825
1063
|
// has been disabled: a recovered subscription may still have queued events.
|
|
826
1064
|
const needsContentReconciliation=process.platform==='win32'||(hasHistory&&historyDisabled);
|
|
827
1065
|
if(needsContentReconciliation&&message.action!=='prelaunch'){
|
|
828
|
-
|
|
1066
|
+
// Windows probes inside the reconciliation, so a known-STALE sync skips it there.
|
|
1067
|
+
const started=performance.now();
|
|
1068
|
+
await refreshPlan({forceCold:true,reuse:true,skip:message.action==='sync'?knownStale:null});
|
|
829
1069
|
timing('daemon.request_content_validation',started,{healthy:healthy?1:0});}
|
|
830
|
-
|
|
1070
|
+
// Without synchronized installed reads, the continuity barrier runs once,
|
|
1071
|
+
// after probing: in-process validation reads the live filesystem, and the
|
|
1072
|
+
// barrier then applies every change before the response is computed.
|
|
1073
|
+
const deferBarrier=process.platform!=='win32'&&!needsContentReconciliation&&
|
|
1074
|
+
message.action!=='prelaunch'&&index&&healthy&&!planning&&![...index.checks].some(([name,row])=>
|
|
1075
|
+
row.plan.synchronizedInstalledRead&&(!message.name||message.name===name));
|
|
1076
|
+
if(healthy&&!deferBarrier)try{const started=performance.now();await catchUp();
|
|
831
1077
|
timing('daemon.request_catch_up',started);}catch(e){observationGap('historical_query',e);}
|
|
832
1078
|
if(flushTimer||pending.size||planDirty||[...external.values()].some(e=>e.pending.size))flush();
|
|
833
1079
|
let synchronizedInstalled=false;
|
|
@@ -862,10 +1108,66 @@ async function request(message) {
|
|
|
862
1108
|
}catch(e){installedRecheck=null;observationGap('installed_recheck',e);}
|
|
863
1109
|
}
|
|
864
1110
|
const probeStarted=performance.now();
|
|
865
|
-
|
|
866
|
-
|
|
1111
|
+
// A run's start checkpoint skips the full probe when its recording can
|
|
1112
|
+
// replace the certificate; the end checkpoint offers that recording.
|
|
1113
|
+
let adoptIntent=false,recorded;const adopting=new Set();
|
|
1114
|
+
if(index&&healthy&&!validatedColdIndex&&message.action!=='prelaunch'){
|
|
1115
|
+
const continuous=continuityBarrier(),probed=index,gaps=metrics.gaps,decided=Date.now();
|
|
1116
|
+
const selected=[...index.checks].filter(([name])=>!message.name||message.name===name);
|
|
1117
|
+
if(message.action==='snapshot'&&typeof message.adopt==='string'&&/^[0-9a-f-]{36}$/.test(message.adopt)){
|
|
1118
|
+
const row=index.checks.get(message.name);
|
|
1119
|
+
recorded=row?adopt(message.name,row,message.adopt):'recording unavailable';
|
|
1120
|
+
}
|
|
1121
|
+
adoptIntent=message.action==='snapshot'&&message.runCertificate===1&&!message.adopt&&continuous;
|
|
1122
|
+
const keys=new Map(selected.map(([name,row])=>[name,inputKey(row)]));
|
|
1123
|
+
const skip=message.action==='sync'?knownStale:()=>false;
|
|
1124
|
+
if(message.action==='sync')refreshReceipts();
|
|
1125
|
+
// With a deferred barrier, validate in-process first and launch any full
|
|
1126
|
+
// probe only after the barrier, so a queued edit costs one probe, not two.
|
|
1127
|
+
for(const [name,row] of selected)if(!skip(name,row))probe(row,continuous,{launch:!deferBarrier&&!adoptIntent});
|
|
1128
|
+
if(continuous&&process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_MAC_PROBE_SIGNAL){
|
|
1129
|
+
fs.writeFileSync(process.env.VSTATE_TEST_MAC_PROBE_SIGNAL,'validated');
|
|
1130
|
+
await new Promise(resolve=>setTimeout(resolve,200));
|
|
1131
|
+
}
|
|
1132
|
+
// Validation and probes block JS event delivery. Finish with a continuity
|
|
1133
|
+
// barrier so queued edits/gaps cannot be published using the pre-probe index.
|
|
1134
|
+
if(continuous||deferBarrier)try{const started=performance.now();
|
|
1135
|
+
await catchUp();if(planDirty||pending.size||flushTimer)flush();
|
|
1136
|
+
timing('daemon.request_catch_up',started,{after:'probes'});
|
|
1137
|
+
// Rows not yet confirmed, or whose inputs moved meanwhile, are probed
|
|
1138
|
+
// from the barrier, as an undeferred decision would have done.
|
|
1139
|
+
// Decided once, after the barrier. The start probe value is already out
|
|
1140
|
+
// of date; clearing it means whichever probe comes next sets the value
|
|
1141
|
+
// for this unchanged interval without counting as a change during the run.
|
|
1142
|
+
if(adoptIntent&&index===probed&&healthy&&!planning)for(const [name,row] of selected)
|
|
1143
|
+
if(row.probeAt<decided&&adoptable(row)){
|
|
1144
|
+
adopting.add(name);row.probeHash=null;
|
|
1145
|
+
row.adoption={token:randomUUID(),serial:inputEventSerial.get(name)||0,gaps:metrics.gaps,
|
|
1146
|
+
input:inputKey(row),cache:row.probeCache};
|
|
1147
|
+
}
|
|
1148
|
+
const rest=index===probed&&healthy&&!planning?selected.filter(([name,row])=>!skip(name,row)&&
|
|
1149
|
+
!adopting.has(name)&&(row.probeAt<decided||metrics.gaps!==gaps||inputKey(row)!==keys.get(name))):[];
|
|
1150
|
+
const launched=metrics.fullProbes||0;
|
|
1151
|
+
for(const [,row] of rest)probe(row,continuityBarrier());
|
|
1152
|
+
if(continuous&&(metrics.fullProbes||0)>launched){await catchUp();if(planDirty||pending.size||flushTimer)flush();}
|
|
1153
|
+
}catch(e){observationGap('post_probe_history',e);}
|
|
1154
|
+
}
|
|
867
1155
|
timing('daemon.request_probes',probeStarted,{checks:index?.checks.size||0});
|
|
868
1156
|
lastObservation=Date.now();publish();
|
|
1157
|
+
// A run checkpoint never holds an installed identity from the racy window:
|
|
1158
|
+
// a same-size write in the same timestamp tick would keep it. Wait the
|
|
1159
|
+
// window out (at most 2 s), record those files again, and mark the
|
|
1160
|
+
// checkpoint unusable if one is still inside it.
|
|
1161
|
+
let racy=false;
|
|
1162
|
+
if(message.action==='snapshot'||message.action==='prelaunch'){
|
|
1163
|
+
const names=message.name?[message.name]:null;
|
|
1164
|
+
const indexes=()=>[index,...[...external.values()].map(e=>e.index)].filter(Boolean);
|
|
1165
|
+
const wait=Math.min(racyWindowMs,Math.max(0,...indexes().map(i=>i.racyUntil(names)))-Date.now());
|
|
1166
|
+
if(wait>0){metrics.racyWaits=(metrics.racyWaits||0)+1;await new Promise(resolve=>setTimeout(resolve,wait+5));}
|
|
1167
|
+
for(const i of indexes())i.settleRacy();
|
|
1168
|
+
racy=indexes().some(i=>i.racyUntil(names)>Date.now());
|
|
1169
|
+
if(racy)metrics.racyCheckpoints=(metrics.racyCheckpoints||0)+1;
|
|
1170
|
+
}
|
|
869
1171
|
const applicabilityStarted=performance.now();
|
|
870
1172
|
const data=status(message.contextHashes,synchronizedInstalled,
|
|
871
1173
|
process.platform==='win32'&&healthy&&!planning&&!planDirty);
|
|
@@ -875,8 +1177,43 @@ async function request(message) {
|
|
|
875
1177
|
data.snapshots[name]={...combinedSummary(name),files:combinedFiles(name),
|
|
876
1178
|
planGeneration:row.plan.id,
|
|
877
1179
|
inputEventSerial:inputEventSerial.get(name)||0,
|
|
878
|
-
|
|
879
|
-
|
|
1180
|
+
// Complete only when the oldest kept event follows the given serial.
|
|
1181
|
+
...(Number.isSafeInteger(message.sinceSerial)?(()=>{const list=inputEventPaths.get(name)||[],
|
|
1182
|
+
since=message.sinceSerial,later=list.filter(([serial])=>serial>since);
|
|
1183
|
+
return {inputEventPaths:later.map(([,rel])=>rel),
|
|
1184
|
+
inputEventPathsComplete:(inputEventSerial.get(name)||0)-since===later.length};})():{}),
|
|
1185
|
+
probeHash:row.probeHash,inputKey:inputKey(row),observationGaps:metrics.gaps,
|
|
1186
|
+
observationHealthy:healthy&&!planning&&!recovering&&!racy&&applicableExternal(name).every(e=>e.healthy)};
|
|
1187
|
+
// Export only a currently guarded, check-specific discovery certificate.
|
|
1188
|
+
// The caller compares it with the answers its own compile received. Schema 2
|
|
1189
|
+
// certifies the realpath of every directory readDirectory visits. The
|
|
1190
|
+
// snapshot stays the start of the observed interval, including validation.
|
|
1191
|
+
if(message.runCertificate===1&&continuityBarrier()){
|
|
1192
|
+
const row=index?.checks.get(message.name),cache=row?.probeCache;
|
|
1193
|
+
// Also after an accepted recording: the caller compares its record with
|
|
1194
|
+
// this certificate and finds its run facts with it.
|
|
1195
|
+
if(row?.plan.qualification==='npm-typescript-direct-v1'&&
|
|
1196
|
+
!row.plan.unresolved.length&&cache?.schema===1&&cache.input===inputKey(row)&&
|
|
1197
|
+
cache.hash===row.probeHash&&cache.guard===planGuard(index.bundle)&&
|
|
1198
|
+
cache.output&&cache.queryData&&data.snapshots[message.name])
|
|
1199
|
+
data.snapshots[message.name].discoveryCertificate={schema:2,planId:row.plan.id,
|
|
1200
|
+
cwd:row.plan.cwd,probeDefinition:sha(JSON.stringify(row.plan.probes)),
|
|
1201
|
+
context:cache.context,output:cache.output,stderrHash:cache.stderrHash,
|
|
1202
|
+
queryData:cache.queryData};
|
|
1203
|
+
}
|
|
1204
|
+
// For a run checked by full probes: the last probe's questions, only to
|
|
1205
|
+
// find the run's lookups outside them. Never evidence for reuse.
|
|
1206
|
+
if(message.runFacts===1&&data.snapshots[message.name]){
|
|
1207
|
+
const row=index?.checks.get(message.name),cache=row?.probeCache;
|
|
1208
|
+
if(row?.plan.qualification==='npm-typescript-direct-v1'&&cache?.queryData&&
|
|
1209
|
+
typeof cache.context==='string'&&cache.input===inputKey(row)&&cache.hash===row.probeHash&&
|
|
1210
|
+
cache.guard===planGuard(index.bundle))
|
|
1211
|
+
data.snapshots[message.name].probeQuestions={context:cache.context,queryData:cache.queryData};
|
|
1212
|
+
}
|
|
1213
|
+
const row=index?.checks.get(message.name);
|
|
1214
|
+
if(adopting.has(message.name)&&row?.adoption&&data.snapshots[message.name])
|
|
1215
|
+
data.snapshots[message.name].recordedCertificate=row.adoption.token;
|
|
1216
|
+
if(typeof recorded==='string'&&data.snapshots[message.name])data.snapshots[message.name].recordingIssue=recorded;
|
|
880
1217
|
}
|
|
881
1218
|
timing('daemon.request_applicability',applicabilityStarted,{checks:data.checks.length});
|
|
882
1219
|
timing('daemon.request_total',totalStarted,{action:message.action,
|
|
@@ -900,7 +1237,7 @@ async function shutdown() {
|
|
|
900
1237
|
healthy=true;retainIndex();healthy=false;}
|
|
901
1238
|
} catch(e){event('index_retention_unavailable',{classification:e.name||'Error'});}
|
|
902
1239
|
}
|
|
903
|
-
planningWorker?.terminate().catch(()=>{});
|
|
1240
|
+
closeHistory();planningWorker?.terminate().catch(()=>{});
|
|
904
1241
|
clearInterval(heartbeat);clearInterval(probeTimer);clearTimeout(recoveryTimer);
|
|
905
1242
|
watcher?.unsubscribe().catch(()=>{});server.close();
|
|
906
1243
|
for(const entry of external.values()){
|
|
@@ -918,15 +1255,19 @@ const server=net.createServer(client=>{
|
|
|
918
1255
|
event('control_reply_unavailable',{classification:handled?'closed_without_reply':
|
|
919
1256
|
'closed_before_request',request_bytes:text.length});});
|
|
920
1257
|
client.on('data',d=>{
|
|
921
|
-
|
|
1258
|
+
// A sync request carries each check's context digests, about 1.5 KB per
|
|
1259
|
+
// check; 1 MiB leaves room for hundreds of checks.
|
|
1260
|
+
if(handled)return;text+=d;if(text.length>1024*1024){event('control_request_rejected',
|
|
922
1261
|
{classification:'request_too_large',request_bytes:text.length});client.destroy();return;}
|
|
923
1262
|
if(!text.includes('\n'))return;handled=true;
|
|
924
1263
|
try{const message=JSON.parse(text);
|
|
925
1264
|
const decision=message.action==='sync'||message.action==='snapshot';
|
|
926
1265
|
if(decision)decisionRequests++;
|
|
927
|
-
|
|
928
|
-
.
|
|
929
|
-
.
|
|
1266
|
+
traceScope({request:message.diagnostic?.request||traceId(),work:'request'},()=>{
|
|
1267
|
+
mark('server.request_received',{activeDecisions:decisionRequests});
|
|
1268
|
+
Promise.resolve(request(message)).then(v=>respond(client,v))
|
|
1269
|
+
.catch(e=>respond(client,{error:e.message}))
|
|
1270
|
+
.finally(()=>{mark('server.request_complete');if(decision)decisionRequests--;});});}
|
|
930
1271
|
catch(e){respond(client,{error:e.message});}
|
|
931
1272
|
});
|
|
932
1273
|
});
|
|
@@ -986,15 +1327,23 @@ const heartbeat=setInterval(()=>{
|
|
|
986
1327
|
}
|
|
987
1328
|
publish();
|
|
988
1329
|
},1000);
|
|
989
|
-
|
|
1330
|
+
// Idle revalidation keeps probe results fresh for cached status without
|
|
1331
|
+
// launching a process: certificates are replayed in-process, and one that no
|
|
1332
|
+
// longer holds expires so the next decision runs the full probe. macOS history
|
|
1333
|
+
// replay would fork, so cached status there relies on live notifications.
|
|
1334
|
+
const probeTimer=setInterval(()=>traceScope({request:traceId(),work:'periodic'},async()=>{
|
|
1335
|
+
mark('server.periodic_start',{pending:pending.size,planDirty,planning,decisionRequests});
|
|
990
1336
|
// Windows cached reads are conservatively UNVERIFIED until a full decision
|
|
991
|
-
// reconciliation
|
|
992
|
-
// but can block control requests behind several seconds of child work.
|
|
1337
|
+
// reconciliation, so idle revalidation cannot strengthen them.
|
|
993
1338
|
if(process.platform==='win32')return;
|
|
994
|
-
if(healthy&&index&&!planning&&!decisionRequests){
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
}
|
|
1339
|
+
if(healthy&&index&&!planning&&!recovering&&!decisionRequests){
|
|
1340
|
+
try{if(!hasHistory)await catchUp();}catch(e){observationGap('periodic_query',e);}
|
|
1341
|
+
if(healthy&&index&&!planning&&!recovering)
|
|
1342
|
+
for(const row of index.checks.values())probe(row,true,{launch:false});
|
|
1343
|
+
if(planDirty)flush();else publish();
|
|
1344
|
+
}
|
|
1345
|
+
mark('server.periodic_complete');
|
|
1346
|
+
}),5000);
|
|
998
1347
|
process.on('uncaughtExceptionMonitor',error=>{
|
|
999
1348
|
try{event('observer_uncaught_exception',{pid:process.pid,code:error.code||null,
|
|
1000
1349
|
message:error.message,stack:error.stack});}catch{}
|