@redue/cli 0.1.0-alpha.10 → 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 +53 -0
- package/README.md +1 -1
- package/bin/redue.mjs +35 -19
- package/docs/user/agents.md +47 -1
- package/docs/user/direct-typescript.md +18 -0
- package/docs/user/pilot.md +9 -6
- package/package.json +1 -1
- package/src/agent-integration.mjs +59 -13
- package/src/claude-hook.mjs +115 -0
- package/src/cli.mjs +51 -11
- package/src/daemon.mjs +236 -82
- package/src/decision-validation.mjs +10 -0
- package/src/direct-run-certificate.mjs +161 -16
- package/src/direct-typescript-probe.mjs +6 -40
- package/src/direct-typescript.mjs +48 -15
- package/src/history-worker.mjs +19 -16
- package/src/observation.mjs +32 -12
- package/src/probe-result.mjs +7 -3
- package/src/run-recorder.cjs +78 -0
- package/src/typescript-input-facts.mjs +5 -2
- package/src/typescript-list.mjs +76 -7
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';
|
|
@@ -15,8 +15,11 @@ import {InputIndex} 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,planGuard} from './decision-validation.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';
|
|
20
23
|
import {timing,mark,traceBase,traceContext,traceScope,traceEnvironment,traceId} from './decision-profile.mjs';
|
|
21
24
|
|
|
22
25
|
const configFile=path.resolve(process.argv[2]);
|
|
@@ -111,12 +114,45 @@ function persistedPlan(bundle) {
|
|
|
111
114
|
return [name,safe];
|
|
112
115
|
}))};
|
|
113
116
|
}
|
|
114
|
-
|
|
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&&
|
|
117
123
|
!pending.size&&!pendingChecks.size&&!external.size&&verifyRoot();
|
|
118
124
|
}
|
|
119
|
-
|
|
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}={}) {
|
|
120
156
|
const started=performance.now();
|
|
121
157
|
const supported=new Map([
|
|
122
158
|
['typescript-noemit-v1','typescript-contract-probe.mjs'],
|
|
@@ -124,47 +160,49 @@ function probe(row,allowReuse=false) {
|
|
|
124
160
|
['yarn-workspace-tsc-v1','yarn-workspace-typecheck-probe.mjs'],
|
|
125
161
|
['pnpm-tsc-v1','pnpm-typecheck-probe.mjs']]);
|
|
126
162
|
const expected=supported.get(row.plan.qualification);
|
|
127
|
-
const
|
|
128
|
-
const cacheable=(process.platform==='win32'||macDirect)&&row.plan.probes.length===1&&expected&&
|
|
163
|
+
const builtIn=row.plan.probes.length===1&&expected&&
|
|
129
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);
|
|
130
167
|
const key=inputKey(row);
|
|
131
|
-
let guard=null,guardReason=null;
|
|
132
|
-
if(macDirect&&cacheable)try{guard=planGuard(index.bundle);}catch{guardReason='plan_guard_unavailable';}
|
|
133
|
-
const cache=row.probeCache;
|
|
134
|
-
const macGuarded=!macDirect||(macProbeBarrier()&&!row.plan.unresolved.length&&
|
|
135
|
-
guard!==null&&cache?.schema===1&&cache.guard===guard&&
|
|
136
|
-
Array.isArray(cache.queries)&&cache.queries.length>0);
|
|
137
168
|
const forceFull=process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_FULL_PROBES==='1';
|
|
138
|
-
|
|
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){
|
|
139
177
|
const argv=row.plan.probes[0];
|
|
140
178
|
mark('daemon.probe_launch',{mode:'certificate',deadlineMs:10000});
|
|
141
|
-
const out=spawnSync(argv[0],[...argv.slice(1),
|
|
142
|
-
cwd:row.plan.cwd,timeout:10000,maxBuffer:1024*1024,env:traceEnvironment(),
|
|
143
|
-
...(macDirect?{input:JSON.stringify({schema:cache.schema,context:cache.context,queries:cache.queries})}:
|
|
144
|
-
{stdio:['ignore','pipe','pipe']})});
|
|
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']});
|
|
145
181
|
mark('daemon.probe_complete',{mode:'certificate',childPid:out.pid,exit:out.status,signal:out.signal,code:out.error?.code});
|
|
146
182
|
metrics.probeContextProcesses=(metrics.probeContextProcesses||0)+1;
|
|
147
|
-
|
|
148
|
-
if(macDirect)try{stillValid=macProbeBarrier()&&inputKey(row)===key&&planGuard(index.bundle)===guard;}catch{}
|
|
149
|
-
if(!out.error&&!out.signal&&out.status===0&&
|
|
150
|
-
out.stdout.toString()===cache.context&&stillValid){
|
|
183
|
+
if(!out.error&&!out.signal&&out.status===0&&out.stdout.toString()===row.probeCache.context){
|
|
151
184
|
row.probeHash=row.probeCache.hash;row.probeAt=Date.now();row.probeError=null;
|
|
152
185
|
metrics.probeReuses=(metrics.probeReuses||0)+1;
|
|
153
186
|
timing('daemon.probe_context_validation',started,{outcome:'reused',reason:'content_and_context_unchanged'});
|
|
154
187
|
return;
|
|
155
188
|
}
|
|
156
|
-
|
|
189
|
+
fullReason='context_or_compiler_queries_changed';
|
|
157
190
|
timing('daemon.probe_context_validation',started,{outcome:'changed_or_unavailable'});
|
|
158
191
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
+
}
|
|
166
199
|
// A failed or incomplete full probe must not leave an older certificate usable.
|
|
167
|
-
|
|
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
|
+
}
|
|
168
206
|
const values=[];let checkpoint=null;
|
|
169
207
|
for(const argv of row.plan.probes) {
|
|
170
208
|
// Supported TypeScript probes perform bounded input/toolchain hashing.
|
|
@@ -227,6 +265,10 @@ function probe(row,allowReuse=false) {
|
|
|
227
265
|
try{checkpoint=JSON.parse(out.stdout);
|
|
228
266
|
checkpoint.queries=checkpoint.queryData?JSON.parse(inflateSync(
|
|
229
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;}
|
|
230
272
|
if(!/^[a-f0-9]{64}$/.test(checkpoint.output)||!/^[a-f0-9]{64}$/.test(checkpoint.context))
|
|
231
273
|
throw Error('invalid supported probe checkpoint');}
|
|
232
274
|
catch{row.probeHash=null;row.probeAt=0;row.probeError='probe checkpoint unavailable';return;}
|
|
@@ -237,9 +279,11 @@ function probe(row,allowReuse=false) {
|
|
|
237
279
|
if((row.probeHash!==null&&row.probeHash!==value)||row.probeError){
|
|
238
280
|
row.revision++;row.lastReason='declared state probe changed or recovered';}
|
|
239
281
|
row.probeHash=value;row.probeAt=Date.now();row.probeError=null;
|
|
240
|
-
if(checkpoint)row.probeCache=checkpoint.queries?{input:key,context:checkpoint.context,
|
|
241
|
-
hash:value,queries:checkpoint.queries,...(
|
|
242
|
-
|
|
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;
|
|
243
287
|
timing('daemon.probe',started,{outcome:'ok',commands:values.length,
|
|
244
288
|
reusable:row.probeCache?1:0,queries:row.probeCache?.queries.length||0});
|
|
245
289
|
}
|
|
@@ -295,7 +339,10 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
295
339
|
item.reason='input coverage unresolved: '+row.plan.unresolved.join('; ')+checkpoint+probeIssue;
|
|
296
340
|
return item;
|
|
297
341
|
}
|
|
298
|
-
if(row.probeError
|
|
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;
|
|
299
346
|
if(row.plan.synchronizedInstalledRead&&!synchronizedInstalled){
|
|
300
347
|
item.reason='installed inputs require a synchronized content read';return item;}
|
|
301
348
|
if(process.platform==='win32'&&!synchronizedFilesystem){
|
|
@@ -308,15 +355,15 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
308
355
|
item.reason=`target execution ${r.target?.status||'unavailable'}`;return item;}
|
|
309
356
|
if(r.target.status==='failed'&&result!=='FAIL'){
|
|
310
357
|
item.reason='target failure conflicts with outer invocation outcome';return item;}
|
|
311
|
-
if(!r.stable){
|
|
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;}
|
|
312
360
|
const names=[...(row.plan.environment.variables||[]),
|
|
313
361
|
...(row.plan.environment.prefixes||[]).map(prefix=>
|
|
314
362
|
`__vstate_env_prefix_${prefix.toLowerCase()}__`),
|
|
315
363
|
...(row.plan.environment.pathExecutables||[]).map(name=>
|
|
316
364
|
`__vstate_path_executable_${name}__`),
|
|
317
365
|
...(row.plan.environment.executableIdentity?['__vstate_node_identity__']:[])];
|
|
318
|
-
if(names.length&&!contextHashes?.[name])
|
|
319
|
-
item.reason='execution environment context not supplied';return item;}
|
|
366
|
+
if(names.length&&!contextHashes?.[name])unconfirmed??='execution environment context not supplied';
|
|
320
367
|
item.freshness='STALE';
|
|
321
368
|
if(r.planId!==row.plan.id){
|
|
322
369
|
const before=new Map((r.resolutionLinks||[]).map(link=>[link.path,link.targetHash]));
|
|
@@ -324,7 +371,7 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
324
371
|
item.reason=changed?`installed input link changed: ${changed.path}`:'input plan changed';
|
|
325
372
|
if(changed)item.changed_inputs=[changed.path];
|
|
326
373
|
}
|
|
327
|
-
else if(names.some(key=>contextHashes[name]
|
|
374
|
+
else if(contextHashes?.[name]&&names.some(key=>contextHashes[name][key]!==r.environmentHashes?.[key]))
|
|
328
375
|
item.reason='declared execution environment changed';
|
|
329
376
|
else if(r.fingerprint!==combinedSummary(name).fingerprint) {
|
|
330
377
|
const key=r.fingerprint+'\0'+combinedSummary(name).fingerprint;
|
|
@@ -346,11 +393,63 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
|
|
|
346
393
|
row.staleReasonKey=key;
|
|
347
394
|
}
|
|
348
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;
|
|
349
399
|
} else {item.freshness='CURRENT';item.reason=result==='FAIL'?'last command failed':'declared inputs match';
|
|
350
400
|
item.reuse_eligible=result==='PASS';}
|
|
351
401
|
return item;
|
|
352
402
|
});
|
|
353
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
|
+
}
|
|
354
453
|
function status(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=false) {
|
|
355
454
|
// Reload notifications accelerate visibility but are not required for it: a
|
|
356
455
|
// wrapper may persist its outcome even when its control connection is denied.
|
|
@@ -451,7 +550,7 @@ function buildInWorker(forceCold=false,reuse=null) {
|
|
|
451
550
|
reject(Error(`input-plan worker exited ${code}`));}});
|
|
452
551
|
});
|
|
453
552
|
}
|
|
454
|
-
async function refreshPlan({forceCold=false,reuse=false}={}) {
|
|
553
|
+
async function refreshPlan({forceCold=false,reuse=false,skip=null}={}) {
|
|
455
554
|
if(planning||stopping)return;
|
|
456
555
|
const totalStarted=performance.now();
|
|
457
556
|
const previousProbes=new Map([...(index?.checks||[])].map(([name,row])=>[name,row.probeCache]));
|
|
@@ -534,7 +633,8 @@ async function refreshPlan({forceCold=false,reuse=false}={}) {
|
|
|
534
633
|
atomic(path.join(state,'plans-v1.json'),persistedPlan(bundle));
|
|
535
634
|
metrics.lastPlanPersistenceMs=performance.now()-persistenceStarted;
|
|
536
635
|
const probeStarted=performance.now();
|
|
537
|
-
|
|
636
|
+
if(skip)refreshReceipts();
|
|
637
|
+
for(const [name,row] of index.checks)if(!skip?.(name,row))probe(row,built.planReused);
|
|
538
638
|
timing('daemon.plan_probes',probeStarted,{checks:index.checks.size});
|
|
539
639
|
lastObservation=Date.now();
|
|
540
640
|
event(built.planReused?'plan_validated':'plan_rebuilt',{checks:Object.keys(bundle.plans),workspace_count:bundle.workspaceCount});
|
|
@@ -630,14 +730,18 @@ function pnpmTopologyChanged(type,rel) {
|
|
|
630
730
|
return stat.isDirectory()||stat.isSymbolicLink();}
|
|
631
731
|
catch(e){return e.code==='ENOENT';}
|
|
632
732
|
}
|
|
633
|
-
function markInputEvent(name,rel,type) {
|
|
733
|
+
function markInputEvent(name,rel,type,replay) {
|
|
634
734
|
pendingChecks.add(name);
|
|
635
|
-
|
|
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);
|
|
636
740
|
// Disposable public CI only. Never enabled for normal/local repositories.
|
|
637
741
|
if(process.env.REDUE_PUBLIC_INPUT_TRACE==='1'&&rel&&diagnosticInputEvents++<5000)
|
|
638
|
-
event('public_input_notification',{path:rel,type:type||'unknown',planning});
|
|
742
|
+
event('public_input_notification',{path:rel,type:type||'unknown',planning,replay});
|
|
639
743
|
}
|
|
640
|
-
function notification(type,filename) {
|
|
744
|
+
function notification(type,filename,replay=false) {
|
|
641
745
|
metrics.notifications++;
|
|
642
746
|
metrics.lastNotificationAt=Date.now();
|
|
643
747
|
if(!filename){observationGap('unnamed_event');return;}
|
|
@@ -645,7 +749,7 @@ function notification(type,filename) {
|
|
|
645
749
|
if(rel==='.git'||rel.startsWith('.git/'))return;
|
|
646
750
|
if(planning) {
|
|
647
751
|
if(index)for(const name of index.candidates(rel))
|
|
648
|
-
if(index.matches(index.checks.get(name),rel))markInputEvent(name,rel,type);
|
|
752
|
+
if(index.matches(index.checks.get(name),rel))markInputEvent(name,rel,type,replay);
|
|
649
753
|
pending.add(rel);metrics.maxQueue=Math.max(metrics.maxQueue,pending.size);
|
|
650
754
|
if(planTrigger(rel)||candidateDescendants.has(rel)||pnpmTopologyChanged(type,rel))
|
|
651
755
|
planDirty=true;
|
|
@@ -669,9 +773,9 @@ function notification(type,filename) {
|
|
|
669
773
|
pending.add(rel);metrics.maxQueue=Math.max(metrics.maxQueue,pending.size);
|
|
670
774
|
if(index) {
|
|
671
775
|
for(const name of index.candidates(rel))
|
|
672
|
-
if(index.matches(index.checks.get(name),rel))markInputEvent(name,rel,type);
|
|
776
|
+
if(index.matches(index.checks.get(name),rel))markInputEvent(name,rel,type,replay);
|
|
673
777
|
for(const [prefix,names] of index.prefixes)
|
|
674
|
-
if(prefix.startsWith(rel+'/'))for(const name of names)markInputEvent(name,rel,type);
|
|
778
|
+
if(prefix.startsWith(rel+'/'))for(const name of names)markInputEvent(name,rel,type,replay);
|
|
675
779
|
}
|
|
676
780
|
if(pending.size>50000){healthy=false;reason='observation backlog overflow';metrics.gaps++;
|
|
677
781
|
event('observation_gap',{classification:'backlog_overflow'});publish();
|
|
@@ -680,23 +784,23 @@ function notification(type,filename) {
|
|
|
680
784
|
if(planDirty){healthy=false;reason='input plan possibly changed';}
|
|
681
785
|
if(planDirty||pendingChecks.size)publish();
|
|
682
786
|
}
|
|
683
|
-
function externalNotification(entry,type,filename) {
|
|
787
|
+
function externalNotification(entry,type,filename,replay=false) {
|
|
684
788
|
metrics.notifications++;
|
|
685
789
|
metrics.lastNotificationAt=Date.now();
|
|
686
790
|
if(!filename){externalGap(entry,'unnamed_event');return;}
|
|
687
791
|
const rel=filename.toString().split(path.sep).join('/');
|
|
688
792
|
const absolute=path.join(entry.root,rel);
|
|
689
793
|
if(planning&&entry.index)for(const name of entry.index.candidates(rel))
|
|
690
|
-
if(entry.index.matches(entry.index.checks.get(name),rel))markInputEvent(name,rel,type);
|
|
794
|
+
if(entry.index.matches(entry.index.checks.get(name),rel))markInputEvent(name,rel,type,replay);
|
|
691
795
|
if(planTrigger(absolute)&&triggerChanged(absolute))planDirty=true;
|
|
692
796
|
if(candidateTriggerChanged(absolute))planDirty=true;
|
|
693
797
|
entry.pending.add(rel);metrics.maxQueue=Math.max(metrics.maxQueue,
|
|
694
798
|
pending.size+[...external.values()].reduce((n,e)=>n+e.pending.size,0));
|
|
695
799
|
if(entry.index) {
|
|
696
800
|
for(const name of entry.index.candidates(rel))
|
|
697
|
-
if(entry.index.matches(entry.index.checks.get(name),rel))markInputEvent(name,rel,type);
|
|
801
|
+
if(entry.index.matches(entry.index.checks.get(name),rel))markInputEvent(name,rel,type,replay);
|
|
698
802
|
for(const [prefix,names] of entry.index.prefixes)
|
|
699
|
-
if(prefix.startsWith(rel+'/'))for(const name of names)markInputEvent(name,rel,type);
|
|
803
|
+
if(prefix.startsWith(rel+'/'))for(const name of names)markInputEvent(name,rel,type,replay);
|
|
700
804
|
}
|
|
701
805
|
if(planDirty){healthy=false;reason='input plan possibly changed';}
|
|
702
806
|
publish();clearTimeout(flushTimer);flushTimer=setTimeout(flush,60);
|
|
@@ -714,7 +818,8 @@ function observationGap(classification,error) {
|
|
|
714
818
|
// Once continuity is uncertain, a cursor is no longer authority. Do not
|
|
715
819
|
// retry native history in this observer generation after reconciliation.
|
|
716
820
|
historyDisabled=true;
|
|
717
|
-
|
|
821
|
+
const cause=error?.message?` (${error.message})`:'';
|
|
822
|
+
healthy=false;reason=`observation unavailable: ${classification}${cause}; deterministic reconciliation pending`;
|
|
718
823
|
metrics.gaps++;
|
|
719
824
|
event('observation_gap',{classification,error_code:error?.code||null});publish();
|
|
720
825
|
// One bounded attempt per gap; a later independent gap may try again.
|
|
@@ -807,7 +912,7 @@ async function catchUp() {
|
|
|
807
912
|
if(!fs.existsSync(snapshotPath))throw Error('historical cursor missing');
|
|
808
913
|
{
|
|
809
914
|
const changes=await queryHistory(root,snapshotPath,'root replay');
|
|
810
|
-
for(const change of changes)notification(change.type,path.relative(root,change.path));
|
|
915
|
+
for(const change of changes)notification(change.type,path.relative(root,change.path),true);
|
|
811
916
|
if(!planning&&(flushTimer||pending.size||planDirty))flush();
|
|
812
917
|
for(const entry of external.values())if(entry.index) {
|
|
813
918
|
try{
|
|
@@ -817,7 +922,7 @@ async function catchUp() {
|
|
|
817
922
|
if(!entry.watcher||!fs.existsSync(entry.snapshot))throw Error('external cursor unavailable');
|
|
818
923
|
const changes=await queryHistory(entry.root,entry.snapshot,'external replay');
|
|
819
924
|
for(const change of changes)externalNotification(entry,change.type,
|
|
820
|
-
path.relative(entry.root,change.path));
|
|
925
|
+
path.relative(entry.root,change.path),true);
|
|
821
926
|
}catch(e){if(e.code==='ETIMEDOUT')throw e;
|
|
822
927
|
externalGap(entry,'historical_replay_failed',e);}
|
|
823
928
|
}
|
|
@@ -869,9 +974,17 @@ async function request(message) {
|
|
|
869
974
|
// has been disabled: a recovered subscription may still have queued events.
|
|
870
975
|
const needsContentReconciliation=process.platform==='win32'||(hasHistory&&historyDisabled);
|
|
871
976
|
if(needsContentReconciliation&&message.action!=='prelaunch'){
|
|
872
|
-
|
|
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});
|
|
873
980
|
timing('daemon.request_content_validation',started,{healthy:healthy?1:0});}
|
|
874
|
-
|
|
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();
|
|
875
988
|
timing('daemon.request_catch_up',started);}catch(e){observationGap('historical_query',e);}
|
|
876
989
|
if(flushTimer||pending.size||planDirty||[...external.values()].some(e=>e.pending.size))flush();
|
|
877
990
|
let synchronizedInstalled=false;
|
|
@@ -906,17 +1019,49 @@ async function request(message) {
|
|
|
906
1019
|
}catch(e){installedRecheck=null;observationGap('installed_recheck',e);}
|
|
907
1020
|
}
|
|
908
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();
|
|
909
1025
|
if(index&&healthy&&!validatedColdIndex&&message.action!=='prelaunch'){
|
|
910
|
-
const
|
|
911
|
-
|
|
912
|
-
if(
|
|
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){
|
|
913
1040
|
fs.writeFileSync(process.env.VSTATE_TEST_MAC_PROBE_SIGNAL,'validated');
|
|
914
1041
|
await new Promise(resolve=>setTimeout(resolve,200));
|
|
915
1042
|
}
|
|
916
|
-
//
|
|
1043
|
+
// Validation and probes block JS event delivery. Finish with a continuity
|
|
917
1044
|
// barrier so queued edits/gaps cannot be published using the pre-probe index.
|
|
918
|
-
if(
|
|
919
|
-
|
|
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);}
|
|
920
1065
|
}
|
|
921
1066
|
timing('daemon.request_probes',probeStarted,{checks:index?.checks.size||0});
|
|
922
1067
|
lastObservation=Date.now();publish();
|
|
@@ -929,22 +1074,31 @@ async function request(message) {
|
|
|
929
1074
|
data.snapshots[name]={...combinedSummary(name),files:combinedFiles(name),
|
|
930
1075
|
planGeneration:row.plan.id,
|
|
931
1076
|
inputEventSerial:inputEventSerial.get(name)||0,
|
|
932
|
-
probeHash:row.probeHash,
|
|
1077
|
+
probeHash:row.probeHash,inputKey:inputKey(row),observationGaps:metrics.gaps,
|
|
933
1078
|
observationHealthy:healthy&&!planning&&!recovering&&applicableExternal(name).every(e=>e.healthy)};
|
|
934
1079
|
// Export only a currently guarded, check-specific discovery certificate.
|
|
935
|
-
// The caller
|
|
1080
|
+
// The caller compares it with the answers its own compile received. Schema 2
|
|
1081
|
+
// certifies the realpath of every directory readDirectory visits. The
|
|
936
1082
|
// snapshot stays the start of the observed interval, including validation.
|
|
937
|
-
if(message.runCertificate===1&&
|
|
1083
|
+
if(message.runCertificate===1&&continuityBarrier()){
|
|
938
1084
|
const row=index?.checks.get(message.name),cache=row?.probeCache;
|
|
939
|
-
|
|
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'&&
|
|
940
1090
|
!row.plan.unresolved.length&&cache?.schema===1&&cache.input===inputKey(row)&&
|
|
941
1091
|
cache.hash===row.probeHash&&cache.guard===planGuard(index.bundle)&&
|
|
942
1092
|
cache.output&&cache.queryData&&data.snapshots[message.name])
|
|
943
|
-
data.snapshots[message.name].discoveryCertificate={schema:
|
|
1093
|
+
data.snapshots[message.name].discoveryCertificate={schema:2,planId:row.plan.id,
|
|
944
1094
|
cwd:row.plan.cwd,probeDefinition:sha(JSON.stringify(row.plan.probes)),
|
|
945
1095
|
context:cache.context,output:cache.output,stderrHash:cache.stderrHash,
|
|
946
1096
|
queryData:cache.queryData};
|
|
947
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;
|
|
948
1102
|
}
|
|
949
1103
|
timing('daemon.request_applicability',applicabilityStarted,{checks:data.checks.length});
|
|
950
1104
|
timing('daemon.request_total',totalStarted,{action:message.action,
|
|
@@ -968,7 +1122,7 @@ async function shutdown() {
|
|
|
968
1122
|
healthy=true;retainIndex();healthy=false;}
|
|
969
1123
|
} catch(e){event('index_retention_unavailable',{classification:e.name||'Error'});}
|
|
970
1124
|
}
|
|
971
|
-
planningWorker?.terminate().catch(()=>{});
|
|
1125
|
+
closeHistory();planningWorker?.terminate().catch(()=>{});
|
|
972
1126
|
clearInterval(heartbeat);clearInterval(probeTimer);clearTimeout(recoveryTimer);
|
|
973
1127
|
watcher?.unsubscribe().catch(()=>{});server.close();
|
|
974
1128
|
for(const entry of external.values()){
|
|
@@ -1056,21 +1210,21 @@ const heartbeat=setInterval(()=>{
|
|
|
1056
1210
|
}
|
|
1057
1211
|
publish();
|
|
1058
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.
|
|
1059
1217
|
const probeTimer=setInterval(()=>traceScope({request:traceId(),work:'periodic'},async()=>{
|
|
1060
1218
|
mark('server.periodic_start',{pending:pending.size,planDirty,planning,decisionRequests});
|
|
1061
1219
|
// Windows cached reads are conservatively UNVERIFIED until a full decision
|
|
1062
|
-
// reconciliation
|
|
1063
|
-
// but can block control requests behind several seconds of child work.
|
|
1220
|
+
// reconciliation, so idle revalidation cannot strengthen them.
|
|
1064
1221
|
if(process.platform==='win32')return;
|
|
1065
|
-
if(healthy&&index&&!planning&&!decisionRequests){
|
|
1066
|
-
|
|
1067
|
-
if(healthy&&!planning&&!recovering)
|
|
1068
|
-
for(const row of index.checks.values())probe(row,
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
publish();}
|
|
1072
|
-
catch(e){observationGap('periodic_query',e);}
|
|
1073
|
-
}
|
|
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
|
+
}
|
|
1074
1228
|
mark('server.periodic_complete');
|
|
1075
1229
|
}),5000);
|
|
1076
1230
|
process.on('uncaughtExceptionMonitor',error=>{
|
|
@@ -39,3 +39,13 @@ export function planGuard(bundle) {
|
|
|
39
39
|
const rootStat=fs.statSync(root,{bigint:true});
|
|
40
40
|
return sha(JSON.stringify([String(rootStat.dev),String(rootStat.ino),values]));
|
|
41
41
|
}
|
|
42
|
+
// Identity of the reviewed compiler tree and the probe's Node executable. A full
|
|
43
|
+
// probe verifies their bytes after this is taken, so any later write changes it.
|
|
44
|
+
export function toolchainKey(tsRoot,executable) {
|
|
45
|
+
const values=[];
|
|
46
|
+
const visit=file=>{const st=fs.lstatSync(file,{bigint:true});
|
|
47
|
+
values.push([file,...['dev','ino','mode','nlink','size','mtimeNs','ctimeNs'].map(key=>String(st[key]))]);
|
|
48
|
+
if(st.isDirectory())for(const name of fs.readdirSync(file).sort())visit(path.join(file,name));};
|
|
49
|
+
visit(tsRoot);visit(fs.realpathSync(executable));
|
|
50
|
+
return sha(JSON.stringify(values));
|
|
51
|
+
}
|