dsh-ptc-plus 0.5.1 → 0.5.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.
Files changed (58) hide show
  1. package/README.en.md +7 -3
  2. package/README.md +7 -3
  3. package/client.cjs +1467 -84
  4. package/compiler-core.cjs +132 -132
  5. package/compiler-core.cjs.map +3 -3
  6. package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +2 -0
  7. package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +11 -1
  8. package/docs/adr/0032-own-session-log-facts-in-a-host-only-projection.md +31 -1
  9. package/docs/adr/0045-customize-model-visible-prompt-text.md +38 -0
  10. package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +35 -0
  11. package/docs/adr/0047-store-journal-argument-digests-in-the-projection.md +88 -0
  12. package/docs/architecture.md +2 -2
  13. package/docs/client-ui.md +5 -2
  14. package/docs/durability-design.md +7 -1
  15. package/docs/releases/v0.5.2.md +79 -0
  16. package/docs/runtime-reference.md +36 -2
  17. package/docs/unmeasured-cell-recovery-repair-proposal.md +387 -0
  18. package/docs/verification.md +6 -2
  19. package/index.js +36 -15
  20. package/internal/bounded-literal.js +85 -0
  21. package/internal/capability-environment.js +162 -10
  22. package/internal/capability-plan.js +11 -8
  23. package/internal/cell-error.js +13 -0
  24. package/internal/config-spec.js +113 -2
  25. package/internal/direct-surface-owner.js +14 -10
  26. package/internal/edit-transport-owner.js +79 -14
  27. package/internal/isolated-worker.js +134 -17
  28. package/internal/kernel-worker.js +167 -3
  29. package/internal/legacy-cell-analysis.js +14 -2
  30. package/internal/legacy-repl-convenience.js +16 -3
  31. package/internal/projection-retention.js +238 -0
  32. package/internal/prompt-text.js +858 -0
  33. package/internal/recovery-tips.js +50 -11
  34. package/internal/rejected-cell-editor.js +3 -2
  35. package/internal/runtime-bridge-owner.js +1 -1
  36. package/internal/runtime-config.js +2 -0
  37. package/internal/runtime-contexts.js +14 -5
  38. package/internal/runtime-messages.js +43 -15
  39. package/internal/session-cell-executor.js +132 -11
  40. package/internal/session-journal-recovery.js +1009 -99
  41. package/internal/session-journal-schema.js +7 -5
  42. package/internal/session-journal.js +177 -14
  43. package/internal/session-log-view.js +111 -24
  44. package/internal/session-runtime.js +674 -33
  45. package/internal/session-state.js +27 -0
  46. package/internal/stateful-root-compiler.js +11 -0
  47. package/internal/stateful-root-runtime.js +17 -0
  48. package/internal/user-binding-console.js +7 -0
  49. package/internal/user-bindings-owner.js +3 -0
  50. package/internal/user-bindings.js +2 -2
  51. package/internal/validated-parse-repair.js +6 -1
  52. package/internal/worker-client.js +27 -4
  53. package/package.json +3 -1
  54. package/src/client-activity.js +24 -4
  55. package/src/client-capabilities-view.js +459 -24
  56. package/src/client-copy.js +163 -2
  57. package/src/client-repl-view.js +6 -1
  58. package/src/client-styles.js +64 -2
@@ -11,6 +11,7 @@ export const DERIVED_RUN_KEY = 'dshPtcPlusDerivedRun'
11
11
  export const POST_EDIT_KEY = 'dshPtcPlusPostEdit'
12
12
  export const REWRITES_KEY = 'dshPtcPlusRewrites'
13
13
  export const RECOVERY_BOUNDARY_KEY = 'dshPtcPlusRecoveryBoundaries'
14
+ export const DIAGNOSTIC_WORDING_KEY = 'dshPtcPlusDiagnosticWording'
14
15
  export const IMPORT_BOUNDARY_JOURNAL_VERSION = 7
15
16
  export const PER_NAME_USER_BINDINGS_JOURNAL_VERSION = 8
16
17
  export const LANGUAGE_SEMANTICS_JOURNAL_VERSION = 9
@@ -35,7 +36,7 @@ export const WHOLE_ENTRY_JOURNAL_FIELDS = new Set(['version', 'bindingPolicy', '
35
36
  export const PER_NAME_JOURNAL_FIELDS = new Set([...WHOLE_ENTRY_JOURNAL_FIELDS, 'userBindingsShadowPolicy', 'userBindingNames'])
36
37
  export const LANGUAGE_SEMANTICS_JOURNAL_FIELDS = new Set([...PER_NAME_JOURNAL_FIELDS, 'languageSemantics'])
37
38
  export const MODULE_TRANSFORM_JOURNAL_FIELDS = new Set([...LANGUAGE_SEMANTICS_JOURNAL_FIELDS, 'moduleTransform'])
38
- export const JOURNAL_FIELDS = new Set([...MODULE_TRANSFORM_JOURNAL_FIELDS, 'executionCwd'])
39
+ export const JOURNAL_FIELDS = new Set([...MODULE_TRANSFORM_JOURNAL_FIELDS, 'executionCwd', 'values', 'unrecordedBindings'])
39
40
  export const FINGERPRINT_REUSE_JOURNAL_FIELDS = new Set([...WHOLE_ENTRY_JOURNAL_FIELDS].filter(field => field !== 'userBindingsReusePolicy'))
40
41
  export const RELATIONLESS_JOURNAL_FIELDS = new Set([...FINGERPRINT_REUSE_JOURNAL_FIELDS].filter(field => field !== 'userBindingsFingerprint'))
41
42
  export const PREDECESSOR_JOURNAL_FIELDS = new Set(['version', 'bindingMode', 'rewritePolicy', 'status', 'calls', 'operations', 'confirms', 'diagnostics', 'completion', 'volatileReason'])
@@ -52,14 +53,15 @@ export const DEFAULT_EXPORT_BINDINGS = new Set([
52
53
  LEGACY_DEFAULT_EXPORT_BINDING,
53
54
  LIVE_DEFAULT_EXPORT_BINDING,
54
55
  ])
55
- export const CALL_SUCCESS_FIELDS = new Set(['global', 'member', 'args', 'ok', 'value', 'settle'])
56
- export const CALL_ERROR_FIELDS = new Set(['global', 'member', 'args', 'ok', 'error', 'settle'])
57
- export const OPERATION_FIELDS = new Set(['action', 'name'])
56
+ export const CALL_SUCCESS_FIELDS = new Set(['global', 'member', 'args', 'argsDigest', 'argsBytes', 'ok', 'value', 'settle'])
57
+ export const CALL_ERROR_FIELDS = new Set(['global', 'member', 'args', 'argsDigest', 'argsBytes', 'ok', 'error', 'settle'])
58
+ export const OPERATION_FIELDS = new Set(['action', 'name', 'kind'])
58
59
  export const RETURN_FIELDS = new Set(['kind', 'hasValue', 'value'])
59
60
  export const THROW_FIELDS = new Set(['kind', 'error'])
60
61
  export const ERROR_FIELDS = new Set(['kind', 'message'])
61
62
  export const EDIT_TARGET_FIELDS = new Set(['targetCallSeq'])
62
- export const DERIVED_RUN_FIELDS = new Set(['code', 'description'])
63
+ export const DERIVED_RUN_FIELDS = new Set(['code', 'codeDigest', 'codeBytes', 'description'])
64
+ export const DIAGNOSTIC_WORDING_FIELDS = new Set(['digest'])
63
65
 
64
66
  export const JOURNAL_VERSIONS = new Set([LEGACY_JOURNAL_VERSION, INTERMEDIATE_JOURNAL_VERSION, PREVIOUS_JOURNAL_VERSION, USER_BINDING_RELATIONLESS_JOURNAL_VERSION, FINGERPRINT_REUSE_JOURNAL_VERSION, VERSIONED_BINDING_REUSE_JOURNAL_VERSION, IMPORT_BOUNDARY_JOURNAL_VERSION, PER_NAME_USER_BINDINGS_JOURNAL_VERSION, LANGUAGE_SEMANTICS_JOURNAL_VERSION, MODULE_TRANSFORM_JOURNAL_VERSION, JOURNAL_VERSION])
65
67
  export const USER_BINDINGS_REUSE_POLICIES = new Set([LEGACY_USER_BINDINGS_REUSE_POLICY, LIVE_USER_BINDINGS_REUSE_POLICY])
@@ -13,6 +13,7 @@ import {
13
13
  LIVE_MODULE_SEMANTICS,
14
14
  } from './repl-rewrite-contract.js'
15
15
  import { userBindingsSnapshotFromMeta } from './user-bindings.js'
16
+ import { digestOf } from './projection-retention.js'
16
17
  import {
17
18
  JOURNAL_KEY,
18
19
  EDIT_TARGET_KEY,
@@ -62,6 +63,7 @@ import {
62
63
  JOURNAL_VERSIONS,
63
64
  USER_BINDINGS_REUSE_POLICIES,
64
65
  RECOVERY_BOUNDARY_FIELDS,
66
+ DIAGNOSTIC_WORDING_KEY,
65
67
  REPL_TOOL_NAMES,
66
68
  REWRITE_FIELDS,
67
69
  REWRITE_KINDS,
@@ -74,11 +76,70 @@ export {
74
76
  DERIVED_RUN_KEY,
75
77
  REWRITES_KEY,
76
78
  RECOVERY_BOUNDARY_KEY,
79
+ DIAGNOSTIC_WORDING_KEY,
80
+ DIAGNOSTIC_WORDING_FIELDS,
77
81
  JOURNAL_VERSION,
78
82
  LIVE_USER_BINDINGS_REUSE_POLICY,
79
83
  RECOVERY_BOUNDARY_EVENT,
80
84
  } from './session-journal-schema.js'
81
85
 
86
+ /**
87
+ * The facts one recorded diagnostic states about what happened, without the
88
+ * presentation it is rendered through.
89
+ *
90
+ * `help` and `source` are presentation: a historical record may carry an older
91
+ * generation's help wording, and a caret position is re-derived from the cell
92
+ * when the diagnostic is rendered. The facts below are the wording the model was
93
+ * told, and they must be the facts that were recorded.
94
+ */
95
+ function diagnosticWordingFacts(diagnostic) {
96
+ return [
97
+ diagnostic.code,
98
+ diagnostic.severity,
99
+ diagnostic.phase,
100
+ diagnostic.message,
101
+ diagnostic.stateEffect,
102
+ diagnostic.dispatchState ?? null,
103
+ diagnosticWordingCause(diagnostic.cause),
104
+ diagnostic.collisions ?? null,
105
+ ]
106
+ }
107
+
108
+ function diagnosticWordingCause(cause) {
109
+ if (cause === undefined) return null
110
+ return cause.severity === undefined
111
+ ? [cause.code ?? null, cause.message]
112
+ : diagnosticWordingFacts(cause)
113
+ }
114
+
115
+ /**
116
+ * Digest of the diagnostic wording a journal records, or `undefined` when it
117
+ * records none.
118
+ *
119
+ * Recovery re-renders a recorded diagnostic to report what the cell did, and it
120
+ * cannot tell an edited diagnostic from the recorded one when the record is the
121
+ * only evidence: the completion message and the diagnostic that produced it are
122
+ * two copies of one settlement. The digest of the recorded wording is taken when
123
+ * the settlement is published, so a record whose wording was changed afterwards
124
+ * is no longer evidence for the bindings its cell declared.
125
+ */
126
+ export function diagnosticWordingDigest(journal) {
127
+ const diagnostics = journal?.diagnostics
128
+ if (!Array.isArray(diagnostics) || diagnostics.length === 0) return undefined
129
+ return digestOf(diagnostics.map(diagnosticWordingFacts))
130
+ }
131
+
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
139
+ }
140
+ return { ...meta, [DIAGNOSTIC_WORDING_KEY]: { digest } }
141
+ }
142
+
82
143
  function cloneJson(value) {
83
144
  if (value === undefined) return undefined
84
145
  return decodeValue(encodeValue(value))
@@ -96,7 +157,8 @@ function normalizeCalls(value) {
96
157
  if (!Array.isArray(value)) throw new Error('invalid dsh-ptc-plus journal calls')
97
158
  const calls = value.map((call, index) => {
98
159
  if (!isRecord(call) || typeof call.global !== 'string' || typeof call.member !== 'string'
99
- || !Object.hasOwn(call, 'args') || (call.ok !== true && call.ok !== false)
160
+ || Object.hasOwn(call, 'args') === Object.hasOwn(call, 'argsDigest')
161
+ || (call.ok !== true && call.ok !== false)
100
162
  || !isCanonicalSequence(call.settle)) {
101
163
  throw new Error(`invalid dsh-ptc-plus journal call at index ${index}`)
102
164
  }
@@ -107,10 +169,17 @@ function normalizeCalls(value) {
107
169
  if (call.ok === false && typeof call.error !== 'string') {
108
170
  throw new Error(`journal call at index ${index} is missing its error`)
109
171
  }
172
+ const digestedArgs = Object.hasOwn(call, 'argsDigest')
173
+ if (digestedArgs && (typeof call.argsDigest !== 'string' || !/^[a-f0-9]{64}$/u.test(call.argsDigest)
174
+ || !isCanonicalSequence(call.argsBytes))) {
175
+ throw new Error(`invalid digested dsh-ptc-plus journal call args at index ${index}`)
176
+ }
110
177
  return {
111
178
  global: call.global,
112
179
  member: call.member,
113
- args: normalizeValueWire(call.args),
180
+ ...(digestedArgs
181
+ ? { argsDigest: call.argsDigest, argsBytes: call.argsBytes }
182
+ : { args: normalizeValueWire(call.args) }),
114
183
  ok: call.ok,
115
184
  settle: call.settle,
116
185
  ...(call.ok ? { value: normalizeValueWire(call.value) } : { error: call.error }),
@@ -131,7 +200,12 @@ function normalizeOperations(value) {
131
200
  throw new Error(`invalid dsh-ptc-plus journal operation at index ${index}`)
132
201
  }
133
202
  assertOwnFields(operation, OPERATION_FIELDS, `journal operation at index ${index}`)
134
- return { action: operation.action, ...(operation.name === undefined ? {} : { name: operation.name }) }
203
+ if (operation.kind !== undefined
204
+ && (operation.action !== 'save' || !['binding', 'checkpoint'].includes(operation.kind))) {
205
+ throw new Error(`invalid dsh-ptc-plus journal operation kind at index ${index}`)
206
+ }
207
+ return { action: operation.action, ...(operation.name === undefined ? {} : { name: operation.name }),
208
+ ...(operation.kind === undefined ? {} : { kind: operation.kind }) }
135
209
  })
136
210
  }
137
211
 
@@ -263,7 +337,21 @@ function normalizeUserBindingsFingerprint(value) {
263
337
  return value
264
338
  }
265
339
 
340
+ // Fields a later journal version introduced. A record is interpreted by its own
341
+ // version, so a field that version could not have carried is not evidence: drop it
342
+ // rather than rejecting the whole journal. A field that carries its own historical
343
+ // contract, such as executionCwd, stays strict; only the value-carrying fields added by the recording work are trimmed, so
344
+ // a record written by a newer build and relabelled by a migration fixture still folds
345
+ // with its historical meaning.
346
+ const NEWER_JOURNAL_FIELDS = Object.freeze(['values', 'unrecordedBindings'])
347
+ function withoutNewerJournalFields(value) {
348
+ const trimmed = { ...value }
349
+ for (const field of NEWER_JOURNAL_FIELDS) delete trimmed[field]
350
+ return trimmed
351
+ }
352
+
266
353
  function migrateJournal(value, resolveLegacyConfirm) {
354
+ value = value.version === JOURNAL_VERSION ? value : withoutNewerJournalFields(value)
267
355
  if (value.version === JOURNAL_VERSION) return value
268
356
  if (value.version === MODULE_TRANSFORM_JOURNAL_VERSION) {
269
357
  assertOwnFields(value, MODULE_TRANSFORM_JOURNAL_FIELDS, 'dsh-ptc-plus journal')
@@ -358,6 +446,33 @@ function normalizeExecutionCwd(value) {
358
446
  }
359
447
 
360
448
  /** Validate and detach one journal emitted by the runtime. */
449
+ /**
450
+ * Values a volatile settlement recorded for the bindings it created.
451
+ *
452
+ * A volatile cell cannot be replayed, so the record carries the values it
453
+ * produced and recovery installs them without running the cell again. Names the
454
+ * codec could not transfer are kept apart so a contraction can name them
455
+ * instead of pretending they were never created.
456
+ */
457
+ function normalizeJournalValues(values) {
458
+ if (values === undefined) return undefined
459
+ if (!Array.isArray(values)) throw new Error('invalid dsh-ptc-plus journal values')
460
+ return Object.freeze(values.map(entry => {
461
+ if (!isRecord(entry) || typeof entry.name !== 'string' || entry.name === '') {
462
+ throw new Error('invalid dsh-ptc-plus journal value entry')
463
+ }
464
+ return Object.freeze({ name: entry.name, wire: normalizeValueWire(entry.wire) })
465
+ }))
466
+ }
467
+
468
+ function normalizeUnrecordedBindings(names) {
469
+ if (names === undefined) return undefined
470
+ if (!Array.isArray(names) || names.some(name => typeof name !== 'string' || name === '')) {
471
+ throw new Error('invalid dsh-ptc-plus journal unrecorded bindings')
472
+ }
473
+ return Object.freeze([...new Set(names)])
474
+ }
475
+
361
476
  export function normalizeJournal(value, options = {}) {
362
477
  if (!isRecord(value)) throw new Error('invalid dsh-ptc-plus journal')
363
478
  if (!JOURNAL_VERSIONS.has(value.version)
@@ -387,6 +502,8 @@ export function normalizeJournal(value, options = {}) {
387
502
  migrated.completion,
388
503
  migrated.status === 'durable' || migrated.status === 'volatile',
389
504
  )
505
+ const recordedValues = normalizeJournalValues(migrated.values)
506
+ const unrecordedBindings = normalizeUnrecordedBindings(migrated.unrecordedBindings)
390
507
  if ((migrated.status === 'discarded' || migrated.status === 'noop')
391
508
  && (calls.length !== 0 || operations.length !== 0)) {
392
509
  throw new Error(`${migrated.status} dsh-ptc-plus journal must not contain calls or operations`)
@@ -416,11 +533,19 @@ export function normalizeJournal(value, options = {}) {
416
533
  diagnostics: Object.freeze(diagnostics),
417
534
  ...(completion === undefined ? {} : { completion }),
418
535
  ...(migrated.volatileReason === undefined ? {} : { volatileReason: migrated.volatileReason }),
536
+ ...(recordedValues === undefined ? {} : { values: recordedValues }),
537
+ ...(unrecordedBindings === undefined ? {} : { unrecordedBindings }),
419
538
  })
420
539
  }
421
540
 
422
- /** Validate the required persisted relation that makes one edit result executable history. */
423
- export function normalizeDerivedEditResult(meta, expectedTargetCallSeq) {
541
+ /**
542
+ * Completion error kind of a cell that never reached the runtime because it could
543
+ * not be measured. Such a settlement records that nothing ran, so it can prove an
544
+ * accepted edit's dispatch relation without proving any executed state.
545
+ */
546
+ export const UNMEASURED_CELL_ERROR_KIND = 'budget-unavailable'
547
+
548
+ function normalizeDerivedRelation(meta, expectedTargetCallSeq, { allowUnexecuted }) {
424
549
  if (!isCanonicalSequence(expectedTargetCallSeq)) {
425
550
  throw new Error('derived edit does not identify an eligible target call')
426
551
  }
@@ -433,24 +558,61 @@ export function normalizeDerivedEditResult(meta, expectedTargetCallSeq) {
433
558
  || target.targetCallSeq !== expectedTargetCallSeq) {
434
559
  throw new Error('derived edit target does not match the eligible target call')
435
560
  }
436
- if (!isRecord(derived) || typeof derived.code !== 'string' || typeof derived.description !== 'string') {
561
+ // A derived run outside the payload window keeps the digest of its source instead
562
+ // of the text. Every field except the source is kept, so the deferred-edit
563
+ // relation stays checkable in both shapes; the text is refolded from the log when
564
+ // a consumer needs it.
565
+ const digestedCode = isRecord(derived) && !Object.hasOwn(derived, 'code')
566
+ if (!isRecord(derived) || typeof derived.description !== 'string'
567
+ || (digestedCode
568
+ ? typeof derived.codeDigest !== 'string' || !/^[a-f0-9]{64}$/u.test(derived.codeDigest)
569
+ || !isCanonicalSequence(derived.codeBytes)
570
+ : typeof derived.code !== 'string')) {
437
571
  throw new Error('invalid dsh-ptc-plus derived run metadata')
438
572
  }
439
573
  assertOwnFields(derived, DERIVED_RUN_FIELDS, 'derived run metadata')
440
574
  const journal = normalizeJournal(meta[JOURNAL_KEY])
441
- if (journal.status === 'noop') throw new Error('derived edit journal must not be noop')
575
+ // Only the current deferred edit relation may prove itself with a settlement
576
+ // that never ran, and only for the unmeasured route: a rejected source, an
577
+ // aborted cell or a lost helper are different facts and keep the historical
578
+ // rule that a derived edit journal must record an execution.
579
+ const unexecuted = journal.status === 'noop'
580
+ && journal.completion?.error?.kind === UNMEASURED_CELL_ERROR_KIND
581
+ if (journal.status === 'noop' && !(allowUnexecuted && unexecuted)) {
582
+ throw new Error('derived edit journal must not be noop')
583
+ }
442
584
  const recoveryBoundaries = meta[RECOVERY_BOUNDARY_KEY] === undefined
443
585
  ? undefined
444
586
  : normalizeRecoveryBoundaries(meta[RECOVERY_BOUNDARY_KEY])
445
587
  const userBindings = userBindingsForJournal(meta, journal)
446
- return Object.freeze({
588
+ return {
447
589
  targetCallSeq: target.targetCallSeq,
448
- code: derived.code,
590
+ ...(digestedCode
591
+ ? { codeDigest: derived.codeDigest, codeBytes: derived.codeBytes }
592
+ : { code: derived.code }),
449
593
  description: derived.description,
450
594
  journal,
595
+ ...(unexecuted ? { unexecuted: true } : {}),
451
596
  ...(recoveryBoundaries === undefined ? {} : { recoveryBoundaries }),
452
597
  ...(userBindings === undefined ? {} : { userBindings }),
453
- })
598
+ }
599
+ }
600
+
601
+ /** Validate the required persisted relation that makes one edit result executable history. */
602
+ export function normalizeDerivedEditResult(meta, expectedTargetCallSeq) {
603
+ return Object.freeze(normalizeDerivedRelation(meta, expectedTargetCallSeq, { allowUnexecuted: false }))
604
+ }
605
+
606
+ /**
607
+ * Validate the relation one accepted deferred edit records for itself.
608
+ *
609
+ * The current deferred relation additionally admits a settlement that proves the
610
+ * revision never ran, because the root cell's own execution stays provable then.
611
+ * The historical top-level editor relation does not: it keeps requiring an
612
+ * executed derived run.
613
+ */
614
+ export function normalizeDeferredEditSettlement(meta, expectedTargetCallSeq) {
615
+ return Object.freeze(normalizeDerivedRelation(meta, expectedTargetCallSeq, { allowUnexecuted: true }))
454
616
  }
455
617
 
456
618
  export function userBindingsForJournal(meta, journal) {
@@ -492,8 +654,8 @@ function validateUserBindingNameProviders(journal, snapshot) {
492
654
  /** Compare only the required persisted relation for one derived edit. */
493
655
  export function derivedEditResultsEqual(leftMeta, rightMeta, expectedTargetCallSeq) {
494
656
  try {
495
- const left = encodeValue(normalizeDerivedEditResult(leftMeta, expectedTargetCallSeq))
496
- const right = encodeValue(normalizeDerivedEditResult(rightMeta, expectedTargetCallSeq))
657
+ const left = encodeValue(normalizeDeferredEditSettlement(leftMeta, expectedTargetCallSeq))
658
+ const right = encodeValue(normalizeDeferredEditSettlement(rightMeta, expectedTargetCallSeq))
497
659
  return JSON.stringify(left) === JSON.stringify(right)
498
660
  } catch {
499
661
  return false
@@ -1108,8 +1270,9 @@ export function reduceStateOperations({ nodes, head, checkpoints }, operations,
1108
1270
  /** Merge the journal into the tool result's private metadata. */
1109
1271
  export function withJournal(meta, journal) {
1110
1272
  const base = isRecord(meta) ? { ...meta } : meta === undefined ? {} : { value: cloneJson(meta) }
1111
- base[JOURNAL_KEY] = normalizeJournal(journal)
1112
- return base
1273
+ const normalized = normalizeJournal(journal)
1274
+ base[JOURNAL_KEY] = normalized
1275
+ return withDiagnosticWording(base, normalized)
1113
1276
  }
1114
1277
 
1115
1278
  /** Validate and detach one rewrite record emitted by the runtime. */
@@ -1,5 +1,6 @@
1
1
  import {
2
- advanceSessionTimeline, createSessionTimelineState, replaySessionTimelineFact, validateSessionTimelineState,
2
+ advanceSessionTimeline, createSessionTimelineState, factDigest, replaySessionTimelineFact, withFullRetention,
3
+ retentionSummaryOf, validateRetentionSummary, validateSessionTimelineState, admissionReplayBudget,
3
4
  } from './session-journal-recovery.js'
4
5
  import { isCanonicalSequence } from './session-journal.js'
5
6
  import { advanceSessionSurface, readSessionLog, sessionEvents } from './session-events.js'
@@ -38,7 +39,7 @@ function isContextStep(event) {
38
39
  || (event?.type === 'user/message' && event.data?.source?.kind === 'user')
39
40
  }
40
41
 
41
- export const SESSION_LOG_PROJECTION_KEY = 'ptcPlusSessionLog'
42
+ const SESSION_LOG_PROJECTION_KEY = 'ptcPlusSessionLog'
42
43
 
43
44
  function emptyLogState() {
44
45
  return { timeline: createSessionTimelineState(), contextStep: 0,
@@ -46,6 +47,19 @@ function emptyLogState() {
46
47
  systemPromptSnapshots: [], ptcMessages: [], runtimeMessagesBySeq: {}, visibleRuntimeMessages: [], surfaceNodes: [], logFacts: [] }
47
48
  }
48
49
 
50
+ /**
51
+ * Digest fields are validated per fact against their own bytes, so the replay
52
+ * comparison must ignore them: a v10 checkpoint has no digests and must still be
53
+ * admissible (the host refolds on the version mismatch), while a v11 one has been
54
+ * digest-checked already.
55
+ */
56
+ function withoutFactDigests(state) {
57
+ const facts = state.timeline.eventFacts
58
+ if (!facts.some(fact => fact.digest !== undefined)) return state
59
+ return { ...state, timeline: { ...state.timeline,
60
+ eventFacts: facts.map(({ digest, ...rest }) => rest) } }
61
+ }
62
+
49
63
  export function runtimeMessageFacts(records, initial = {}) {
50
64
  const facts = { ...initial }
51
65
  for (const record of records) {
@@ -115,10 +129,11 @@ function applyLogEvent(state, event, index = event?.seq,
115
129
  return next
116
130
  }
117
131
 
118
- export function createSessionLogProjection() {
132
+ export function createSessionLogProjection(options = {}) {
133
+ const replayBudget = admissionReplayBudget(options.replayBudgetBytes)
119
134
  return Object.freeze({
120
135
  key: SESSION_LOG_PROJECTION_KEY,
121
- stateVersion: 9,
136
+ stateVersion: 13,
122
137
  stateSchema: Object.freeze({ parse(value) {
123
138
  if (!isJsonValue(value) || !isRecord(value) || !isRecord(value.timeline)
124
139
  || typeof value.timeline.openTurn !== 'boolean' || typeof value.timeline.found !== 'boolean'
@@ -134,12 +149,17 @@ export function createSessionLogProjection() {
134
149
  || !isRecord(value.runtimeMessagesBySeq) || !isRecord(value.timeline.cordisTranscript)) {
135
150
  throw new TypeError('invalid PTC session-log projection checkpoint')
136
151
  }
137
- validateSessionTimelineState(value.timeline)
138
152
  if (!Array.isArray(value.logFacts)) throw new TypeError('invalid PTC session-log projection checkpoint facts')
139
- const timelineFacts = new Map(value.timeline.eventFacts.map(fact => [fact.eventIndex, fact]))
140
- let reconstructed = emptyLogState()
153
+ validateSessionTimelineState(value.timeline, value.logFacts, { replayBudgetBytes: replayBudget })
154
+ // Admission proves digest-bound continuity and frontier agreement, not a
155
+ // byte-for-byte replay equality of argument text. Replaying the whole log
156
+ // here would cost the host a second fold of every event (and the retained
157
+ // payloads make that fold proportional to everything the session wrote), so
158
+ // this validates the identity chain the projection itself maintains:
159
+ // ordered log facts whose indices agree with the timeline facts, each
160
+ // timeline fact carrying a digest that matches the log fact it points at,
161
+ // and a frontier that names the processed cut.
141
162
  let lastIndex = -1
142
- let timelineCount = 0
143
163
  for (const fact of value.logFacts) {
144
164
  if (!isRecord(fact) || Object.keys(fact).some(key => !['index', 'event'].includes(key))
145
165
  || !isCanonicalSequence(fact.index) || fact.index <= lastIndex
@@ -148,23 +168,79 @@ export function createSessionLogProjection() {
148
168
  throw new TypeError('invalid PTC session-log projection checkpoint facts')
149
169
  }
150
170
  lastIndex = fact.index
171
+ }
172
+ // Strictly increasing indices are what makes this index one-to-one, so a
173
+ // duplicate cannot reach the identity loop below. The per-fact digest is
174
+ // already required by `validateSessionTimelineState` above, which reads the
175
+ // same equality from the same facts.
176
+ const factsByIndex = new Map(value.logFacts.map(fact => [fact.index, fact]))
177
+ for (const timelineFact of value.timeline.eventFacts) {
178
+ const logFact = factsByIndex.get(timelineFact.eventIndex)
179
+ if (logFact === undefined) {
180
+ throw new TypeError('inconsistent PTC session-log projection checkpoint event identity')
181
+ }
182
+ if (logFact.event.seq !== timelineFact.event.seq || logFact.event.type !== timelineFact.event.type) {
183
+ throw new TypeError('inconsistent PTC session-log projection checkpoint event identity')
184
+ }
185
+ }
186
+ // Replay is the strongest admission check, but replaying every fact costs a
187
+ // second fold whose payload is proportional to everything the session wrote.
188
+ // Admission therefore replays while the retained payload stays inside the
189
+ // budget and falls back to the identity chain above (ordered facts, per-fact
190
+ // digests, frontier agreement) beyond it. Small checkpoints keep the full
191
+ // byte-for-byte proof; large ones prove digest-bound continuity instead.
192
+ // Admission proves digest-bound continuity and frontier agreement, not a
193
+ // byte-for-byte replay equality of argument text: replay runs only while the
194
+ // retained payload stays inside a fixed budget, and every retained fact
195
+ // carries a digest over its own bytes, so beyond that budget a payload edit
196
+ // is still rejected without folding the whole session a second time.
197
+ const summaryPresent = retentionSummaryOf(value.timeline) !== undefined
198
+ // A compacted checkpoint dropped whole clusters, so replaying the log can no
199
+ // longer reach the stored state. Admission proves the summary instead (below)
200
+ // and skips the replay entirely: it is both impossible to satisfy and the
201
+ // most expensive thing this file does.
202
+ const timelineFacts = new Map(value.timeline.eventFacts.map(fact => [fact.eventIndex, fact]))
203
+ let replayBytes = 0
204
+ let replayComplete = true
205
+ let reconstructed = emptyLogState()
206
+ for (const fact of summaryPresent ? [] : value.logFacts) {
151
207
  const timelineFact = timelineFacts.get(fact.index)
152
- if (timelineFact !== undefined) {
153
- if (!deepEqualJson(presentationEvent(timelineFact.event), fact.event)) {
154
- throw new TypeError('inconsistent PTC session-log projection checkpoint event identity')
208
+ if (timelineFact === undefined) {
209
+ if (advanceSessionTimeline(reconstructed.timeline, fact.event, fact.index) !== reconstructed.timeline) {
210
+ throw new TypeError('inconsistent PTC session-log projection checkpoint missing timeline facts')
211
+ }
212
+ } else {
213
+ replayBytes += factDigest(timelineFact.event).length * 4
214
+ if (replayBytes > replayBudget) {
215
+ replayComplete = false
216
+ continue
155
217
  }
156
- timelineCount += 1
157
- }
158
- if (timelineFact === undefined
159
- && advanceSessionTimeline(reconstructed.timeline, fact.event, fact.index) !== reconstructed.timeline) {
160
- throw new TypeError('inconsistent PTC session-log projection checkpoint missing timeline facts')
161
218
  }
162
219
  const timeline = timelineFact === undefined ? reconstructed.timeline
163
- : replaySessionTimelineFact(reconstructed.timeline, timelineFact)
220
+ : replaySessionTimelineFact(reconstructed.timeline, timelineFact, value.timeline.executableCalls)
164
221
  reconstructed = applyLogEvent(reconstructed, fact.event, fact.index, timeline)
165
222
  }
166
- if (timelineCount !== timelineFacts.size || !deepEqualJson(reconstructed, value)) {
167
- throw new TypeError('inconsistent PTC session-log projection checkpoint facts')
223
+ // A compacted checkpoint cannot be compared with the fold that produced it:
224
+ // the payload it dropped is gone from its own side. Admission proves the cut
225
+ // instead (below it stubs, at or above it text), which is the weaker, stated
226
+ // claim for compacted states. Non-compacted states keep the full comparison.
227
+ const summary = retentionSummaryOf(value.timeline)
228
+ if (summary === undefined) {
229
+ if (replayComplete && !deepEqualJson(withoutFactDigests(reconstructed), withoutFactDigests(value))) {
230
+ throw new TypeError('inconsistent PTC session-log projection checkpoint facts')
231
+ }
232
+ } else {
233
+ // A compacted checkpoint dropped its old clusters, so replay can never
234
+ // reproduce it. Admission proves the summary instead: the retained window is
235
+ // contiguous with the boundary, nothing below it survives, and the chain is
236
+ // recomputed from the logged dropped range so the summary cannot certify
237
+ // itself.
238
+ // A compacted checkpoint cannot prove its chain from its own state: the fold
239
+ // that dropped the clusters also dropped the observations, and `logFacts`
240
+ // hold presentation facts only. Admission claims the boundary and range here;
241
+ // callers that hold the raw log verify the chain with
242
+ // `verifyRetentionSummaryAgainstEvents`.
243
+ validateRetentionSummary(value.timeline, summary)
168
244
  }
169
245
  return deepFreeze(value)
170
246
  } }),
@@ -207,7 +283,7 @@ export function createSessionLogOwner(ctx) {
207
283
  }
208
284
 
209
285
  /** Project session events into immutable facts consumed by prompt presentation. */
210
- export function projectSessionLog(agent, requestedEdit, projectedState) {
286
+ export function projectSessionLog(agent, requestedEdit, projectedState, options = {}) {
211
287
  const events = projectedState === undefined ? sessionEvents(agent?.session) : undefined
212
288
  if (projectedState === undefined && !Array.isArray(events)) {
213
289
  return Object.freeze({
@@ -226,7 +302,13 @@ export function projectSessionLog(agent, requestedEdit, projectedState) {
226
302
  }
227
303
  let state = projectedState ?? emptyLogState()
228
304
  if (projectedState === undefined) {
229
- for (let index = 0; index < events.length; index += 1) state = applyLogEvent(state, events[index], index)
305
+ const fold = () => {
306
+ for (let index = 0; index < events.length; index += 1) state = applyLogEvent(state, events[index], index)
307
+ }
308
+ // A consumer that needs a dropped cluster refolds the same slice with pruning
309
+ // disabled; the checkpointed state never carries the flag.
310
+ if (options.retainAll === true) withFullRetention(fold)
311
+ else fold()
230
312
  }
231
313
  const { timeline, contextStep, systemPromptSnapshots, ptcMessages, runtimeMessagesBySeq } = state
232
314
  const requestedEntry = requestedEdit === undefined
@@ -243,9 +325,10 @@ export function projectSessionLog(agent, requestedEdit, projectedState) {
243
325
  let visibleRuntimeMessages = projectedState === undefined ? undefined : state.visibleRuntimeMessages
244
326
  if (projectedState === undefined) try {
245
327
  const nodes = agent?.session?.surface?.nodes
246
- const knownSeqs = events === undefined ? undefined : new Set(events.map(event => event?.seq))
247
- if (Array.isArray(nodes) && nodes.every(seq => isCanonicalSequence(seq)
248
- && (knownSeqs === undefined ? seq < agent.session.seq : knownSeqs.has(seq)))) {
328
+ // `events` is an array here: the caller either passed a projected state or
329
+ // sessionEvents produced one, so the undefined arm could never run.
330
+ const knownSeqs = new Set(events.map(event => event?.seq))
331
+ if (Array.isArray(nodes) && nodes.every(seq => isCanonicalSequence(seq) && knownSeqs.has(seq))) {
249
332
  visibleRuntimeMessages = Object.freeze(nodes.flatMap(seq => {
250
333
  const record = runtimeMessagesBySeq[`k${seq}`]
251
334
  return record === undefined ? [] : [record]
@@ -266,6 +349,10 @@ export function projectSessionLog(agent, requestedEdit, projectedState) {
266
349
  editableRun,
267
350
  repairSource: editableRun?.source,
268
351
  cordisTranscript,
352
+ retentionSummary: timeline.retentionSummary ?? undefined,
353
+ // Consumers must be able to tell "the newest cell was dropped for budget" from
354
+ // "there never was a cell"; both would otherwise look like an undefined run.
355
+ hotCellDropped: timeline.hotCellDropped === true,
269
356
  ...(requestedEdit === undefined ? {} : { requestedEditTarget }),
270
357
  })
271
358
  }