dsh-ptc-plus 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +10 -4
- package/README.md +10 -4
- package/client.cjs +1623 -104
- package/compiler-core.cjs +132 -132
- package/compiler-core.cjs.map +3 -3
- package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +2 -0
- package/docs/adr/0017-track-the-latest-dsh-public-surface.md +4 -2
- package/docs/adr/0019-plugin-settings-and-kill-switch.md +2 -2
- package/docs/adr/0023-global-user-bindings.md +12 -0
- package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +11 -1
- package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +1 -1
- package/docs/adr/0030-serve-both-typert-codec-generations-from-one-codec.md +2 -2
- package/docs/adr/0032-own-session-log-facts-in-a-host-only-projection.md +31 -1
- package/docs/adr/0044-coexist-with-the-official-persistent-repl.md +4 -4
- package/docs/adr/0045-customize-model-visible-prompt-text.md +38 -0
- package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +35 -0
- package/docs/adr/0047-store-journal-argument-digests-in-the-projection.md +88 -0
- package/docs/adr/0048-supersede-a-rewritten-cell.md +48 -0
- package/docs/adr/0049-prove-a-settlement-against-its-published-record.md +53 -0
- package/docs/adr/0050-read-host-surfaces-through-their-published-contract.md +48 -0
- package/docs/adr/README.md +7 -1
- package/docs/architecture.md +3 -3
- package/docs/client-ui.md +5 -2
- package/docs/durability-design.md +15 -2
- package/docs/installation.md +9 -9
- package/docs/releases/v0.5.2.md +91 -0
- package/docs/releases/v0.5.3.md +77 -0
- package/docs/runtime-reference.md +43 -3
- package/docs/unmeasured-cell-recovery-repair-proposal.md +387 -0
- package/docs/user-bindings.en.md +15 -0
- package/docs/user-bindings.md +15 -0
- package/docs/v0.5.3-goals.md +117 -0
- package/docs/verification.md +7 -3
- package/index.js +100 -31
- package/internal/binding-adoption.js +136 -0
- package/internal/bounded-literal.js +85 -0
- package/internal/capability-command-receipt.js +18 -4
- package/internal/capability-delivery-proof.js +70 -0
- package/internal/capability-environment.js +200 -11
- package/internal/capability-plan.js +11 -8
- package/internal/cell-error.js +13 -0
- package/internal/config-spec.js +126 -6
- package/internal/cordis-tools-owner.js +2 -1
- package/internal/direct-surface-owner.js +39 -21
- package/internal/edit-transport-owner.js +81 -15
- package/internal/execution-provider-owner.js +6 -0
- package/internal/execution-seam-compat.js +25 -0
- package/internal/host-tool-sdk.js +308 -0
- package/internal/isolated-worker.js +134 -17
- package/internal/kernel-worker.js +167 -3
- package/internal/legacy-cell-analysis.js +14 -2
- package/internal/legacy-repl-convenience.js +16 -3
- package/internal/official-repl-priority.js +31 -10
- package/internal/program-bindings.js +12 -1
- package/internal/projection-retention.js +238 -0
- package/internal/prompt-text.js +858 -0
- package/internal/recovery-tips.js +50 -11
- package/internal/rejected-cell-editor.js +3 -2
- package/internal/repl-memory-projection.js +21 -2
- package/internal/runtime-bridge-owner.js +114 -9
- package/internal/runtime-config.js +2 -0
- package/internal/runtime-contexts.js +14 -5
- package/internal/runtime-messages.js +48 -15
- package/internal/session-cell-executor.js +132 -11
- package/internal/session-journal-recovery.js +1204 -116
- package/internal/session-journal-schema.js +10 -5
- package/internal/session-journal.js +214 -14
- package/internal/session-log-view.js +133 -26
- package/internal/session-runtime.js +746 -38
- package/internal/session-state.js +27 -0
- package/internal/stateful-root-compiler.js +11 -0
- package/internal/stateful-root-runtime.js +17 -0
- package/internal/user-binding-console.js +7 -0
- package/internal/user-bindings-owner.js +112 -6
- package/internal/user-bindings.js +2 -2
- package/internal/validated-parse-repair.js +6 -1
- package/internal/worker-client.js +27 -4
- package/package.json +16 -6
- package/src/client-activity.js +24 -4
- package/src/client-capabilities-view.js +504 -27
- package/src/client-copy.js +187 -2
- package/src/client-indicator-view.js +2 -1
- package/src/client-repl-view.js +35 -11
- package/src/client-styles.js +64 -2
- package/src/client.js +3 -1
|
@@ -11,6 +11,8 @@ 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'
|
|
15
|
+
export const SETTLEMENT_PROOF_KEY = 'dshPtcPlusSettlementProof'
|
|
14
16
|
export const IMPORT_BOUNDARY_JOURNAL_VERSION = 7
|
|
15
17
|
export const PER_NAME_USER_BINDINGS_JOURNAL_VERSION = 8
|
|
16
18
|
export const LANGUAGE_SEMANTICS_JOURNAL_VERSION = 9
|
|
@@ -35,7 +37,7 @@ export const WHOLE_ENTRY_JOURNAL_FIELDS = new Set(['version', 'bindingPolicy', '
|
|
|
35
37
|
export const PER_NAME_JOURNAL_FIELDS = new Set([...WHOLE_ENTRY_JOURNAL_FIELDS, 'userBindingsShadowPolicy', 'userBindingNames'])
|
|
36
38
|
export const LANGUAGE_SEMANTICS_JOURNAL_FIELDS = new Set([...PER_NAME_JOURNAL_FIELDS, 'languageSemantics'])
|
|
37
39
|
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'])
|
|
40
|
+
export const JOURNAL_FIELDS = new Set([...MODULE_TRANSFORM_JOURNAL_FIELDS, 'executionCwd', 'values', 'unrecordedBindings'])
|
|
39
41
|
export const FINGERPRINT_REUSE_JOURNAL_FIELDS = new Set([...WHOLE_ENTRY_JOURNAL_FIELDS].filter(field => field !== 'userBindingsReusePolicy'))
|
|
40
42
|
export const RELATIONLESS_JOURNAL_FIELDS = new Set([...FINGERPRINT_REUSE_JOURNAL_FIELDS].filter(field => field !== 'userBindingsFingerprint'))
|
|
41
43
|
export const PREDECESSOR_JOURNAL_FIELDS = new Set(['version', 'bindingMode', 'rewritePolicy', 'status', 'calls', 'operations', 'confirms', 'diagnostics', 'completion', 'volatileReason'])
|
|
@@ -52,14 +54,17 @@ export const DEFAULT_EXPORT_BINDINGS = new Set([
|
|
|
52
54
|
LEGACY_DEFAULT_EXPORT_BINDING,
|
|
53
55
|
LIVE_DEFAULT_EXPORT_BINDING,
|
|
54
56
|
])
|
|
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'])
|
|
57
|
+
export const CALL_SUCCESS_FIELDS = new Set(['global', 'member', 'args', 'argsDigest', 'argsBytes', 'ok', 'value', 'settle'])
|
|
58
|
+
export const CALL_ERROR_FIELDS = new Set(['global', 'member', 'args', 'argsDigest', 'argsBytes', 'ok', 'error', 'settle'])
|
|
59
|
+
export const OPERATION_FIELDS = new Set(['action', 'name', 'kind'])
|
|
58
60
|
export const RETURN_FIELDS = new Set(['kind', 'hasValue', 'value'])
|
|
59
61
|
export const THROW_FIELDS = new Set(['kind', 'error'])
|
|
60
62
|
export const ERROR_FIELDS = new Set(['kind', 'message'])
|
|
61
63
|
export const EDIT_TARGET_FIELDS = new Set(['targetCallSeq'])
|
|
62
|
-
export const DERIVED_RUN_FIELDS = new Set(['code', 'description'])
|
|
64
|
+
export const DERIVED_RUN_FIELDS = new Set(['code', 'codeDigest', 'codeBytes', 'description'])
|
|
65
|
+
export const DIAGNOSTIC_WORDING_FIELDS = new Set(['digest'])
|
|
66
|
+
export const SETTLEMENT_PROOF_VERSION = 1
|
|
67
|
+
export const SETTLEMENT_PROOF_FIELDS = new Set(['version', 'recordedMessage'])
|
|
63
68
|
|
|
64
69
|
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
70
|
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,10 @@ import {
|
|
|
62
63
|
JOURNAL_VERSIONS,
|
|
63
64
|
USER_BINDINGS_REUSE_POLICIES,
|
|
64
65
|
RECOVERY_BOUNDARY_FIELDS,
|
|
66
|
+
DIAGNOSTIC_WORDING_KEY,
|
|
67
|
+
SETTLEMENT_PROOF_KEY,
|
|
68
|
+
SETTLEMENT_PROOF_FIELDS,
|
|
69
|
+
SETTLEMENT_PROOF_VERSION,
|
|
65
70
|
REPL_TOOL_NAMES,
|
|
66
71
|
REWRITE_FIELDS,
|
|
67
72
|
REWRITE_KINDS,
|
|
@@ -74,11 +79,104 @@ export {
|
|
|
74
79
|
DERIVED_RUN_KEY,
|
|
75
80
|
REWRITES_KEY,
|
|
76
81
|
RECOVERY_BOUNDARY_KEY,
|
|
82
|
+
DIAGNOSTIC_WORDING_KEY,
|
|
83
|
+
DIAGNOSTIC_WORDING_FIELDS,
|
|
84
|
+
SETTLEMENT_PROOF_KEY,
|
|
85
|
+
SETTLEMENT_PROOF_FIELDS,
|
|
86
|
+
SETTLEMENT_PROOF_VERSION,
|
|
77
87
|
JOURNAL_VERSION,
|
|
78
88
|
LIVE_USER_BINDINGS_REUSE_POLICY,
|
|
79
89
|
RECOVERY_BOUNDARY_EVENT,
|
|
80
90
|
} from './session-journal-schema.js'
|
|
81
91
|
|
|
92
|
+
/**
|
|
93
|
+
* The facts one recorded diagnostic states about what happened, without the
|
|
94
|
+
* presentation it is rendered through.
|
|
95
|
+
*
|
|
96
|
+
* `help` and `source` are presentation: a historical record may carry an older
|
|
97
|
+
* generation's help wording, and a caret position is re-derived from the cell
|
|
98
|
+
* when the diagnostic is rendered. The facts below are the wording the model was
|
|
99
|
+
* told, and they must be the facts that were recorded.
|
|
100
|
+
*/
|
|
101
|
+
function diagnosticWordingFacts(diagnostic) {
|
|
102
|
+
return [
|
|
103
|
+
diagnostic.code,
|
|
104
|
+
diagnostic.severity,
|
|
105
|
+
diagnostic.phase,
|
|
106
|
+
diagnostic.message,
|
|
107
|
+
diagnostic.stateEffect,
|
|
108
|
+
diagnostic.dispatchState ?? null,
|
|
109
|
+
diagnosticWordingCause(diagnostic.cause),
|
|
110
|
+
diagnostic.collisions ?? null,
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function diagnosticWordingCause(cause) {
|
|
115
|
+
if (cause === undefined) return null
|
|
116
|
+
return cause.severity === undefined
|
|
117
|
+
? [cause.code ?? null, cause.message]
|
|
118
|
+
: diagnosticWordingFacts(cause)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Digest of the diagnostic wording a journal records, or `undefined` when it
|
|
123
|
+
* records none.
|
|
124
|
+
*
|
|
125
|
+
* Recovery re-renders a recorded diagnostic to report what the cell did, and it
|
|
126
|
+
* cannot tell an edited diagnostic from the recorded one when the record is the
|
|
127
|
+
* only evidence: the completion message and the diagnostic that produced it are
|
|
128
|
+
* two copies of one settlement. The digest of the recorded wording is taken when
|
|
129
|
+
* the settlement is published, so a record whose wording was changed afterwards
|
|
130
|
+
* is no longer evidence for the bindings its cell declared.
|
|
131
|
+
*/
|
|
132
|
+
export function diagnosticWordingDigest(journal) {
|
|
133
|
+
const diagnostics = journal?.diagnostics
|
|
134
|
+
if (!Array.isArray(diagnostics) || diagnostics.length === 0) return undefined
|
|
135
|
+
return digestOf(diagnostics.map(diagnosticWordingFacts))
|
|
136
|
+
}
|
|
137
|
+
|
|
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
|
+
},
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
82
180
|
function cloneJson(value) {
|
|
83
181
|
if (value === undefined) return undefined
|
|
84
182
|
return decodeValue(encodeValue(value))
|
|
@@ -96,7 +194,8 @@ function normalizeCalls(value) {
|
|
|
96
194
|
if (!Array.isArray(value)) throw new Error('invalid dsh-ptc-plus journal calls')
|
|
97
195
|
const calls = value.map((call, index) => {
|
|
98
196
|
if (!isRecord(call) || typeof call.global !== 'string' || typeof call.member !== 'string'
|
|
99
|
-
||
|
|
197
|
+
|| Object.hasOwn(call, 'args') === Object.hasOwn(call, 'argsDigest')
|
|
198
|
+
|| (call.ok !== true && call.ok !== false)
|
|
100
199
|
|| !isCanonicalSequence(call.settle)) {
|
|
101
200
|
throw new Error(`invalid dsh-ptc-plus journal call at index ${index}`)
|
|
102
201
|
}
|
|
@@ -107,10 +206,17 @@ function normalizeCalls(value) {
|
|
|
107
206
|
if (call.ok === false && typeof call.error !== 'string') {
|
|
108
207
|
throw new Error(`journal call at index ${index} is missing its error`)
|
|
109
208
|
}
|
|
209
|
+
const digestedArgs = Object.hasOwn(call, 'argsDigest')
|
|
210
|
+
if (digestedArgs && (typeof call.argsDigest !== 'string' || !/^[a-f0-9]{64}$/u.test(call.argsDigest)
|
|
211
|
+
|| !isCanonicalSequence(call.argsBytes))) {
|
|
212
|
+
throw new Error(`invalid digested dsh-ptc-plus journal call args at index ${index}`)
|
|
213
|
+
}
|
|
110
214
|
return {
|
|
111
215
|
global: call.global,
|
|
112
216
|
member: call.member,
|
|
113
|
-
|
|
217
|
+
...(digestedArgs
|
|
218
|
+
? { argsDigest: call.argsDigest, argsBytes: call.argsBytes }
|
|
219
|
+
: { args: normalizeValueWire(call.args) }),
|
|
114
220
|
ok: call.ok,
|
|
115
221
|
settle: call.settle,
|
|
116
222
|
...(call.ok ? { value: normalizeValueWire(call.value) } : { error: call.error }),
|
|
@@ -131,7 +237,12 @@ function normalizeOperations(value) {
|
|
|
131
237
|
throw new Error(`invalid dsh-ptc-plus journal operation at index ${index}`)
|
|
132
238
|
}
|
|
133
239
|
assertOwnFields(operation, OPERATION_FIELDS, `journal operation at index ${index}`)
|
|
134
|
-
|
|
240
|
+
if (operation.kind !== undefined
|
|
241
|
+
&& (operation.action !== 'save' || !['binding', 'checkpoint'].includes(operation.kind))) {
|
|
242
|
+
throw new Error(`invalid dsh-ptc-plus journal operation kind at index ${index}`)
|
|
243
|
+
}
|
|
244
|
+
return { action: operation.action, ...(operation.name === undefined ? {} : { name: operation.name }),
|
|
245
|
+
...(operation.kind === undefined ? {} : { kind: operation.kind }) }
|
|
135
246
|
})
|
|
136
247
|
}
|
|
137
248
|
|
|
@@ -263,7 +374,21 @@ function normalizeUserBindingsFingerprint(value) {
|
|
|
263
374
|
return value
|
|
264
375
|
}
|
|
265
376
|
|
|
377
|
+
// Fields a later journal version introduced. A record is interpreted by its own
|
|
378
|
+
// version, so a field that version could not have carried is not evidence: drop it
|
|
379
|
+
// rather than rejecting the whole journal. A field that carries its own historical
|
|
380
|
+
// contract, such as executionCwd, stays strict; only the value-carrying fields added by the recording work are trimmed, so
|
|
381
|
+
// a record written by a newer build and relabelled by a migration fixture still folds
|
|
382
|
+
// with its historical meaning.
|
|
383
|
+
const NEWER_JOURNAL_FIELDS = Object.freeze(['values', 'unrecordedBindings'])
|
|
384
|
+
function withoutNewerJournalFields(value) {
|
|
385
|
+
const trimmed = { ...value }
|
|
386
|
+
for (const field of NEWER_JOURNAL_FIELDS) delete trimmed[field]
|
|
387
|
+
return trimmed
|
|
388
|
+
}
|
|
389
|
+
|
|
266
390
|
function migrateJournal(value, resolveLegacyConfirm) {
|
|
391
|
+
value = value.version === JOURNAL_VERSION ? value : withoutNewerJournalFields(value)
|
|
267
392
|
if (value.version === JOURNAL_VERSION) return value
|
|
268
393
|
if (value.version === MODULE_TRANSFORM_JOURNAL_VERSION) {
|
|
269
394
|
assertOwnFields(value, MODULE_TRANSFORM_JOURNAL_FIELDS, 'dsh-ptc-plus journal')
|
|
@@ -358,6 +483,33 @@ function normalizeExecutionCwd(value) {
|
|
|
358
483
|
}
|
|
359
484
|
|
|
360
485
|
/** Validate and detach one journal emitted by the runtime. */
|
|
486
|
+
/**
|
|
487
|
+
* Values a volatile settlement recorded for the bindings it created.
|
|
488
|
+
*
|
|
489
|
+
* A volatile cell cannot be replayed, so the record carries the values it
|
|
490
|
+
* produced and recovery installs them without running the cell again. Names the
|
|
491
|
+
* codec could not transfer are kept apart so a contraction can name them
|
|
492
|
+
* instead of pretending they were never created.
|
|
493
|
+
*/
|
|
494
|
+
function normalizeJournalValues(values) {
|
|
495
|
+
if (values === undefined) return undefined
|
|
496
|
+
if (!Array.isArray(values)) throw new Error('invalid dsh-ptc-plus journal values')
|
|
497
|
+
return Object.freeze(values.map(entry => {
|
|
498
|
+
if (!isRecord(entry) || typeof entry.name !== 'string' || entry.name === '') {
|
|
499
|
+
throw new Error('invalid dsh-ptc-plus journal value entry')
|
|
500
|
+
}
|
|
501
|
+
return Object.freeze({ name: entry.name, wire: normalizeValueWire(entry.wire) })
|
|
502
|
+
}))
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function normalizeUnrecordedBindings(names) {
|
|
506
|
+
if (names === undefined) return undefined
|
|
507
|
+
if (!Array.isArray(names) || names.some(name => typeof name !== 'string' || name === '')) {
|
|
508
|
+
throw new Error('invalid dsh-ptc-plus journal unrecorded bindings')
|
|
509
|
+
}
|
|
510
|
+
return Object.freeze([...new Set(names)])
|
|
511
|
+
}
|
|
512
|
+
|
|
361
513
|
export function normalizeJournal(value, options = {}) {
|
|
362
514
|
if (!isRecord(value)) throw new Error('invalid dsh-ptc-plus journal')
|
|
363
515
|
if (!JOURNAL_VERSIONS.has(value.version)
|
|
@@ -387,6 +539,8 @@ export function normalizeJournal(value, options = {}) {
|
|
|
387
539
|
migrated.completion,
|
|
388
540
|
migrated.status === 'durable' || migrated.status === 'volatile',
|
|
389
541
|
)
|
|
542
|
+
const recordedValues = normalizeJournalValues(migrated.values)
|
|
543
|
+
const unrecordedBindings = normalizeUnrecordedBindings(migrated.unrecordedBindings)
|
|
390
544
|
if ((migrated.status === 'discarded' || migrated.status === 'noop')
|
|
391
545
|
&& (calls.length !== 0 || operations.length !== 0)) {
|
|
392
546
|
throw new Error(`${migrated.status} dsh-ptc-plus journal must not contain calls or operations`)
|
|
@@ -416,11 +570,19 @@ export function normalizeJournal(value, options = {}) {
|
|
|
416
570
|
diagnostics: Object.freeze(diagnostics),
|
|
417
571
|
...(completion === undefined ? {} : { completion }),
|
|
418
572
|
...(migrated.volatileReason === undefined ? {} : { volatileReason: migrated.volatileReason }),
|
|
573
|
+
...(recordedValues === undefined ? {} : { values: recordedValues }),
|
|
574
|
+
...(unrecordedBindings === undefined ? {} : { unrecordedBindings }),
|
|
419
575
|
})
|
|
420
576
|
}
|
|
421
577
|
|
|
422
|
-
/**
|
|
423
|
-
|
|
578
|
+
/**
|
|
579
|
+
* Completion error kind of a cell that never reached the runtime because it could
|
|
580
|
+
* not be measured. Such a settlement records that nothing ran, so it can prove an
|
|
581
|
+
* accepted edit's dispatch relation without proving any executed state.
|
|
582
|
+
*/
|
|
583
|
+
export const UNMEASURED_CELL_ERROR_KIND = 'budget-unavailable'
|
|
584
|
+
|
|
585
|
+
function normalizeDerivedRelation(meta, expectedTargetCallSeq, { allowUnexecuted }) {
|
|
424
586
|
if (!isCanonicalSequence(expectedTargetCallSeq)) {
|
|
425
587
|
throw new Error('derived edit does not identify an eligible target call')
|
|
426
588
|
}
|
|
@@ -433,24 +595,61 @@ export function normalizeDerivedEditResult(meta, expectedTargetCallSeq) {
|
|
|
433
595
|
|| target.targetCallSeq !== expectedTargetCallSeq) {
|
|
434
596
|
throw new Error('derived edit target does not match the eligible target call')
|
|
435
597
|
}
|
|
436
|
-
|
|
598
|
+
// A derived run outside the payload window keeps the digest of its source instead
|
|
599
|
+
// of the text. Every field except the source is kept, so the deferred-edit
|
|
600
|
+
// relation stays checkable in both shapes; the text is refolded from the log when
|
|
601
|
+
// a consumer needs it.
|
|
602
|
+
const digestedCode = isRecord(derived) && !Object.hasOwn(derived, 'code')
|
|
603
|
+
if (!isRecord(derived) || typeof derived.description !== 'string'
|
|
604
|
+
|| (digestedCode
|
|
605
|
+
? typeof derived.codeDigest !== 'string' || !/^[a-f0-9]{64}$/u.test(derived.codeDigest)
|
|
606
|
+
|| !isCanonicalSequence(derived.codeBytes)
|
|
607
|
+
: typeof derived.code !== 'string')) {
|
|
437
608
|
throw new Error('invalid dsh-ptc-plus derived run metadata')
|
|
438
609
|
}
|
|
439
610
|
assertOwnFields(derived, DERIVED_RUN_FIELDS, 'derived run metadata')
|
|
440
611
|
const journal = normalizeJournal(meta[JOURNAL_KEY])
|
|
441
|
-
|
|
612
|
+
// Only the current deferred edit relation may prove itself with a settlement
|
|
613
|
+
// that never ran, and only for the unmeasured route: a rejected source, an
|
|
614
|
+
// aborted cell or a lost helper are different facts and keep the historical
|
|
615
|
+
// rule that a derived edit journal must record an execution.
|
|
616
|
+
const unexecuted = journal.status === 'noop'
|
|
617
|
+
&& journal.completion?.error?.kind === UNMEASURED_CELL_ERROR_KIND
|
|
618
|
+
if (journal.status === 'noop' && !(allowUnexecuted && unexecuted)) {
|
|
619
|
+
throw new Error('derived edit journal must not be noop')
|
|
620
|
+
}
|
|
442
621
|
const recoveryBoundaries = meta[RECOVERY_BOUNDARY_KEY] === undefined
|
|
443
622
|
? undefined
|
|
444
623
|
: normalizeRecoveryBoundaries(meta[RECOVERY_BOUNDARY_KEY])
|
|
445
624
|
const userBindings = userBindingsForJournal(meta, journal)
|
|
446
|
-
return
|
|
625
|
+
return {
|
|
447
626
|
targetCallSeq: target.targetCallSeq,
|
|
448
|
-
|
|
627
|
+
...(digestedCode
|
|
628
|
+
? { codeDigest: derived.codeDigest, codeBytes: derived.codeBytes }
|
|
629
|
+
: { code: derived.code }),
|
|
449
630
|
description: derived.description,
|
|
450
631
|
journal,
|
|
632
|
+
...(unexecuted ? { unexecuted: true } : {}),
|
|
451
633
|
...(recoveryBoundaries === undefined ? {} : { recoveryBoundaries }),
|
|
452
634
|
...(userBindings === undefined ? {} : { userBindings }),
|
|
453
|
-
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Validate the required persisted relation that makes one edit result executable history. */
|
|
639
|
+
export function normalizeDerivedEditResult(meta, expectedTargetCallSeq) {
|
|
640
|
+
return Object.freeze(normalizeDerivedRelation(meta, expectedTargetCallSeq, { allowUnexecuted: false }))
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Validate the relation one accepted deferred edit records for itself.
|
|
645
|
+
*
|
|
646
|
+
* The current deferred relation additionally admits a settlement that proves the
|
|
647
|
+
* revision never ran, because the root cell's own execution stays provable then.
|
|
648
|
+
* The historical top-level editor relation does not: it keeps requiring an
|
|
649
|
+
* executed derived run.
|
|
650
|
+
*/
|
|
651
|
+
export function normalizeDeferredEditSettlement(meta, expectedTargetCallSeq) {
|
|
652
|
+
return Object.freeze(normalizeDerivedRelation(meta, expectedTargetCallSeq, { allowUnexecuted: true }))
|
|
454
653
|
}
|
|
455
654
|
|
|
456
655
|
export function userBindingsForJournal(meta, journal) {
|
|
@@ -492,8 +691,8 @@ function validateUserBindingNameProviders(journal, snapshot) {
|
|
|
492
691
|
/** Compare only the required persisted relation for one derived edit. */
|
|
493
692
|
export function derivedEditResultsEqual(leftMeta, rightMeta, expectedTargetCallSeq) {
|
|
494
693
|
try {
|
|
495
|
-
const left = encodeValue(
|
|
496
|
-
const right = encodeValue(
|
|
694
|
+
const left = encodeValue(normalizeDeferredEditSettlement(leftMeta, expectedTargetCallSeq))
|
|
695
|
+
const right = encodeValue(normalizeDeferredEditSettlement(rightMeta, expectedTargetCallSeq))
|
|
497
696
|
return JSON.stringify(left) === JSON.stringify(right)
|
|
498
697
|
} catch {
|
|
499
698
|
return false
|
|
@@ -1108,8 +1307,9 @@ export function reduceStateOperations({ nodes, head, checkpoints }, operations,
|
|
|
1108
1307
|
/** Merge the journal into the tool result's private metadata. */
|
|
1109
1308
|
export function withJournal(meta, journal) {
|
|
1110
1309
|
const base = isRecord(meta) ? { ...meta } : meta === undefined ? {} : { value: cloneJson(meta) }
|
|
1111
|
-
|
|
1112
|
-
|
|
1310
|
+
const normalized = normalizeJournal(journal)
|
|
1311
|
+
base[JOURNAL_KEY] = normalized
|
|
1312
|
+
return withSettlementProof(base, normalized)
|
|
1113
1313
|
}
|
|
1114
1314
|
|
|
1115
1315
|
/** Validate and detach one rewrite record emitted by the runtime. */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
-
advanceSessionTimeline, createSessionTimelineState, replaySessionTimelineFact,
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
140
|
-
|
|
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
|
|
153
|
-
if (
|
|
154
|
-
throw new TypeError('inconsistent PTC session-log projection checkpoint
|
|
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
|
-
|
|
167
|
-
|
|
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
|
} }),
|
|
@@ -173,6 +249,24 @@ export function createSessionLogProjection() {
|
|
|
173
249
|
})
|
|
174
250
|
}
|
|
175
251
|
|
|
252
|
+
/**
|
|
253
|
+
* The disposer a session projection registration handed back.
|
|
254
|
+
*
|
|
255
|
+
* Two registration contracts are published: an earlier host returns a disposer and a
|
|
256
|
+
* newer one returns a `ProjectionHandle` (`stateOf`/`readerFor`/`dispose`, possibly
|
|
257
|
+
* async). Every projection this plugin registers goes through this reader, because a
|
|
258
|
+
* registration that only understands the earlier shape drops the projection — and
|
|
259
|
+
* with it the fact that the host accepted it — on the newer contract.
|
|
260
|
+
* @param {unknown} registration - what `sessionProjections.register` returned.
|
|
261
|
+
* @returns {(() => unknown)|undefined} the release call, or undefined when the answer
|
|
262
|
+
* names neither contract.
|
|
263
|
+
*/
|
|
264
|
+
export function projectionRegistrationDisposer(registration) {
|
|
265
|
+
if (typeof registration === 'function') return () => registration()
|
|
266
|
+
if (typeof registration?.dispose === 'function') return () => registration.dispose()
|
|
267
|
+
return undefined
|
|
268
|
+
}
|
|
269
|
+
|
|
176
270
|
export function createSessionLogOwner(ctx) {
|
|
177
271
|
const projection = createSessionLogProjection()
|
|
178
272
|
let registeredService
|
|
@@ -180,8 +274,10 @@ export function createSessionLogOwner(ctx) {
|
|
|
180
274
|
try {
|
|
181
275
|
scope.effect(() => {
|
|
182
276
|
const registry = scope.sessionProjections
|
|
183
|
-
const unregister = registry.register(projection)
|
|
184
|
-
if (
|
|
277
|
+
const unregister = projectionRegistrationDisposer(registry.register(projection))
|
|
278
|
+
if (unregister === undefined) {
|
|
279
|
+
throw new Error('ptc-plus: sessionProjections.register returned neither a disposer nor a handle')
|
|
280
|
+
}
|
|
185
281
|
registeredService = registry
|
|
186
282
|
return async () => {
|
|
187
283
|
await unregister()
|
|
@@ -207,7 +303,7 @@ export function createSessionLogOwner(ctx) {
|
|
|
207
303
|
}
|
|
208
304
|
|
|
209
305
|
/** Project session events into immutable facts consumed by prompt presentation. */
|
|
210
|
-
export function projectSessionLog(agent, requestedEdit, projectedState) {
|
|
306
|
+
export function projectSessionLog(agent, requestedEdit, projectedState, options = {}) {
|
|
211
307
|
const events = projectedState === undefined ? sessionEvents(agent?.session) : undefined
|
|
212
308
|
if (projectedState === undefined && !Array.isArray(events)) {
|
|
213
309
|
return Object.freeze({
|
|
@@ -226,7 +322,13 @@ export function projectSessionLog(agent, requestedEdit, projectedState) {
|
|
|
226
322
|
}
|
|
227
323
|
let state = projectedState ?? emptyLogState()
|
|
228
324
|
if (projectedState === undefined) {
|
|
229
|
-
|
|
325
|
+
const fold = () => {
|
|
326
|
+
for (let index = 0; index < events.length; index += 1) state = applyLogEvent(state, events[index], index)
|
|
327
|
+
}
|
|
328
|
+
// A consumer that needs a dropped cluster refolds the same slice with pruning
|
|
329
|
+
// disabled; the checkpointed state never carries the flag.
|
|
330
|
+
if (options.retainAll === true) withFullRetention(fold)
|
|
331
|
+
else fold()
|
|
230
332
|
}
|
|
231
333
|
const { timeline, contextStep, systemPromptSnapshots, ptcMessages, runtimeMessagesBySeq } = state
|
|
232
334
|
const requestedEntry = requestedEdit === undefined
|
|
@@ -243,9 +345,10 @@ export function projectSessionLog(agent, requestedEdit, projectedState) {
|
|
|
243
345
|
let visibleRuntimeMessages = projectedState === undefined ? undefined : state.visibleRuntimeMessages
|
|
244
346
|
if (projectedState === undefined) try {
|
|
245
347
|
const nodes = agent?.session?.surface?.nodes
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
348
|
+
// `events` is an array here: the caller either passed a projected state or
|
|
349
|
+
// sessionEvents produced one, so the undefined arm could never run.
|
|
350
|
+
const knownSeqs = new Set(events.map(event => event?.seq))
|
|
351
|
+
if (Array.isArray(nodes) && nodes.every(seq => isCanonicalSequence(seq) && knownSeqs.has(seq))) {
|
|
249
352
|
visibleRuntimeMessages = Object.freeze(nodes.flatMap(seq => {
|
|
250
353
|
const record = runtimeMessagesBySeq[`k${seq}`]
|
|
251
354
|
return record === undefined ? [] : [record]
|
|
@@ -266,6 +369,10 @@ export function projectSessionLog(agent, requestedEdit, projectedState) {
|
|
|
266
369
|
editableRun,
|
|
267
370
|
repairSource: editableRun?.source,
|
|
268
371
|
cordisTranscript,
|
|
372
|
+
retentionSummary: timeline.retentionSummary ?? undefined,
|
|
373
|
+
// Consumers must be able to tell "the newest cell was dropped for budget" from
|
|
374
|
+
// "there never was a cell"; both would otherwise look like an undefined run.
|
|
375
|
+
hotCellDropped: timeline.hotCellDropped === true,
|
|
269
376
|
...(requestedEdit === undefined ? {} : { requestedEditTarget }),
|
|
270
377
|
})
|
|
271
378
|
}
|