dsh-ptc-plus 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,22 +2,34 @@ import { decodeValue, encodeValue, normalizeValueWire } from './value-wire.js'
2
2
  import { normalizeDiagnostic } from './diagnostic.js'
3
3
  import { assertOwnFields, isRecord } from './record-utils.js'
4
4
  import { sessionEvents } from './session-events.js'
5
+ import {
6
+ LEGACY_DEFAULT_EXPORT_BINDING,
7
+ LIVE_DEFAULT_EXPORT_BINDING,
8
+ } from './repl-rewrite-contract.js'
5
9
 
6
10
  export const JOURNAL_KEY = 'dshPtcPlus'
7
11
  export const EDIT_TARGET_KEY = 'dshPtcPlusEdit'
8
12
  export const DERIVED_RUN_KEY = 'dshPtcPlusDerivedRun'
9
13
  export const REWRITES_KEY = 'dshPtcPlusRewrites'
10
14
  export const RECOVERY_BOUNDARY_KEY = 'dshPtcPlusRecoveryBoundaries'
11
- export const JOURNAL_VERSION = 3
15
+ export const JOURNAL_VERSION = 4
12
16
  const LEGACY_JOURNAL_VERSION = 1
13
17
  const INTERMEDIATE_JOURNAL_VERSION = 2
18
+ const PREVIOUS_JOURNAL_VERSION = 3
14
19
  export const RECOVERY_BOUNDARY_EVENT = 'ptc-plus/recovery-boundary'
15
20
 
16
21
  const STATUSES = new Set(['durable', 'volatile', 'discarded', 'noop'])
17
22
  const BINDING_MODES = new Set(['loose', 'strict'])
18
- const JOURNAL_FIELDS = new Set(['version', 'bindingMode', 'rewritePolicy', 'status', 'calls', 'operations', 'confirms', 'diagnostics', 'completion', 'volatileReason'])
19
- const LEGACY_JOURNAL_FIELDS = new Set([...JOURNAL_FIELDS].filter(field => field !== 'rewritePolicy'))
23
+ const JOURNAL_FIELDS = new Set(['version', 'bindingPolicy', 'rewritePolicy', 'moduleSemantics', 'status', 'calls', 'operations', 'confirms', 'diagnostics', 'completion', 'volatileReason'])
24
+ const PREDECESSOR_JOURNAL_FIELDS = new Set(['version', 'bindingMode', 'rewritePolicy', 'status', 'calls', 'operations', 'confirms', 'diagnostics', 'completion', 'volatileReason'])
25
+ const LEGACY_JOURNAL_FIELDS = new Set([...PREDECESSOR_JOURNAL_FIELDS].filter(field => field !== 'rewritePolicy'))
26
+ const BINDING_POLICY_FIELDS = new Set(['variableRedeclarations', 'functionClassRedeclarations'])
20
27
  const REWRITE_POLICY_FIELDS = new Set(['autoRewriteImports', 'autoStripExports', 'autoSplitRedeclarations'])
28
+ const MODULE_SEMANTICS_FIELDS = new Set(['defaultExportBinding'])
29
+ const DEFAULT_EXPORT_BINDINGS = new Set([
30
+ LEGACY_DEFAULT_EXPORT_BINDING,
31
+ LIVE_DEFAULT_EXPORT_BINDING,
32
+ ])
21
33
  const CALL_SUCCESS_FIELDS = new Set(['global', 'member', 'args', 'ok', 'value', 'settle'])
22
34
  const CALL_ERROR_FIELDS = new Set(['global', 'member', 'args', 'ok', 'error', 'settle'])
23
35
  const OPERATION_FIELDS = new Set(['action', 'name'])
@@ -167,28 +179,64 @@ function normalizeRewritePolicy(value) {
167
179
  })
168
180
  }
169
181
 
182
+ function normalizeBindingPolicy(value) {
183
+ if (!isRecord(value)) throw new Error('invalid dsh-ptc-plus journal binding policy')
184
+ assertOwnFields(value, BINDING_POLICY_FIELDS, 'journal binding policy')
185
+ for (const key of BINDING_POLICY_FIELDS) {
186
+ if (typeof value[key] !== 'boolean') throw new Error(`invalid dsh-ptc-plus journal binding policy ${key}`)
187
+ }
188
+ return Object.freeze({
189
+ variableRedeclarations: value.variableRedeclarations,
190
+ functionClassRedeclarations: value.functionClassRedeclarations,
191
+ })
192
+ }
193
+
194
+ function normalizeModuleSemantics(value) {
195
+ if (!isRecord(value)) throw new Error('invalid dsh-ptc-plus journal module semantics')
196
+ assertOwnFields(value, MODULE_SEMANTICS_FIELDS, 'journal module semantics')
197
+ if (!DEFAULT_EXPORT_BINDINGS.has(value.defaultExportBinding)) {
198
+ throw new Error('invalid dsh-ptc-plus journal default export binding semantics')
199
+ }
200
+ return Object.freeze({ defaultExportBinding: value.defaultExportBinding })
201
+ }
202
+
170
203
  function migrateJournal(value, resolveLegacyConfirm) {
171
- if (value.version !== LEGACY_JOURNAL_VERSION) return value
172
- assertOwnFields(value, LEGACY_JOURNAL_FIELDS, 'dsh-ptc-plus journal')
204
+ if (value.version === JOURNAL_VERSION) return value
205
+ const legacy = value.version === LEGACY_JOURNAL_VERSION
206
+ assertOwnFields(
207
+ value,
208
+ legacy ? LEGACY_JOURNAL_FIELDS : PREDECESSOR_JOURNAL_FIELDS,
209
+ 'dsh-ptc-plus journal',
210
+ )
211
+ if (!BINDING_MODES.has(value.bindingMode)) throw new Error('invalid dsh-ptc-plus journal binding mode')
212
+ const { bindingMode, ...rest } = value
173
213
  return {
174
- ...value,
214
+ ...rest,
175
215
  version: JOURNAL_VERSION,
176
- rewritePolicy: LEGACY_REWRITE_POLICY,
177
- confirms: normalizeLegacyConfirms(value.confirms, resolveLegacyConfirm),
216
+ bindingPolicy: {
217
+ variableRedeclarations: bindingMode === 'loose',
218
+ functionClassRedeclarations: false,
219
+ },
220
+ rewritePolicy: legacy ? LEGACY_REWRITE_POLICY : value.rewritePolicy,
221
+ moduleSemantics: { defaultExportBinding: LEGACY_DEFAULT_EXPORT_BINDING },
222
+ confirms: legacy
223
+ ? normalizeLegacyConfirms(value.confirms, resolveLegacyConfirm)
224
+ : value.confirms,
178
225
  }
179
226
  }
180
227
 
181
228
  /** Validate and detach one journal emitted by the runtime. */
182
229
  export function normalizeJournal(value, options = {}) {
183
230
  if (!isRecord(value)) throw new Error('invalid dsh-ptc-plus journal')
184
- if (![LEGACY_JOURNAL_VERSION, INTERMEDIATE_JOURNAL_VERSION, JOURNAL_VERSION].includes(value.version)
231
+ if (![LEGACY_JOURNAL_VERSION, INTERMEDIATE_JOURNAL_VERSION, PREVIOUS_JOURNAL_VERSION, JOURNAL_VERSION].includes(value.version)
185
232
  || !STATUSES.has(value.status)) {
186
233
  throw new Error('invalid dsh-ptc-plus journal')
187
234
  }
188
235
  const migrated = migrateJournal(value, options.resolveLegacyConfirm)
189
236
  assertOwnFields(migrated, JOURNAL_FIELDS, 'dsh-ptc-plus journal')
190
- if (!BINDING_MODES.has(migrated.bindingMode)) throw new Error('invalid dsh-ptc-plus journal binding mode')
237
+ const bindingPolicy = normalizeBindingPolicy(migrated.bindingPolicy)
191
238
  const rewritePolicy = normalizeRewritePolicy(migrated.rewritePolicy)
239
+ const moduleSemantics = normalizeModuleSemantics(migrated.moduleSemantics)
192
240
  const calls = normalizeCalls(migrated.calls)
193
241
  const operations = normalizeOperations(migrated.operations)
194
242
  const confirms = normalizeConfirms(migrated.confirms)
@@ -209,8 +257,9 @@ export function normalizeJournal(value, options = {}) {
209
257
  }
210
258
  return Object.freeze({
211
259
  version: JOURNAL_VERSION,
212
- bindingMode: migrated.bindingMode,
260
+ bindingPolicy,
213
261
  rewritePolicy,
262
+ moduleSemantics,
214
263
  status: migrated.status,
215
264
  calls: Object.freeze(calls),
216
265
  operations: Object.freeze(operations),
@@ -394,12 +443,21 @@ export function liveToolCallSeq(session, callId, toolName) {
394
443
  }
395
444
 
396
445
  /** Start a mutable journal for one live cell. */
397
- export function createJournal(confirms = [], bindingMode, rewritePolicy) {
398
- if (!BINDING_MODES.has(bindingMode)) throw new TypeError('invalid dsh-ptc-plus journal binding mode')
446
+ export function createJournal(confirms = [], bindingPolicy, rewritePolicy) {
447
+ if (typeof bindingPolicy === 'string') {
448
+ if (!BINDING_MODES.has(bindingPolicy)) throw new TypeError('invalid dsh-ptc-plus journal binding mode')
449
+ bindingPolicy = {
450
+ variableRedeclarations: bindingPolicy === 'loose',
451
+ functionClassRedeclarations: false,
452
+ }
453
+ }
399
454
  return {
400
455
  version: JOURNAL_VERSION,
401
- bindingMode,
456
+ bindingPolicy: normalizeBindingPolicy(bindingPolicy),
402
457
  rewritePolicy: normalizeRewritePolicy(rewritePolicy),
458
+ moduleSemantics: normalizeModuleSemantics({
459
+ defaultExportBinding: LIVE_DEFAULT_EXPORT_BINDING,
460
+ }),
403
461
  calls: [],
404
462
  operations: [],
405
463
  confirms: [...confirms],
@@ -456,11 +514,6 @@ function applyOperations(state, operations, nodeIndex) {
456
514
  function applyRecord(state, record, invalidCallSeqs) {
457
515
  const { call, code, result } = record
458
516
  if (invalidCallSeqs.has(call.seq)) return
459
- if (code === undefined || result?.journal === undefined) {
460
- state.trusted = false
461
- state.volatileSuffix.push({ seq: call.seq, code, reason: result?.error ?? 'missing dsh-ptc-plus journal result' })
462
- return
463
- }
464
517
  const journal = result.journal
465
518
  if (journal.status === 'noop') return
466
519
  if (journal.status === 'discarded') {
@@ -476,7 +529,7 @@ function applyRecord(state, record, invalidCallSeqs) {
476
529
  applyOperations(state, journal.operations, undefined)
477
530
  return
478
531
  }
479
- if (!state.trusted) {
532
+ if (!state.trusted && !state.surfaceContracted) {
480
533
  state.trusted = true
481
534
  state.volatileSuffix.length = 0
482
535
  }
@@ -488,38 +541,31 @@ function applyRecord(state, record, invalidCallSeqs) {
488
541
  })
489
542
  const index = state.nodes.push(node) - 1
490
543
  state.head = index
491
- applyOperations(state, journal.operations, index)
492
- }
493
-
494
- function forceRecoveryHead(state, boundary) {
495
- state.head = boundary.frontierCallSeq === null
496
- ? undefined
497
- : state.nodes.findIndex(node => node.callSeq === boundary.frontierCallSeq)
498
- if (state.head === -1) throw new Error('recovery boundary frontier is not reconstructable')
499
- state.trusted = true
500
- state.volatileSuffix = []
544
+ try {
545
+ applyOperations(state, journal.operations, index)
546
+ } catch (error) {
547
+ state.nodes.pop()
548
+ state.head = node.parent
549
+ throw error
550
+ }
501
551
  }
502
552
 
503
553
  function recordEventSeq(record) {
504
554
  return record.result?.eventSeq ?? record.call.seq
505
555
  }
506
556
 
507
- function foldRecords(records, invalidCallSeqs, boundaries = []) {
557
+ function foldRecords(records, invalidCallSeqs, options = {}) {
508
558
  const state = {
509
559
  nodes: [],
510
560
  head: undefined,
511
561
  checkpoints: new Map(),
512
562
  volatileSuffix: [],
513
563
  trusted: true,
564
+ surfaceContracted: options.surfaceContracted === true,
514
565
  }
515
- let boundaryIndex = 0
516
566
  for (const record of records) {
517
- while (boundaryIndex < boundaries.length && boundaries[boundaryIndex].eventSeq < recordEventSeq(record)) {
518
- forceRecoveryHead(state, boundaries[boundaryIndex++])
519
- }
520
567
  applyRecord(state, record, invalidCallSeqs)
521
568
  }
522
- while (boundaryIndex < boundaries.length) forceRecoveryHead(state, boundaries[boundaryIndex++])
523
569
  return state
524
570
  }
525
571
 
@@ -530,6 +576,77 @@ function dependsOn(nodes, index, ancestor) {
530
576
  return false
531
577
  }
532
578
 
579
+ /** Return executable call sequences whose provenance remains model-visible. */
580
+ export function visibleExecutableCallSeqs(session) {
581
+ const events = sessionEvents(session)
582
+ let nodes
583
+ try {
584
+ nodes = session?.surface?.nodes
585
+ } catch {
586
+ return undefined
587
+ }
588
+ if (!Array.isArray(events) || !Array.isArray(nodes)) return undefined
589
+ const eventBySeq = new Map()
590
+ for (const event of events) eventBySeq.set(event?.seq, event)
591
+ for (const seq of nodes) {
592
+ if (!Number.isSafeInteger(seq) || seq < 0 || eventBySeq.get(seq) === undefined) return undefined
593
+ }
594
+ const visible = new Set(nodes)
595
+ const resultSources = new Set()
596
+ const visibleAssistantCallSeqs = new Set()
597
+ const pendingAssistantCalls = new Map()
598
+ let assistantCallEvidence = false
599
+ for (const event of events) {
600
+ if (event?.type === 'turn/start' || event?.type === 'turn/end') {
601
+ pendingAssistantCalls.clear()
602
+ continue
603
+ }
604
+ if (event?.type === 'assistant/message') {
605
+ pendingAssistantCalls.clear()
606
+ const content = event.data?.message?.content
607
+ if (Array.isArray(content)) {
608
+ for (const part of content) {
609
+ if (part?.type === 'tool-call' && typeof part.id === 'string') {
610
+ const isVisible = visible.has(event.seq)
611
+ if (isVisible) assistantCallEvidence = true
612
+ let occurrences = pendingAssistantCalls.get(part.id)
613
+ if (occurrences === undefined) {
614
+ occurrences = []
615
+ pendingAssistantCalls.set(part.id, occurrences)
616
+ }
617
+ occurrences.push(isVisible)
618
+ }
619
+ }
620
+ }
621
+ continue
622
+ }
623
+ if (event?.type === 'tool/call' && typeof event.data?.callId === 'string') {
624
+ const occurrences = pendingAssistantCalls.get(event.data.callId)
625
+ if (occurrences !== undefined && occurrences.length > 0) {
626
+ const isVisible = occurrences.shift()
627
+ if (occurrences.length === 0) pendingAssistantCalls.delete(event.data.callId)
628
+ if (isVisible && ['run_code', 'edit_run_code'].includes(event.data.name)) {
629
+ visibleAssistantCallSeqs.add(event.seq)
630
+ }
631
+ }
632
+ }
633
+ if (visible.has(event?.seq)
634
+ && event?.type === 'tool/result' && Array.isArray(event.sourceEventSeqs)) {
635
+ for (const sourceSeq of event.sourceEventSeqs) {
636
+ if (Number.isSafeInteger(sourceSeq) && sourceSeq >= 0) resultSources.add(sourceSeq)
637
+ }
638
+ }
639
+ }
640
+ const calls = new Set()
641
+ for (const event of events) {
642
+ if (event?.type !== 'tool/call' || !['run_code', 'edit_run_code'].includes(event.data?.name)) continue
643
+ if (assistantCallEvidence
644
+ ? visibleAssistantCallSeqs.has(event.seq)
645
+ : (visible.has(event.seq) || resultSources.has(event.seq))) calls.add(event.seq)
646
+ }
647
+ return calls
648
+ }
649
+
533
650
  function timelineRun(call, result, eventIndex, journal) {
534
651
  let args
535
652
  try {
@@ -589,6 +706,7 @@ export function foldSessionTimeline(events) {
589
706
  const claimedEditTargets = new Set()
590
707
  const editClaims = new Map()
591
708
  const journalResultSeqs = new Set()
709
+ let pruneReplacementWindow
592
710
  let scope = 0
593
711
 
594
712
  const resetTurn = (openTurn) => {
@@ -603,6 +721,11 @@ export function foldSessionTimeline(events) {
603
721
 
604
722
  for (let eventIndex = 0; eventIndex < events.length; eventIndex += 1) {
605
723
  const event = events[eventIndex]
724
+ if (pruneReplacementWindow !== undefined
725
+ && event?.type !== 'compaction/prune'
726
+ && eventIndex !== pruneReplacementWindow.lastEventIndex + 1) {
727
+ pruneReplacementWindow = undefined
728
+ }
606
729
  if (event?.type === 'turn/start') {
607
730
  resetTurn(true)
608
731
  continue
@@ -612,7 +735,23 @@ export function foldSessionTimeline(events) {
612
735
  continue
613
736
  }
614
737
  if (event?.type === RECOVERY_BOUNDARY_EVENT) {
615
- throw new Error('legacy recovery boundary requires migration before DSH session restore')
738
+ state.unavailableResultSeq ??= Number.isSafeInteger(event.seq) ? event.seq : eventIndex
739
+ continue
740
+ }
741
+ if (event?.type === 'compaction/prune' && Array.isArray(event.data?.shadowedSeqs)) {
742
+ const shadowedSeqs = event.data.shadowedSeqs
743
+ const seqs = new Set(shadowedSeqs)
744
+ const shadowedRange = event.data.shadowedRange
745
+ const rangeValid = shadowedRange === undefined
746
+ || (isRecord(shadowedRange)
747
+ && shadowedRange.start === shadowedSeqs[0]
748
+ && shadowedRange.end === shadowedSeqs[shadowedSeqs.length - 1])
749
+ const valid = shadowedSeqs.length > 0
750
+ && seqs.size === shadowedSeqs.length
751
+ && shadowedSeqs.every(seq => Number.isSafeInteger(seq) && seq >= 0)
752
+ && shadowedSeqs.every((seq, index) => index === 0 || seq > shadowedSeqs[index - 1])
753
+ && rangeValid
754
+ pruneReplacementWindow = { seqs: valid ? seqs : new Set(), lastEventIndex: eventIndex }
616
755
  continue
617
756
  }
618
757
  if (event?.type === 'tool/call' && typeof event.data?.callId === 'string') {
@@ -652,6 +791,31 @@ export function foldSessionTimeline(events) {
652
791
  const sourceSeq = event.sourceEventSeqs?.[0]
653
792
  const callId = event.data?.message?.source?.callId
654
793
  let entry = Number.isSafeInteger(sourceSeq) ? state.executableCalls.get(sourceSeq) : undefined
794
+ let prunedReplacement = false
795
+ if (entry === undefined && Number.isSafeInteger(sourceSeq)
796
+ && pruneReplacementWindow?.seqs.has(sourceSeq)
797
+ && eventIndex === pruneReplacementWindow.lastEventIndex + 1
798
+ && Array.isArray(event.sourceEventSeqs) && event.sourceEventSeqs.length === 1
799
+ && typeof callId === 'string') {
800
+ const candidate = pendingByCallId.get(callId)
801
+ const candidateSeq = candidate?.event?.seq
802
+ const pruneSeq = events[pruneReplacementWindow.lastEventIndex]?.seq
803
+ const resultEventSeq = event.seq
804
+ const surfaceOp = event.surfaceOp
805
+ if (candidate !== undefined && candidate !== null
806
+ && (candidate.event.data.name === 'run_code' || candidate.event.data.name === 'edit_run_code')
807
+ && Number.isSafeInteger(candidateSeq) && candidateSeq >= 0
808
+ && sourceSeq > candidateSeq
809
+ && Number.isSafeInteger(pruneSeq) && pruneSeq >= 0
810
+ && Number.isSafeInteger(resultEventSeq) && resultEventSeq - pruneSeq === 1
811
+ && sourceSeq < pruneSeq
812
+ && (surfaceOp === undefined
813
+ || (surfaceOp?.op === 'replace' && surfaceOp.start === sourceSeq && surfaceOp.end === sourceSeq))) {
814
+ entry = candidate
815
+ prunedReplacement = true
816
+ pruneReplacementWindow.lastEventIndex = eventIndex
817
+ }
818
+ }
655
819
  if (entry === undefined && !Number.isSafeInteger(sourceSeq) && typeof callId === 'string') {
656
820
  entry = pendingByCallId.get(callId)
657
821
  }
@@ -668,23 +832,38 @@ export function foldSessionTimeline(events) {
668
832
  let normalized
669
833
  if (isRecord(meta) && Object.hasOwn(meta, JOURNAL_KEY)) {
670
834
  if (Number.isSafeInteger(sourceSeq) && journalResultSeqs.has(sourceSeq)) {
671
- throw new Error(`session log contains duplicate PTC journal results for call seq ${sourceSeq}`)
835
+ state.unavailableResultSeq ??= sourceSeq
836
+ state.results.set(sourceSeq, {
837
+ eventSeq: Number.isSafeInteger(event.seq) && event.seq >= 0 ? event.seq : sourceSeq,
838
+ eventIndex,
839
+ error: `session log contains duplicate PTC journal results for call seq ${sourceSeq}`,
840
+ })
841
+ state.found = true
842
+ continue
672
843
  }
673
844
  if (Number.isSafeInteger(sourceSeq)) journalResultSeqs.add(sourceSeq)
674
845
  if (entry === undefined) {
675
- if (Number.isSafeInteger(sourceSeq)) state.unavailableResultSeq ??= sourceSeq
846
+ if (Number.isSafeInteger(sourceSeq)) {
847
+ state.unavailableResultSeq ??= sourceSeq
848
+ }
676
849
  continue
677
850
  }
678
- const resultSeq = Number.isSafeInteger(sourceSeq) ? sourceSeq : callSeq
851
+ const resultSeq = prunedReplacement
852
+ ? callSeq
853
+ : Number.isSafeInteger(sourceSeq) ? sourceSeq : callSeq
679
854
  const raw = {
680
855
  meta,
681
856
  eventSeq: Number.isSafeInteger(event.seq) && event.seq >= 0 ? event.seq : resultSeq,
682
857
  eventIndex,
683
858
  }
684
859
  if (Object.hasOwn(meta, RECOVERY_BOUNDARY_KEY)) {
685
- state.boundaries.push(...normalizeRecoveryBoundaries(
686
- meta[RECOVERY_BOUNDARY_KEY], raw.eventSeq,
687
- ))
860
+ try {
861
+ state.boundaries.push(...normalizeRecoveryBoundaries(
862
+ meta[RECOVERY_BOUNDARY_KEY], raw.eventSeq,
863
+ ))
864
+ } catch {
865
+ state.unavailableResultSeq ??= callSeq
866
+ }
688
867
  }
689
868
  try {
690
869
  if (call.data.name === 'edit_run_code') {
@@ -749,57 +928,98 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
749
928
  const timeline = foldSessionTimeline(events)
750
929
  const calls = timeline.calls.filter(call => call.seq !== currentCallSeq)
751
930
  const { executableCalls, results, found } = timeline
931
+ const visibleCalls = options?.visibleCallSeqs
932
+ let unavailableSeq = timeline.unavailableResultSeq
933
+ let unavailableReason = 'unavailable or malformed dsh-ptc-plus journal result'
934
+ for (const [resultSeq, result] of results.entries()) {
935
+ if (result?.error !== undefined && Number.isSafeInteger(resultSeq)) {
936
+ unavailableSeq ??= resultSeq
937
+ unavailableReason = result.error
938
+ }
939
+ }
752
940
  const extraBoundaries = options?.extraBoundaries ?? []
941
+ let normalizedExtraBoundaries = []
942
+ try {
943
+ normalizedExtraBoundaries = normalizeRecoveryBoundaries(extraBoundaries, undefined)
944
+ } catch {
945
+ normalizedExtraBoundaries = []
946
+ }
753
947
  const boundaries = [
754
948
  ...timeline.boundaries,
755
- ...normalizeRecoveryBoundaries(extraBoundaries, undefined).map(boundary => ({
949
+ ...normalizedExtraBoundaries.map(boundary => ({
756
950
  ...boundary,
757
951
  eventSeq: Number.POSITIVE_INFINITY,
758
952
  })),
759
953
  ].sort((left, right) => left.eventSeq - right.eventSeq)
760
- if (timeline.unavailableResultSeq !== undefined) {
761
- throw new Error(`PTC journal result references unavailable run_code call seq ${timeline.unavailableResultSeq}`)
762
- }
954
+ // An unavailable historical result contracts the replay frontier. It must
955
+ // not prevent the current request from executing on the verified prefix.
763
956
 
764
957
  const confirmedNoops = new Set()
765
- for (const { journal, eventIndex } of results.values()) {
958
+ const invalidResultReasons = new Map()
959
+ for (const [resultSeq, { journal, eventIndex }] of results.entries()) {
960
+ const candidateConfirms = []
766
961
  for (const callSeq of journal?.confirms ?? []) {
767
962
  const confirmed = executableCalls.get(callSeq)
768
963
  if (confirmed === undefined || confirmed.eventIndex >= eventIndex || results.has(callSeq)) {
769
- throw new Error(`confirmed no-op does not identify an earlier unjournaled run_code call seq ${callSeq}`)
964
+ const reason = `confirmed no-op does not identify an earlier unjournaled run_code call seq ${callSeq}`
965
+ invalidResultReasons.set(resultSeq, reason)
966
+ unavailableSeq ??= resultSeq
967
+ unavailableReason = reason
968
+ break
770
969
  }
771
- confirmedNoops.add(callSeq)
970
+ candidateConfirms.push(callSeq)
971
+ }
972
+ if (!invalidResultReasons.has(resultSeq)) {
973
+ for (const callSeq of candidateConfirms) confirmedNoops.add(callSeq)
772
974
  }
773
975
  }
774
976
  const records = []
775
977
  const orderedRecords = []
776
978
  const invalidCallSeqs = new Set()
777
- const appliedBoundaries = []
778
979
  let state = foldRecords(records, invalidCallSeqs)
980
+ let blockedUnavailableSeq = unavailableSeq
779
981
  let boundaryIndex = 0
780
982
  const applyBoundariesBefore = (seq) => {
781
983
  while (boundaryIndex < boundaries.length && boundaries[boundaryIndex].eventSeq <= seq) {
782
984
  const boundary = boundaries[boundaryIndex++]
985
+ const failedCall = executableCalls.get(boundary.failedCallSeq)
986
+ if (failedCall === undefined || failedCall.event.seq >= boundary.eventSeq) continue
783
987
  const failedIndex = state.nodes.findIndex(node => node.callSeq === boundary.failedCallSeq)
784
- if (failedIndex < 0) throw new Error('recovery boundary references an unavailable failed cell')
785
- const expectedFrontier = state.nodes[failedIndex].parent
988
+ const expectedFrontier = failedIndex < 0 ? state.head : state.nodes[failedIndex].parent
786
989
  const frontierIndex = boundary.frontierCallSeq === null
787
990
  ? undefined
788
991
  : state.nodes.findIndex(node => node.callSeq === boundary.frontierCallSeq)
789
- if (frontierIndex !== expectedFrontier) {
790
- throw new Error('recovery boundary does not identify the failed cell parent')
791
- }
992
+ if (frontierIndex !== expectedFrontier) continue
993
+ const resolvesUnavailable = blockedUnavailableSeq === boundary.failedCallSeq
994
+ if (failedIndex < 0 && !resolvesUnavailable) continue
792
995
  for (let index = 0; index < state.nodes.length; index += 1) {
793
996
  if (dependsOn(state.nodes, index, failedIndex)) invalidCallSeqs.add(state.nodes[index].callSeq)
794
997
  }
795
- appliedBoundaries.push(boundary)
796
- state = foldRecords(records, invalidCallSeqs, appliedBoundaries)
998
+ if (failedIndex < 0) {
999
+ for (const record of records) {
1000
+ if (record.call.seq >= boundary.failedCallSeq) invalidCallSeqs.add(record.call.seq)
1001
+ }
1002
+ }
1003
+ state = foldRecords(records, invalidCallSeqs, {
1004
+ surfaceContracted: resolvesUnavailable ? false : state.surfaceContracted,
1005
+ })
1006
+ if (resolvesUnavailable) {
1007
+ blockedUnavailableSeq = undefined
1008
+ if (unavailableSeq === boundary.failedCallSeq) unavailableSeq = undefined
1009
+ }
797
1010
  }
798
1011
  }
799
1012
  for (const call of calls) {
800
1013
  const result = results.get(call.seq)
801
1014
  const code = call.data?.name === 'edit_run_code' ? result?.derived?.code : sourceForRunCall(call)
802
- const record = { call, code, result }
1015
+ const record = {
1016
+ call,
1017
+ code,
1018
+ result: invalidResultReasons.has(call.seq)
1019
+ ? { ...result, journal: undefined, error: invalidResultReasons.get(call.seq) }
1020
+ : result,
1021
+ hidden: visibleCalls instanceof Set && !visibleCalls.has(call.seq),
1022
+ }
803
1023
  if (call.data?.name === 'edit_run_code' && record.code === undefined && result === undefined) continue
804
1024
  if (result?.journal === undefined && confirmedNoops.has(call.seq)) continue
805
1025
  orderedRecords.push(record)
@@ -810,15 +1030,62 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
810
1030
  for (const record of orderedRecords) {
811
1031
  applyBoundariesBefore(recordEventSeq(record))
812
1032
  records.push(record)
813
- applyRecord(state, record, invalidCallSeqs)
1033
+ if (record.hidden) {
1034
+ blockedUnavailableSeq ??= record.call.seq
1035
+ unavailableSeq ??= record.call.seq
1036
+ unavailableReason = 'model-visible provenance was shadowed'
1037
+ invalidCallSeqs.add(record.call.seq)
1038
+ state.surfaceContracted = true
1039
+ state.trusted = false
1040
+ state.volatileSuffix.push({ seq: record.call.seq, code: undefined, reason: unavailableReason })
1041
+ continue
1042
+ }
1043
+ if (blockedUnavailableSeq !== undefined && record.call.seq >= blockedUnavailableSeq) {
1044
+ invalidCallSeqs.add(record.call.seq)
1045
+ if (!state.volatileSuffix.some(item => item.seq === record.call.seq)) {
1046
+ state.trusted = false
1047
+ state.volatileSuffix.push({
1048
+ seq: record.call.seq,
1049
+ code: record.code,
1050
+ reason: unavailableReason,
1051
+ })
1052
+ }
1053
+ continue
1054
+ }
1055
+ if (record.result?.journal === undefined) {
1056
+ blockedUnavailableSeq ??= record.call.seq
1057
+ unavailableSeq ??= record.call.seq
1058
+ unavailableReason = record.result?.error ?? 'missing dsh-ptc-plus journal result'
1059
+ invalidCallSeqs.add(record.call.seq)
1060
+ state.trusted = false
1061
+ state.volatileSuffix.push({ seq: record.call.seq, code: record.code, reason: unavailableReason })
1062
+ continue
1063
+ }
1064
+ try {
1065
+ applyRecord(state, record, invalidCallSeqs)
1066
+ } catch (error) {
1067
+ unavailableSeq ??= record.call.seq
1068
+ blockedUnavailableSeq ??= record.call.seq
1069
+ unavailableReason = error.message
1070
+ invalidCallSeqs.add(record.call.seq)
1071
+ }
814
1072
  }
815
1073
  applyBoundariesBefore(Number.POSITIVE_INFINITY)
1074
+ if (unavailableSeq !== undefined
1075
+ && !state.volatileSuffix.some(item => item.seq === unavailableSeq)) {
1076
+ state.trusted = false
1077
+ state.volatileSuffix.push({
1078
+ seq: unavailableSeq,
1079
+ code: undefined,
1080
+ reason: unavailableReason,
1081
+ })
1082
+ }
816
1083
  return {
817
1084
  nodes: state.nodes,
818
1085
  head: state.head,
819
1086
  checkpoints: state.checkpoints,
820
1087
  volatileSuffix: state.volatileSuffix,
821
- available: found,
1088
+ available: found && !state.surfaceContracted && blockedUnavailableSeq === undefined,
822
1089
  }
823
1090
  }
824
1091