@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/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
- function macProbeBarrier() {
115
- return process.platform==='darwin'&&hasHistory&&!historyDisabled&&healthy&&
116
- watcher&&!planning&&!recovering&&!planDirty&&!index?.unavailable&&
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
- function probe(row,allowReuse=false) {
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 macDirect=process.platform==='darwin'&&row.plan.qualification==='npm-typescript-direct-v1';
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
- if(cacheable&&allowReuse&&macGuarded&&!forceFull&&cache?.input===key){
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),macDirect?'--redue-validate-queries':'--redue-context'],{
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
- let stillValid=!macDirect;
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
- guardReason='context_or_compiler_queries_changed';
189
+ fullReason='context_or_compiler_queries_changed';
157
190
  timing('daemon.probe_context_validation',started,{outcome:'changed_or_unavailable'});
158
191
  }
159
- if(macDirect&&cache?.guard&&guard!==cache.guard)planDirty=true;
160
- const fullReason=forceFull?'forced_full_reference':guardReason||
161
- (macDirect&&!macGuarded?'mac_validation_basis_unavailable':!cacheable?'generic_probe':
162
- !row.probeCache?'no_validated_result':row.probeCache.input!==key?'inputs_changed':'context_changed');
163
- timing('daemon.probe_reason',started,{reason:fullReason});
164
- metrics.fullProbeReasons??={};metrics.fullProbeReasons[fullReason]=(metrics.fullProbeReasons[fullReason]||0)+1;
165
- metrics.fullProbes=(metrics.fullProbes||0)+1;
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
- if(macDirect)row.probeCache=null;
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,...(macDirect?{schema:1,guard,
242
- output:checkpoint.output,queryData:checkpoint.queryData,stderrHash:values[0][3]}: {})}:null;
282
+ if(checkpoint)row.probeCache=checkpoint.queries&&(!inProcess||basis)?{input:key,context:checkpoint.context,
283
+ hash:value,queries:checkpoint.queries,...(inProcess?{schema:1,...basis,
284
+ identities:new Map(),
285
+ output:checkpoint.output,queryData:checkpoint.queryData,layout:checkpoint.layout,
286
+ stderrHash:values[0][3]}: {})}:null;
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||!row.probeAt||Date.now()-row.probeAt>10000){item.reason=row.probeError||'state probe observation expired';return item;}
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){item.reason='inputs changed during execution or observation gap';return item;}
358
+ if(!r.stable){const issues=r.checkpoint?.issues?.length?` (${r.checkpoint.issues.join('; ')})`:'';
359
+ item.reason=`inputs changed during execution or observation gap${issues}`;return item;}
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]?.[key]!==r.environmentHashes?.[key]))
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
- for(const row of index.checks.values())probe(row,built.planReused);
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
- inputEventSerial.set(name,(inputEventSerial.get(name)||0)+1);
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
- healthy=false;reason=`observation unavailable: ${classification}; deterministic reconciliation pending`;
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
- const started=performance.now();await refreshPlan({forceCold:true,reuse:true});
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
- if(healthy)try{const started=performance.now();await catchUp();
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 macReuse=macProbeBarrier();
911
- for(const row of index.checks.values())probe(row,macReuse);
912
- if(macReuse&&process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_MAC_PROBE_SIGNAL){
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
- // Child validation blocks JS event delivery. Finish with another continuity
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(macReuse)try{await catchUp();if(planDirty||pending.size||flushTimer)flush();}
919
- catch(e){observationGap('post_probe_history',e);}
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 must independently validate it in its own environment. The
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&&macProbeBarrier()){
1083
+ if(message.runCertificate===1&&continuityBarrier()){
938
1084
  const row=index?.checks.get(message.name),cache=row?.probeCache;
939
- if(row?.plan.qualification==='npm-typescript-direct-v1'&&
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:1,planId:row.plan.id,
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. Periodic synchronous probes cannot strengthen that cache,
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
- try{await catchUp();
1067
- if(healthy&&!planning&&!recovering){const macReuse=macProbeBarrier();
1068
- for(const row of index.checks.values())probe(row,macReuse);
1069
- if(macReuse){await catchUp();if(planDirty||pending.size||flushTimer)flush();}
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
+ }