dsh-ptc-plus 0.5.2 → 0.5.4

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.
Files changed (72) hide show
  1. package/README.en.md +4 -2
  2. package/README.md +4 -2
  3. package/client.cjs +3392 -2811
  4. package/compiler-core.cjs +127 -127
  5. package/compiler-core.cjs.map +2 -2
  6. package/docs/README.md +1 -0
  7. package/docs/adr/0017-track-the-latest-dsh-public-surface.md +4 -2
  8. package/docs/adr/0019-plugin-settings-and-kill-switch.md +2 -2
  9. package/docs/adr/0023-global-user-bindings.md +12 -0
  10. package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +1 -1
  11. package/docs/adr/0030-serve-both-typert-codec-generations-from-one-codec.md +2 -2
  12. package/docs/adr/0044-coexist-with-the-official-persistent-repl.md +4 -4
  13. package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +1 -1
  14. package/docs/adr/0048-supersede-a-rewritten-cell.md +48 -0
  15. package/docs/adr/0049-prove-a-settlement-against-its-published-record.md +53 -0
  16. package/docs/adr/0050-read-host-surfaces-through-their-published-contract.md +48 -0
  17. package/docs/adr/README.md +7 -1
  18. package/docs/architecture.md +1 -1
  19. package/docs/capability-planning-components.md +6 -6
  20. package/docs/capability-projection.md +1 -1
  21. package/docs/capability-surface.md +133 -0
  22. package/docs/client-ui.md +17 -15
  23. package/docs/client-visual-contract.md +7 -7
  24. package/docs/durability-design.md +9 -2
  25. package/docs/installation.md +17 -10
  26. package/docs/panel-concept.md +171 -0
  27. package/docs/releases/v0.5.2.md +26 -14
  28. package/docs/releases/v0.5.3.md +77 -0
  29. package/docs/releases/v0.5.4.md +75 -0
  30. package/docs/runtime-reference.md +21 -1
  31. package/docs/user-bindings.en.md +15 -0
  32. package/docs/user-bindings.md +15 -0
  33. package/docs/v0.5.3-goals.md +117 -0
  34. package/docs/verification.md +4 -4
  35. package/index.js +176 -41
  36. package/internal/binding-adoption.js +228 -0
  37. package/internal/capability-command-receipt.js +106 -8
  38. package/internal/capability-environment.js +1109 -312
  39. package/internal/capability-plan.js +18 -2
  40. package/internal/capability-policy.js +13 -0
  41. package/internal/capability-selection.js +330 -0
  42. package/internal/config-spec.js +14 -5
  43. package/internal/cordis-tools-owner.js +2 -1
  44. package/internal/direct-surface-owner.js +44 -17
  45. package/internal/edit-transport-owner.js +6 -5
  46. package/internal/execution-provider-owner.js +6 -0
  47. package/internal/execution-seam-compat.js +25 -0
  48. package/internal/host-tool-sdk.js +308 -0
  49. package/internal/message-sources.js +21 -0
  50. package/internal/official-repl-priority.js +31 -10
  51. package/internal/program-bindings.js +25 -5
  52. package/internal/repl-memory-projection.js +21 -2
  53. package/internal/runtime-bridge-owner.js +118 -9
  54. package/internal/runtime-config.js +10 -0
  55. package/internal/runtime-messages.js +5 -0
  56. package/internal/session-journal-recovery.js +210 -32
  57. package/internal/session-journal-schema.js +3 -0
  58. package/internal/session-journal.js +46 -9
  59. package/internal/session-log-view.js +69 -7
  60. package/internal/session-runtime.js +106 -39
  61. package/internal/settings-compat.js +6 -0
  62. package/internal/user-bindings-owner.js +126 -6
  63. package/package.json +59 -37
  64. package/src/client-authoring-view.js +7 -8
  65. package/src/client-capabilities-view.js +874 -909
  66. package/src/client-capability-sort.js +22 -9
  67. package/src/client-copy.js +118 -272
  68. package/src/client-indicator-view.js +2 -1
  69. package/src/client-repl-view.js +35 -16
  70. package/src/client-settings-view.js +90 -33
  71. package/src/client-styles.js +67 -19
  72. package/src/client.js +49 -22
@@ -64,6 +64,9 @@ import {
64
64
  USER_BINDINGS_REUSE_POLICIES,
65
65
  RECOVERY_BOUNDARY_FIELDS,
66
66
  DIAGNOSTIC_WORDING_KEY,
67
+ SETTLEMENT_PROOF_KEY,
68
+ SETTLEMENT_PROOF_FIELDS,
69
+ SETTLEMENT_PROOF_VERSION,
67
70
  REPL_TOOL_NAMES,
68
71
  REWRITE_FIELDS,
69
72
  REWRITE_KINDS,
@@ -78,6 +81,9 @@ export {
78
81
  RECOVERY_BOUNDARY_KEY,
79
82
  DIAGNOSTIC_WORDING_KEY,
80
83
  DIAGNOSTIC_WORDING_FIELDS,
84
+ SETTLEMENT_PROOF_KEY,
85
+ SETTLEMENT_PROOF_FIELDS,
86
+ SETTLEMENT_PROOF_VERSION,
81
87
  JOURNAL_VERSION,
82
88
  LIVE_USER_BINDINGS_REUSE_POLICY,
83
89
  RECOVERY_BOUNDARY_EVENT,
@@ -129,15 +135,46 @@ export function diagnosticWordingDigest(journal) {
129
135
  return digestOf(diagnostics.map(diagnosticWordingFacts))
130
136
  }
131
137
 
132
- /** Attach the recorded wording digest beside a journal, or drop a stale one. */
133
- export function withDiagnosticWording(meta, journal) {
134
- const digest = diagnosticWordingDigest(journal)
135
- if (digest === undefined) {
136
- if (!Object.hasOwn(meta, DIAGNOSTIC_WORDING_KEY)) return meta
137
- const { [DIAGNOSTIC_WORDING_KEY]: _omitted, ...rest } = meta
138
- return rest
138
+ /**
139
+ * Digest of the one settlement a journal states: the status of its cell, its
140
+ * completion, and the wording of its diagnostics.
141
+ *
142
+ * A published record is the only account of its own cell, so a record edited
143
+ * afterwards stays self-consistent: the completion is checkable against a
144
+ * diagnostic, and the diagnostic against the wording digest, but all three are
145
+ * the same settlement. The proof is taken at publication, from the facts the
146
+ * settlement was published with, and every fact is represented here exactly once,
147
+ * so a record whose status, completion or diagnostic wording changed no longer
148
+ * reproduces it.
149
+ */
150
+ export function settlementProofDigest(journal) {
151
+ const completion = journal?.completion
152
+ const completionFacts = completion === undefined ? null
153
+ : completion.kind === 'return'
154
+ ? [completion.kind, completion.hasValue,
155
+ completion.hasValue ? digestOf(completion.value) : null]
156
+ : [completion.kind, completion.error.kind, completion.error.message]
157
+ return digestOf([
158
+ journal?.status ?? null,
159
+ completionFacts,
160
+ diagnosticWordingDigest(journal) ?? null,
161
+ ])
162
+ }
163
+
164
+ /**
165
+ * Attach the published settlement's proof beside a journal.
166
+ *
167
+ * Every settlement states a status, so every publication carries one; a record
168
+ * that carries none predates this proof and keeps the older evidence.
169
+ */
170
+ export function withSettlementProof(meta, journal) {
171
+ return {
172
+ ...meta,
173
+ [SETTLEMENT_PROOF_KEY]: {
174
+ version: SETTLEMENT_PROOF_VERSION,
175
+ recordedMessage: settlementProofDigest(journal),
176
+ },
139
177
  }
140
- return { ...meta, [DIAGNOSTIC_WORDING_KEY]: { digest } }
141
178
  }
142
179
 
143
180
  function cloneJson(value) {
@@ -1272,7 +1309,7 @@ export function withJournal(meta, journal) {
1272
1309
  const base = isRecord(meta) ? { ...meta } : meta === undefined ? {} : { value: cloneJson(meta) }
1273
1310
  const normalized = normalizeJournal(journal)
1274
1311
  base[JOURNAL_KEY] = normalized
1275
- return withDiagnosticWording(base, normalized)
1312
+ return withSettlementProof(base, normalized)
1276
1313
  }
1277
1314
 
1278
1315
  /** Validate and detach one rewrite record emitted by the runtime. */
@@ -9,9 +9,38 @@ import { deepEqualJson, isJsonValue } from '@deepseek-ai/dsh-util-values'
9
9
  import { deepFreeze, isRecord } from './record-utils.js'
10
10
  import { advanceRuntimeHistory, createRuntimeHistory } from './recovery-tips.js'
11
11
 
12
- import { isHostRuntimeContextSource } from './message-sources.js'
12
+ import { isHostRuntimeContextSource, isPtcMessageSource } from './message-sources.js'
13
+ import { BINDING_ADOPTION_FORM, readBindingAdoption } from './binding-adoption.js'
13
14
  const SYSTEM_PROMPT_CLEARED = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.'
14
15
 
16
+ /**
17
+ * A PTC binding-adoption statement, readable or not.
18
+ *
19
+ * The fold has to recognize the statement even when this build cannot resolve its
20
+ * wording: the fact it keeps is what a restored checkpoint replays, so a fact that
21
+ * dropped the form could not reproduce the record the fold appended.
22
+ */
23
+ function isBindingAdoptionStatement(message) {
24
+ const source = message?.source
25
+ return isPtcMessageSource(source) && source.form === BINDING_ADOPTION_FORM
26
+ }
27
+
28
+ /**
29
+ * An adoption statement this build cannot resolve, kept as the statement it is.
30
+ *
31
+ * Both the panel and the `/binding` command answer from this projection, and the
32
+ * answer has to be this session's *newest* statement. A projection that dropped a
33
+ * statement it could not resolve would let the record the session superseded answer
34
+ * instead, so the statement is retained with no declined list — the absence is what
35
+ * marks it unreadable. It never reaches the model-visible surface, which is built
36
+ * from resolvable runtime messages only.
37
+ */
38
+ function unreadableBindingAdoption(message) {
39
+ if (!isBindingAdoptionStatement(message)) return undefined
40
+ if (readBindingAdoption(message) !== undefined) return undefined
41
+ return Object.freeze({ form: BINDING_ADOPTION_FORM })
42
+ }
43
+
15
44
  /** DSH's empty aggregate uses an exact owner marker without a snapshot form. */
16
45
  export function systemPromptSnapshotSections(message) {
17
46
  const source = message?.source
@@ -72,7 +101,8 @@ export function runtimeMessageFacts(records, initial = {}) {
72
101
 
73
102
  function presentationEvent(event) {
74
103
  const recognized = event?.type === 'user/message'
75
- && (systemPromptSnapshotSections(event.data) !== undefined || readRuntimeMessage(event.data) !== undefined)
104
+ && (systemPromptSnapshotSections(event.data) !== undefined || readRuntimeMessage(event.data) !== undefined
105
+ || isBindingAdoptionStatement(event.data))
76
106
  const data = recognized ? { source: event.data.source, content: event.data.content }
77
107
  : event?.type === 'user/message' && typeof event.data?.source?.kind === 'string'
78
108
  ? { source: { kind: event.data.source.kind } } : undefined
@@ -86,11 +116,14 @@ function applyLogEvent(state, event, index = event?.seq,
86
116
  timeline = advanceSessionTimeline(state.timeline, event, index)) {
87
117
  const sections = event?.type === 'user/message' ? systemPromptSnapshotSections(event.data) : undefined
88
118
  const ptc = event?.type === 'user/message' ? readRuntimeMessage(event.data) : undefined
119
+ // An adoption statement this build cannot resolve is still this session's statement,
120
+ // so it stays in the projection as the newest one rather than disappearing.
121
+ const unresolvedAdoption = ptc === undefined ? unreadableBindingAdoption(event?.data) : undefined
89
122
  const contextStep = state.contextStep + (isContextStep(event) ? 1 : 0)
90
123
  const surfaceNodes = advanceSessionSurface(state.surfaceNodes, event)
91
124
  let visibleRuntimeMessages = state.visibleRuntimeMessages
92
- if (timeline === state.timeline && sections === undefined && ptc === undefined && contextStep === state.contextStep
93
- && surfaceNodes === state.surfaceNodes) return state
125
+ if (timeline === state.timeline && sections === undefined && ptc === undefined && unresolvedAdoption === undefined
126
+ && contextStep === state.contextStep && surfaceNodes === state.surfaceNodes) return state
94
127
  const next = { ...state, timeline, contextStep,
95
128
  logFacts: [...state.logFacts, deepFreeze({ index, event: presentationEvent(event) })],
96
129
  }
@@ -110,6 +143,11 @@ function applyLogEvent(state, event, index = event?.seq,
110
143
  const record = Object.freeze({ ...ptc, index, contextStep: state.contextStep, producer: 'ptc-plus' })
111
144
  next.ptcMessages = [...state.ptcMessages, record]
112
145
  next.runtimeMessagesBySeq = { ...next.runtimeMessagesBySeq, [`k${event.seq}`]: record }
146
+ } else if (unresolvedAdoption !== undefined) {
147
+ // Listed as this session's newest statement, but not as a resolvable runtime
148
+ // message: the ordered surface below is built from `runtimeMessagesBySeq`.
149
+ next.ptcMessages = [...state.ptcMessages,
150
+ Object.freeze({ ...unresolvedAdoption, index, contextStep: state.contextStep, producer: 'ptc-plus' })]
113
151
  }
114
152
  if (event?.surfaceOp === 'append') {
115
153
  const record = next.runtimeMessagesBySeq[`k${event.seq}`]
@@ -133,7 +171,11 @@ export function createSessionLogProjection(options = {}) {
133
171
  const replayBudget = admissionReplayBudget(options.replayBudgetBytes)
134
172
  return Object.freeze({
135
173
  key: SESSION_LOG_PROJECTION_KEY,
136
- stateVersion: 13,
174
+ // 14 records the binding-adoption statement this build cannot resolve: the fact it
175
+ // keeps now carries the form its own replay needs, and `ptcMessages` gained a record
176
+ // for it. A checkpoint written by an earlier fold answers a different question, so
177
+ // the version moves and stored checkpoints are refolded rather than admitted.
178
+ stateVersion: 14,
137
179
  stateSchema: Object.freeze({ parse(value) {
138
180
  if (!isJsonValue(value) || !isRecord(value) || !isRecord(value.timeline)
139
181
  || typeof value.timeline.openTurn !== 'boolean' || typeof value.timeline.found !== 'boolean'
@@ -249,6 +291,24 @@ export function createSessionLogProjection(options = {}) {
249
291
  })
250
292
  }
251
293
 
294
+ /**
295
+ * The disposer a session projection registration handed back.
296
+ *
297
+ * Two registration contracts are published: an earlier host returns a disposer and a
298
+ * newer one returns a `ProjectionHandle` (`stateOf`/`readerFor`/`dispose`, possibly
299
+ * async). Every projection this plugin registers goes through this reader, because a
300
+ * registration that only understands the earlier shape drops the projection — and
301
+ * with it the fact that the host accepted it — on the newer contract.
302
+ * @param {unknown} registration - what `sessionProjections.register` returned.
303
+ * @returns {(() => unknown)|undefined} the release call, or undefined when the answer
304
+ * names neither contract.
305
+ */
306
+ export function projectionRegistrationDisposer(registration) {
307
+ if (typeof registration === 'function') return () => registration()
308
+ if (typeof registration?.dispose === 'function') return () => registration.dispose()
309
+ return undefined
310
+ }
311
+
252
312
  export function createSessionLogOwner(ctx) {
253
313
  const projection = createSessionLogProjection()
254
314
  let registeredService
@@ -256,8 +316,10 @@ export function createSessionLogOwner(ctx) {
256
316
  try {
257
317
  scope.effect(() => {
258
318
  const registry = scope.sessionProjections
259
- const unregister = registry.register(projection)
260
- if (typeof unregister !== 'function') throw new Error('ptc-plus: sessionProjections.register did not return a disposer')
319
+ const unregister = projectionRegistrationDisposer(registry.register(projection))
320
+ if (unregister === undefined) {
321
+ throw new Error('ptc-plus: sessionProjections.register returned neither a disposer nor a handle')
322
+ }
261
323
  registeredService = registry
262
324
  return async () => {
263
325
  await unregister()
@@ -180,6 +180,34 @@ export function vanishedBindingDiagnostic(names) {
180
180
  })
181
181
  }
182
182
 
183
+ /**
184
+ * Notice for bindings a recorded revision superseded.
185
+ *
186
+ * A revision that rewrites its target puts the target off the recorded path, so
187
+ * the target's declarations stop building the environment. They are not missing:
188
+ * the log says which revision replaced them, and reporting that relation keeps
189
+ * the model from reading a later `x is not defined` as its own mistake.
190
+ */
191
+ export function supersededDiagnostic(bindings, supersessions = []) {
192
+ const names = bindings.length === 0 ? '' : ` (${bindings.join(', ')})`
193
+ const relations = supersessions.map(item =>
194
+ `cell ${item.supersededCallSeq} by cell ${item.supersedingCallSeq}`).join('; ')
195
+ return diagnostic({
196
+ code: 'PTC-R007',
197
+ severity: 'warning',
198
+ phase: 'recover',
199
+ message: (bindings.length === 0
200
+ ? 'A recorded revision replaced the cell it edited'
201
+ : `Superseded ${bindings.length} binding(s)${names} because a recorded revision replaced the cell that declared them`)
202
+ + `${relations === '' ? '' : ` (${relations})`}. The replaced cell stays in the session log but no longer builds this environment.`,
203
+ stateEffect: 'rolled-back',
204
+ help: [
205
+ 'this is not a recovery failure: an accepted edit rewrote the cell that declared these bindings, and the revision does not declare them',
206
+ 'redeclare a superseded name in a new cell before using it again',
207
+ ],
208
+ })
209
+ }
210
+
183
211
  function unverifiedCompletionDiagnostic(count) {
184
212
  return diagnostic({
185
213
  code: 'PTC-R003',
@@ -329,19 +357,21 @@ class SessionKernel {
329
357
  this.liveCallSeqs = new Set()
330
358
  this.unverifiedCompletions = []
331
359
  this.restoredFromRecord = []
332
- this.recoveryNotice = disposedCells > 0
360
+ this.recoveryNotices = disposedCells > 0
333
361
  // A kernel whose owning agent or session was disposed must not resurrect
334
362
  // the disposed environment: the history is gone by contract, and a later
335
363
  // cell starts from an empty REPL with the loss reported once.
336
- ? recoveryDiagnostic([], disposedCells)
364
+ ? [recoveryDiagnostic([], disposedCells)]
337
365
  : history.volatileSuffix.length === 0
338
- ? undefined
339
- : recoveryDiagnostic(droppedBindingNames(history), history.volatileSuffix.length)
340
- this.recoveryNoticeShown = false
366
+ ? []
367
+ : [recoveryDiagnostic(droppedBindingNames(history), history.volatileSuffix.length)]
341
368
  // Bindings a recovery told the model were live, and the last help pair that
342
369
  // explained a recovery. Both exist to keep one event from being reported
343
370
  // twice and to notice a binding that stops existing afterwards.
344
371
  this.announcedLive = new Set()
372
+ // A recorded supersession is a durable fact; announcing it once per runtime
373
+ // keeps later folds from repeating a relation the model already read.
374
+ this.announcedSupersessions = new Set()
345
375
  this.recoveryHelpSignature = undefined
346
376
  this.recoveryEventCount = 0
347
377
  this.active = undefined
@@ -558,27 +588,29 @@ class SessionKernel {
558
588
  if (worker !== undefined) await this.resetWorker(worker)
559
589
  this.rollbackToDurable()
560
590
  if (config.durableReplay) {
561
- this.recoveryNotice = undefined
591
+ this.recoveryNotices = []
562
592
  try {
563
593
  this.history = recoverJournal(observedSession, request.callSeq, {
564
594
  visibleCallSeqs,
595
+ editRewritesHistory: config.editRewritesHistory === true,
565
596
  })
566
597
  } catch (error) {
567
- /* c8 ignore start -- a fold that throws is not reachable from any supported session; cover or prove it in 0.5.3 */
598
+ /* c8 ignore start -- the fold reports damaged history as data, so no supported session reaches this catch */
568
599
  const boundary = recoveryBoundaryForHistory(this.history, undefined, { reset: true })
569
600
  if (boundary !== undefined) recoveryBoundaries.push(boundary)
570
601
  this.history = emptyHistory()
571
- this.recoveryNotice = recoveryDiagnostic([], 1)
602
+ this.recoveryNotices = [recoveryDiagnostic([], 1)]
572
603
  this.initialRecoveryBoundary = undefined
573
604
  /* c8 ignore stop */
574
605
  }
575
- /* c8 ignore start -- volatile suffix on the first fold; cover in 0.5.3 */
606
+ /* c8 ignore start -- volatile suffix on the first fold; no case settles a volatile suffix before the first replay */
576
607
  if (this.history.volatileSuffix.length > 0) {
577
- this.recoveryNotice = recoveryDiagnostic(droppedBindingNames(this.history), this.history.volatileSuffix.length)
608
+ this.recoveryNotices = [recoveryDiagnostic(
609
+ droppedBindingNames(this.history), this.history.volatileSuffix.length)]
578
610
  }
579
611
  /* c8 ignore stop */
580
612
  this.initialRecoveryBoundary = recoveryBoundaryForHistory(this.history)
581
- /* c8 ignore start -- first-fold recovery boundary; cover in 0.5.3 */
613
+ /* c8 ignore start -- first-fold recovery boundary; no case makes the first replay record one */
582
614
  if (this.initialRecoveryBoundary !== undefined) {
583
615
  recoveryBoundaries.push(this.initialRecoveryBoundary)
584
616
  this.initialRecoveryBoundary = undefined
@@ -587,7 +619,7 @@ class SessionKernel {
587
619
  } else {
588
620
  this.history = emptyHistory()
589
621
  this.replayed = true
590
- this.recoveryNotice = recoveryDiagnostic([], 1)
622
+ this.recoveryNotices = [recoveryDiagnostic([], 1)]
591
623
  }
592
624
  }
593
625
  }
@@ -680,6 +712,7 @@ class SessionKernel {
680
712
  const recovered = recoverJournal(observedSession, request.callSeq, {
681
713
  extraBoundaries: [boundary],
682
714
  visibleCallSeqs: visibleExecutableCallSeqs(observedSession),
715
+ editRewritesHistory: config.editRewritesHistory === true,
683
716
  })
684
717
  const nextPathLength = pathToHead(recovered).length
685
718
  if (nextPathLength >= previousPathLength) throw new Error('recovery did not contract the historical frontier')
@@ -698,10 +731,12 @@ class SessionKernel {
698
731
  this.durability = durabilityState()
699
732
  }
700
733
  }
701
- // One recovery event produces one notice. The four facts below describe the
702
- // same event, so they are composed once instead of overwriting each other:
703
- // an earlier overwrite dropped the restored-name list whenever a later fact
704
- // arrived, and a repeat entry re-emitted the same sentence.
734
+ // One recovery event produces one report. The facts below describe the same
735
+ // event, so each one that holds is collected instead of overwriting the ones
736
+ // already found: an earlier overwrite dropped the restored-name list whenever
737
+ // a later fact arrived, and a relation dropped that way was then cleared
738
+ // without ever reaching the model. The facts never name the same binding, so
739
+ // collecting them states the event once per fact rather than twice per name.
705
740
  const restoredCount = this.restoredFromRecord.length
706
741
  const allLive = [...new Set(this.restoredFromRecord.flatMap(item => item.bindings))]
707
742
  // Report only what this recovery added. A later recovery that installs
@@ -716,6 +751,8 @@ const failedNames = [...new Set(this.restoredFromRecord
716
751
  .filter(name => typeof name === 'string'))]
717
752
  const droppedNames = this.missingBindings === undefined ? [] : [...new Set(this.missingBindings)]
718
753
  const endedNames = this.rewindEndedBindings === undefined ? [] : [...new Set(this.rewindEndedBindings)]
754
+ const supersededNames = this.supersededEndedBindings === undefined
755
+ ? [] : [...new Set(this.supersededEndedBindings)]
719
756
  const alreadyReported = this.recoveryEventCount > 0
720
757
  // A complete restore is invisible to the model. Every recorded binding was
721
758
  // installed, nothing was dropped or ended, and the next cell behaves exactly
@@ -726,58 +763,78 @@ const failedNames = [...new Set(this.restoredFromRecord
726
763
  const completeRestore = failedNames.length === 0
727
764
  && droppedNames.length === 0
728
765
  && endedNames.length === 0
766
+ && supersededNames.length === 0
729
767
  if (restoredCount > 0 && completeRestore) {
730
768
  this.restoredFromRecord = []
731
- /* c8 ignore start -- needs a rewind that ended an announced name; cover in 0.5.3 */
769
+ /* c8 ignore start -- needs a rewind that ended an announced name after a reported recovery; no case does */
732
770
  } else if (restoredCount > 0 && liveNames.length === 0 && failedNames.length === 0 && alreadyReported) {
733
771
  this.restoredFromRecord = []
734
772
  /* c8 ignore stop */
735
773
  } else if (restoredCount > 0) {
736
774
  this.recoveryEventCount += 1
737
- this.recoveryNotice = restoredFromRecordDiagnostic(
775
+ this.recoveryNotices.push(restoredFromRecordDiagnostic(
738
776
  restoredCount,
739
777
  liveNames,
740
778
  failedNames,
741
779
  alreadyReported,
742
- )
780
+ ))
743
781
  }
744
- /* c8 ignore start -- needs a proven name the replay never visits; cover in 0.5.3 */
782
+ /* c8 ignore start -- needs a proven name the replay never visits; no case records one */
745
783
  if (droppedNames.length > 0) {
746
- this.recoveryNotice = recoveryDiagnostic(droppedNames, droppedNames.length)
784
+ this.recoveryNotices.push(recoveryDiagnostic(droppedNames, droppedNames.length))
747
785
  /* c8 ignore stop */
748
786
  } else if (restoredCount === 0 && skipped > 0) {
749
- this.recoveryNotice = recoveryDiagnostic([], skipped)
787
+ this.recoveryNotices.push(recoveryDiagnostic([], skipped))
750
788
  }
751
789
  // A rewind that ended a binding is recorded, not a loss: report it only when
752
790
  // the replay installed nothing under that name anywhere (path, off-path or
753
- // catalog), so a correct value never carries a "dropped" notice.
754
- if (this.recoveryNotice === undefined && endedNames.length > 0) {
755
- this.recoveryNotice = rewindEndedDiagnostic(endedNames)
791
+ // catalog), so a correct value never carries a "dropped" notice. A revision
792
+ // that superseded a cell is the other recorded ending: the environment it
793
+ // built differs from the cells the log still lists, so the relation is
794
+ // reported once per runtime instead of on every later fold.
795
+ const freshSupersessions = (this.supersessions ?? [])
796
+ .filter(item => !this.announcedSupersessions.has(item.supersedingCallSeq))
797
+ // A restored cell and a superseded one are two facts about the same recovery,
798
+ // not alternatives: the names this relation withdrew are excluded from the
799
+ // missing and rewind lists, so a report that chose the restore notice over it
800
+ // left a withdrawn name reported nowhere.
801
+ if (freshSupersessions.length > 0 || supersededNames.length > 0) {
802
+ this.recoveryNotices.push(supersededDiagnostic(supersededNames, freshSupersessions))
803
+ for (const item of freshSupersessions) this.announcedSupersessions.add(item.supersedingCallSeq)
756
804
  }
757
- if (this.recoveryNotice === undefined && this.unverifiedCompletions.length > 0) {
758
- this.recoveryNotice = unverifiedCompletionDiagnostic(this.unverifiedCompletions.length)
805
+ if (endedNames.length > 0) {
806
+ this.recoveryNotices.push(rewindEndedDiagnostic(endedNames))
759
807
  }
760
808
  // A completion the replay could not certify is not a lost binding: the
761
809
  // replay established them, and only the recorded payload stays unproved.
762
- if (this.recoveryNotice !== undefined) {
810
+ if (this.unverifiedCompletions.length > 0) {
811
+ this.recoveryNotices.push(unverifiedCompletionDiagnostic(this.unverifiedCompletions.length))
812
+ }
813
+ if (this.recoveryNotices.length > 0) {
763
814
  // Remember what was promised as live, so a binding that disappears later
764
815
  // is reported by name instead of surfacing as an unexplained ReferenceError.
765
816
  for (const name of liveNames) this.announcedLive.add(name)
766
817
  // The help pair explains the recovery once. Repeating it on every later
767
818
  // recovery in the same session is noise the model cannot act on twice.
768
- const signature = this.recoveryNotice.message
769
- if (this.recoveryHelpSignature === signature) this.recoveryNotice = { ...this.recoveryNotice, help: [] }
770
- else this.recoveryHelpSignature = signature
819
+ const signature = this.recoveryNotices.map(item => item.message).join('\n')
820
+ if (this.recoveryHelpSignature === signature) {
821
+ this.recoveryNotices = this.recoveryNotices.map(item => ({ ...item, help: [] }))
822
+ } else {
823
+ this.recoveryHelpSignature = signature
824
+ }
771
825
  }
772
826
  this.restoredFromRecord = []
773
827
  this.missingBindings = undefined
774
828
  this.rewindEndedBindings = undefined
829
+ this.supersededEndedBindings = undefined
830
+ this.supersessions = undefined
775
831
  }
776
- const leadingDiagnostics = [
777
- ...(this.recoveryNotice === undefined || this.recoveryNoticeShown ? [] : [this.recoveryNotice]),
778
- ]
779
- this.recoveryNoticeShown = this.recoveryNotice !== undefined
780
- this.recoveryNotice = undefined
832
+ // Every recovery reports the facts it established. Suppressing a later recovery's
833
+ // notice hid a relation the runtime had already marked as announced, so a name the
834
+ // revision withdrew was reported nowhere; what is deduplicated is the explanation
835
+ // (below) and each name or relation this runtime already stated.
836
+ const leadingDiagnostics = this.recoveryNotices
837
+ this.recoveryNotices = []
781
838
  if (request.journal !== undefined) request.journal.diagnostics.push(...leadingDiagnostics)
782
839
  let result
783
840
  try {
@@ -938,9 +995,17 @@ const failedNames = [...new Set(this.restoredFromRecord
938
995
  // correct.
939
996
  const installed = new Set(replayedNames)
940
997
  for (const name of this.bindingCatalog.inputs().knownBindings) installed.add(name)
941
- this.rewindEndedBindings = [...endedBindings].filter(name => !installed.has(name))
998
+ // A superseded name is ended by the record that replaced its cell, so it is
999
+ // reported apart from a rewind and never as a binding recovery failed to
1000
+ // install: the log says which revision replaced it.
1001
+ const supersededEnded = new Set((this.history.supersededBindings ?? [])
1002
+ .filter(name => !installed.has(name)))
1003
+ this.rewindEndedBindings = [...endedBindings]
1004
+ .filter(name => !installed.has(name) && !supersededEnded.has(name))
1005
+ this.supersededEndedBindings = [...supersededEnded]
1006
+ this.supersessions = this.history.supersessions ?? []
942
1007
  this.missingBindings = (this.history.provenBindings ?? [])
943
- .filter(name => !installed.has(name) && !endedBindings.has(name))
1008
+ .filter(name => !installed.has(name) && !endedBindings.has(name) && !supersededEnded.has(name))
944
1009
  }
945
1010
 
946
1011
  completeJournal(journal, status, result, volatileReason, diagnostics = [], completion = undefined, values = undefined) {
@@ -1372,7 +1437,8 @@ export class SessionRuntime {
1372
1437
  kernel.surfaceGeneration = surfaceGeneration(session)
1373
1438
  } else {
1374
1439
  const timeline = foldSessionTimeline(observed.events)
1375
- const history = recoverJournal(observed, undefined, { visibleCallSeqs: visible })
1440
+ const history = recoverJournal(observed, undefined, { visibleCallSeqs: visible,
1441
+ editRewritesHistory: this.config.editRewritesHistory === true })
1376
1442
  const head = history.nodes[history.head]
1377
1443
  if (head === undefined) return unavailableReplMemorySnapshot()
1378
1444
  // A cold inventory can describe a proved recorded frontier without
@@ -1468,6 +1534,7 @@ export class SessionRuntime {
1468
1534
  ? emptyHistory()
1469
1535
  : recoverJournal(sessionLog, callSeq, {
1470
1536
  visibleCallSeqs: visibleExecutableCallSeqs(sessionLog),
1537
+ editRewritesHistory: cellConfig.editRewritesHistory === true,
1471
1538
  ...(liveOnlyLost ? { liveOnlyLost: true } : {}),
1472
1539
  })
1473
1540
  } catch (error) {
@@ -74,6 +74,12 @@ export function installSettingsSectionCompat({
74
74
  && typeof writeNamespace === 'string' && writeNamespace.length > 0
75
75
  ? Object.freeze({
76
76
  update: (patch, expectedRevision) => provider.update(writeNamespace, patch, expectedRevision),
77
+ // The service applies field edits as `{ op: 'set' | 'unset', path }`; `update` can only
78
+ // restate values, so expose the primitive when it exists instead of narrowing the callers
79
+ // to a writer that cannot remove a field.
80
+ ...(typeof provider.mutate === 'function'
81
+ ? { mutate: (ops, expectedRevision) => provider.mutate(writeNamespace, ops, expectedRevision) }
82
+ : {}),
77
83
  read: () => provider.describe?.().find(descriptor => descriptor.ns === writeNamespace),
78
84
  }) : undefined
79
85
  const unbind = onProvider(provider, writer)