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.
- package/README.en.md +7 -3
- package/README.md +7 -3
- package/client.cjs +1467 -84
- 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/0027-run-the-session-kernel-in-a-killable-helper-process.md +11 -1
- package/docs/adr/0032-own-session-log-facts-in-a-host-only-projection.md +31 -1
- 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/architecture.md +2 -2
- package/docs/client-ui.md +5 -2
- package/docs/durability-design.md +7 -1
- package/docs/releases/v0.5.2.md +79 -0
- package/docs/runtime-reference.md +36 -2
- package/docs/unmeasured-cell-recovery-repair-proposal.md +387 -0
- package/docs/verification.md +6 -2
- package/index.js +36 -15
- package/internal/bounded-literal.js +85 -0
- package/internal/capability-environment.js +162 -10
- package/internal/capability-plan.js +11 -8
- package/internal/cell-error.js +13 -0
- package/internal/config-spec.js +113 -2
- package/internal/direct-surface-owner.js +14 -10
- package/internal/edit-transport-owner.js +79 -14
- 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/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/runtime-bridge-owner.js +1 -1
- package/internal/runtime-config.js +2 -0
- package/internal/runtime-contexts.js +14 -5
- package/internal/runtime-messages.js +43 -15
- package/internal/session-cell-executor.js +132 -11
- package/internal/session-journal-recovery.js +1009 -99
- package/internal/session-journal-schema.js +7 -5
- package/internal/session-journal.js +177 -14
- package/internal/session-log-view.js +111 -24
- package/internal/session-runtime.js +674 -33
- 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 +3 -0
- 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 +3 -1
- package/src/client-activity.js +24 -4
- package/src/client-capabilities-view.js +459 -24
- package/src/client-copy.js +163 -2
- package/src/client-repl-view.js +6 -1
- package/src/client-styles.js +64 -2
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
JOURNAL_KEY,
|
|
8
8
|
RECOVERY_BOUNDARY_KEY,
|
|
9
9
|
RECOVERY_BOUNDARY_EVENT,
|
|
10
|
+
DIAGNOSTIC_WORDING_KEY,
|
|
11
|
+
DIAGNOSTIC_WORDING_FIELDS,
|
|
10
12
|
LEGACY_JOURNAL_VERSION,
|
|
11
13
|
REPL_TOOL_NAMES,
|
|
12
14
|
REWRITES_KEY,
|
|
@@ -15,8 +17,9 @@ import {
|
|
|
15
17
|
POST_EDIT_KEY,
|
|
16
18
|
} from './session-journal-schema.js'
|
|
17
19
|
import {
|
|
20
|
+
diagnosticWordingDigest,
|
|
18
21
|
isCanonicalSequence,
|
|
19
|
-
|
|
22
|
+
normalizeDeferredEditSettlement,
|
|
20
23
|
normalizeJournal,
|
|
21
24
|
normalizeRecoveryBoundaries,
|
|
22
25
|
reduceStateOperations,
|
|
@@ -25,6 +28,11 @@ import {
|
|
|
25
28
|
} from './session-journal.js'
|
|
26
29
|
import { decodeValue } from './value-wire.js'
|
|
27
30
|
import { editRejectedCell } from './rejected-cell-editor.js'
|
|
31
|
+
import { renderDiagnostic } from './diagnostic.js'
|
|
32
|
+
import {
|
|
33
|
+
byteLength, chainOfEntries, digestOf, extendSummary, retainedClusterCount, retentionLimits, settlementBytes,
|
|
34
|
+
summaryEntry, summaryRecordOf,
|
|
35
|
+
} from './projection-retention.js'
|
|
28
36
|
|
|
29
37
|
const RECOVERY_BOUNDARY_EVIDENCE = Symbol('recoveryBoundaryEvidence')
|
|
30
38
|
const RUN_SELECTION_CLEAR_REASONS = new Set([
|
|
@@ -39,9 +47,26 @@ const MODEL_VISIBLE_SURFACE_EVENTS = new Set([
|
|
|
39
47
|
'tool/result',
|
|
40
48
|
])
|
|
41
49
|
|
|
42
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Argument text for a call, resolved through its canonical owner.
|
|
52
|
+
*
|
|
53
|
+
* `executableCalls` retains the text once; a call event that still carries its
|
|
54
|
+
* own copy (the live fold, or an uncompacted checkpoint) is read directly. A
|
|
55
|
+
* compacted event and a pruned entry hold only digests, so derivations see
|
|
56
|
+
* `undefined` and must treat the run as unavailable rather than guess.
|
|
57
|
+
*/
|
|
58
|
+
function callArguments(call, executableCalls) {
|
|
59
|
+
if (typeof call?.data?.arguments === 'string') return call.data.arguments
|
|
60
|
+
const entry = executableCalls?.get?.(call?.seq)
|
|
61
|
+
const stored = entry?.event?.data?.arguments
|
|
62
|
+
return typeof stored === 'string' ? stored : undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function sourceForRunCall(call, executableCalls) {
|
|
66
|
+
const text = callArguments(call, executableCalls)
|
|
67
|
+
if (text === undefined) return undefined
|
|
43
68
|
try {
|
|
44
|
-
const args = JSON.parse(
|
|
69
|
+
const args = JSON.parse(text)
|
|
45
70
|
return isRecord(args) && typeof args.code === 'string' ? args.code : undefined
|
|
46
71
|
} catch {
|
|
47
72
|
return undefined
|
|
@@ -68,8 +93,50 @@ export function isConfirmableNoop(timeline, callSeq, resultEventIndex) {
|
|
|
68
93
|
return call !== undefined && call.eventIndex < resultEventIndex && !timeline.results.has(callSeq)
|
|
69
94
|
}
|
|
70
95
|
|
|
96
|
+
/** Node indices reachable from a head by following parents. */
|
|
97
|
+
function reachableFrom(nodes, head) {
|
|
98
|
+
const reachable = new Set()
|
|
99
|
+
for (let cursor = head; cursor !== undefined && !reachable.has(cursor);) {
|
|
100
|
+
reachable.add(cursor)
|
|
101
|
+
cursor = nodes[cursor]?.parent
|
|
102
|
+
}
|
|
103
|
+
return reachable
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Binding names a node recorded.
|
|
108
|
+
*
|
|
109
|
+
* The recorded values are the only evidence that names a durable binding today;
|
|
110
|
+
* `unrecordedBindings` names the ones the codec could not transfer, and a
|
|
111
|
+
* `kind: "binding"` save names one explicitly. A checkpoint save never counts.
|
|
112
|
+
*/
|
|
113
|
+
function recordedBindingNames(node) {
|
|
114
|
+
const journal = node?.journal
|
|
115
|
+
if (journal === undefined) return []
|
|
116
|
+
return [...new Set([
|
|
117
|
+
...(journal.values ?? []).map(value => value.name),
|
|
118
|
+
...(journal.unrecordedBindings ?? []),
|
|
119
|
+
...bindingNamesOfOperations(journal.operations),
|
|
120
|
+
].filter(name => typeof name === 'string'))]
|
|
121
|
+
}
|
|
122
|
+
|
|
71
123
|
function applyOperations(state, operations, nodeIndex) {
|
|
124
|
+
const previousHead = state.head
|
|
72
125
|
const transition = reduceStateOperations(state, operations, nodeIndex)
|
|
126
|
+
if (transition.restored) {
|
|
127
|
+
const reachableBefore = reachableFrom(state.nodes, previousHead)
|
|
128
|
+
const reachableAfter = reachableFrom(state.nodes, transition.head)
|
|
129
|
+
for (const index of reachableBefore) {
|
|
130
|
+
if (reachableAfter.has(index)) continue
|
|
131
|
+
for (const name of recordedBindingNames(state.nodes[index])) state.deletedBindings.add(name)
|
|
132
|
+
}
|
|
133
|
+
// The cutting node is not reachable from the new head either: the rewind it
|
|
134
|
+
// performed ends whatever that same cell created before the cut, so its own
|
|
135
|
+
// names belong to the recorded deletion rather than to an omission.
|
|
136
|
+
if (nodeIndex !== undefined) {
|
|
137
|
+
for (const name of recordedBindingNames(state.nodes[nodeIndex])) state.deletedBindings.add(name)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
73
140
|
state.head = transition.head
|
|
74
141
|
state.checkpoints = transition.checkpoints
|
|
75
142
|
if (transition.restored) {
|
|
@@ -78,21 +145,97 @@ function applyOperations(state, operations, nodeIndex) {
|
|
|
78
145
|
}
|
|
79
146
|
}
|
|
80
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Binding names a recorded journal operation list saves.
|
|
150
|
+
*
|
|
151
|
+
* Only a save with `kind: "binding"` creates a binding. `repl.state({ action:
|
|
152
|
+
* "save" })` records a checkpoint name in the same `save` shape, which is why an
|
|
153
|
+
* untagged save is a checkpoint: every operation recorded before the kind existed
|
|
154
|
+
* came from that control call. Treating those names as bindings made a contraction
|
|
155
|
+
* report a branch name as a dropped variable.
|
|
156
|
+
*/
|
|
157
|
+
export function bindingNamesOfOperations(operations) {
|
|
158
|
+
const names = new Set()
|
|
159
|
+
for (const operation of operations ?? []) {
|
|
160
|
+
if (operation.action === 'save' && operation.kind === 'binding' && typeof operation.name === 'string') {
|
|
161
|
+
names.add(operation.name)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return [...names]
|
|
165
|
+
}
|
|
166
|
+
|
|
81
167
|
function applyRecord(state, record, invalidCallSeqs) {
|
|
82
168
|
const { call, code, result } = record
|
|
169
|
+
// Every binding a journal names is a binding the session log proves. Recording
|
|
170
|
+
// the names here lets recovery report a binding it could not install instead of
|
|
171
|
+
// letting an unreplayed node take it silently.
|
|
172
|
+
// Only a volatile record proves a binding today: it cannot be replayed, so its
|
|
173
|
+
// recorded values are the entire evidence. A durable settlement records values too,
|
|
174
|
+
// for the restore path that installs nodes the replay does not reach, but its
|
|
175
|
+
// bindings are still proved by replaying it; counting them here would report a
|
|
176
|
+
// binding as dropped in every contraction whose replay is supposed to be complete.
|
|
177
|
+
// A released live environment proves nothing durable either: its live-only values
|
|
178
|
+
// are contracted below instead of being reported as a binding recovery owed.
|
|
179
|
+
const recordNames = [
|
|
180
|
+
...(result?.journal?.status === 'volatile' && state.liveOnlyLost !== true
|
|
181
|
+
? (result.journal.values ?? []).map(value => value.name)
|
|
182
|
+
: []),
|
|
183
|
+
...bindingNamesOfOperations(result?.journal?.operations),
|
|
184
|
+
]
|
|
185
|
+
for (const name of recordNames) state.provenBindings.add(name)
|
|
83
186
|
if (invalidCallSeqs.has(call.seq)) return
|
|
84
187
|
const journal = result.journal
|
|
85
188
|
if (journal.status === 'noop') return
|
|
86
189
|
if (journal.status === 'discarded') {
|
|
87
190
|
if (journal.volatileReason !== undefined) {
|
|
88
191
|
state.trusted = false
|
|
89
|
-
|
|
192
|
+
const discardedBindings = bindingNamesOfOperations(journal.operations)
|
|
193
|
+
state.volatileSuffix.push({ seq: call.seq, code, reason: journal.volatileReason,
|
|
194
|
+
...(discardedBindings.length === 0 ? {} : { bindings: discardedBindings }) })
|
|
90
195
|
}
|
|
91
196
|
return
|
|
92
197
|
}
|
|
93
198
|
if (journal.status === 'volatile') {
|
|
94
199
|
state.trusted = false
|
|
95
|
-
|
|
200
|
+
// A volatile settlement drops its `save` operations, so the names it created
|
|
201
|
+
// survive only as the bindings the record could not transfer.
|
|
202
|
+
const volatileBindings = [...new Set([...bindingNamesOfOperations(journal.operations),
|
|
203
|
+
...(journal.unrecordedBindings ?? [])])]
|
|
204
|
+
// A volatile cell that recorded its values keeps its place in the durable
|
|
205
|
+
// frontier: recovery installs those values instead of replaying the cell, so
|
|
206
|
+
// the completion is unverified while the bindings survive. A volatile cell
|
|
207
|
+
// whose values the codec could not transfer has nothing to install and is
|
|
208
|
+
// reported as a contraction instead of vanishing silently.
|
|
209
|
+
//
|
|
210
|
+
// Those values are the live environment's own observation, so a fold that
|
|
211
|
+
// knows the environment was released (`liveOnlyLost`) cannot install them:
|
|
212
|
+
// the owner that read `Date.now()` or `process.pid` is gone, and the next
|
|
213
|
+
// environment inherits the durable computation, not the live cache.
|
|
214
|
+
if (!state.liveOnlyLost && journal.values !== undefined && journal.values.length > 0) {
|
|
215
|
+
const node = Object.freeze({
|
|
216
|
+
code,
|
|
217
|
+
journal,
|
|
218
|
+
callSeq: call.seq,
|
|
219
|
+
parent: state.head,
|
|
220
|
+
restoredFromRecord: true,
|
|
221
|
+
...(result.userBindings === undefined ? {} : { userBindings: result.userBindings }),
|
|
222
|
+
})
|
|
223
|
+
const index = state.nodes.push(node) - 1
|
|
224
|
+
state.head = index
|
|
225
|
+
applyOperations(state, journal.operations, index)
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
// A released live environment is reported as a contracted cell: the model
|
|
229
|
+
// loses whatever the live cache held, and naming those bindings would claim
|
|
230
|
+
// they were proved by the durable frontier when they never were.
|
|
231
|
+
state.volatileSuffix.push({
|
|
232
|
+
seq: call.seq,
|
|
233
|
+
code,
|
|
234
|
+
reason: state.liveOnlyLost === true
|
|
235
|
+
? 'the live environment that observed this cell was released'
|
|
236
|
+
: journal.volatileReason ?? 'volatile cell',
|
|
237
|
+
...(volatileBindings.length === 0 || state.liveOnlyLost === true ? {} : { bindings: volatileBindings }),
|
|
238
|
+
})
|
|
96
239
|
applyOperations(state, journal.operations, undefined)
|
|
97
240
|
return
|
|
98
241
|
}
|
|
@@ -132,8 +275,11 @@ function foldRecords(records, invalidCallSeqs, options = {}) {
|
|
|
132
275
|
head: undefined,
|
|
133
276
|
checkpoints: new Map(),
|
|
134
277
|
volatileSuffix: [],
|
|
278
|
+
provenBindings: new Set(),
|
|
279
|
+
deletedBindings: new Set(),
|
|
135
280
|
trusted: true,
|
|
136
281
|
surfaceContracted: options.surfaceContracted === true,
|
|
282
|
+
liveOnlyLost: options.liveOnlyLost === true,
|
|
137
283
|
}
|
|
138
284
|
for (const record of records) {
|
|
139
285
|
applyRecord(state, record, invalidCallSeqs)
|
|
@@ -224,22 +370,29 @@ export function visibleExecutableCallSeqs(session) {
|
|
|
224
370
|
if (visibleAssistantCallSeqs.has(event.seq)) calls.add(event.seq)
|
|
225
371
|
}
|
|
226
372
|
const timeline = foldSessionTimeline(events)
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
373
|
+
if (process.env.DEBUG_RECOVER) console.error('DEBUG events', events.length, 'results', timeline.results.size, 'calls', timelineCalls(timeline).length, 'exec', timeline.executableCalls.size)
|
|
374
|
+
for (const seq of timeline.calls) {
|
|
375
|
+
const post = timeline.results.get(seq)?.postEdit
|
|
376
|
+
// A derived execution that never ran was never published as a cell the model
|
|
377
|
+
// can see, so it is not model-visible executable history.
|
|
378
|
+
if (post !== undefined && post.derived?.unexecuted !== true
|
|
379
|
+
&& calls.has(seq) && calls.has(post.targetCallSeq)) {
|
|
230
380
|
calls.add(post.executionStartSeq)
|
|
231
381
|
}
|
|
232
382
|
}
|
|
233
383
|
return calls
|
|
234
384
|
}
|
|
235
385
|
|
|
236
|
-
function timelineRun(call, result, eventIndex, journal) {
|
|
386
|
+
function timelineRun(call, result, eventIndex, journal, executableCalls) {
|
|
237
387
|
let args
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
388
|
+
const text = callArguments(call, executableCalls)
|
|
389
|
+
if (text !== undefined) {
|
|
390
|
+
try {
|
|
391
|
+
const parsed = JSON.parse(text)
|
|
392
|
+
if (isRecord(parsed)) args = Object.freeze(parsed)
|
|
393
|
+
} catch {
|
|
394
|
+
// Invalid persisted arguments cannot support derived source facts.
|
|
395
|
+
}
|
|
243
396
|
}
|
|
244
397
|
return Object.freeze({
|
|
245
398
|
index: eventIndex,
|
|
@@ -263,27 +416,35 @@ function timelineDerivedRun(call, result, eventIndex, derived) {
|
|
|
263
416
|
}
|
|
264
417
|
|
|
265
418
|
function successfulTimelineRun(run) {
|
|
266
|
-
|
|
419
|
+
// A pruned settlement yields no run, so it cannot certify success either.
|
|
420
|
+
return run?.journal?.completion?.kind === 'return' && run.journal.status !== 'noop'
|
|
267
421
|
}
|
|
268
422
|
|
|
269
|
-
function settlementRun(settlement) {
|
|
423
|
+
function settlementRun(settlement, executableCalls) {
|
|
270
424
|
const publication = settlement.publication
|
|
271
425
|
if (publication === undefined) return undefined
|
|
272
426
|
const result = { data: { meta: { [REWRITES_KEY]: publication.postEditRewrites ?? publication.rewrites } } }
|
|
427
|
+
// A revision that never ran publishes no source of its own, so the settlement
|
|
428
|
+
// keeps its own call's source: the root cell stays the editable target and the
|
|
429
|
+
// unexecuted revision never becomes one.
|
|
430
|
+
if (publication.postEdit?.derived?.unexecuted === true || publication.derived?.unexecuted === true) {
|
|
431
|
+
return timelineJson(timelineRun(settlement.call, result, publication.index, publication.journal, executableCalls))
|
|
432
|
+
}
|
|
273
433
|
if (publication.postEdit !== undefined) {
|
|
274
434
|
return timelineJson(timelineDerivedRun({ seq: publication.postEdit.executionStartSeq }, result,
|
|
275
435
|
publication.index, publication.postEdit.derived))
|
|
276
436
|
}
|
|
277
437
|
return timelineJson(publication.derived === undefined
|
|
278
|
-
? timelineRun(settlement.call, result, publication.index, publication.journal)
|
|
438
|
+
? timelineRun(settlement.call, result, publication.index, publication.journal, executableCalls)
|
|
279
439
|
: timelineDerivedRun(settlement.call, result, publication.index, publication.derived))
|
|
280
440
|
}
|
|
281
441
|
|
|
282
|
-
function selectSettlementRun(selected, settlement, scope, frontier) {
|
|
442
|
+
function selectSettlementRun(selected, settlement, scope, frontier, executableCalls) {
|
|
283
443
|
const publication = settlement.publication
|
|
284
444
|
if (publication === undefined || settlement.entry.scope !== scope
|
|
285
445
|
|| (frontier !== null && publication.index <= frontier.index)) return selected
|
|
286
|
-
return selected === undefined || publication.index >= selected.index
|
|
446
|
+
return selected === undefined || publication.index >= selected.index
|
|
447
|
+
? settlementRun(settlement, executableCalls) : selected
|
|
287
448
|
}
|
|
288
449
|
|
|
289
450
|
function editTargetForSelection(run, claimedTargets) {
|
|
@@ -416,6 +577,28 @@ function republishedTimelineResult(result, event) {
|
|
|
416
577
|
return { ...result, eventSeq: event.seq, positionSeq: result.positionSeq ?? result.eventSeq }
|
|
417
578
|
}
|
|
418
579
|
|
|
580
|
+
/**
|
|
581
|
+
* Fact-shaped data for a `tool/call`: the arguments are represented by their
|
|
582
|
+
* digest pair so a stored event fact never carries the text. The text itself is
|
|
583
|
+
* retained once, in `timelineCallEvent` inside `executableCalls`. A fact that is
|
|
584
|
+
* already digested passes through unchanged, which keeps re-deriving a fact from
|
|
585
|
+
* a compacted observation byte-identical.
|
|
586
|
+
*/
|
|
587
|
+
function callFactData(event) {
|
|
588
|
+
const data = event?.data ?? {}
|
|
589
|
+
const value = data.arguments
|
|
590
|
+
const digested = value === undefined && typeof data.argumentsDigest === 'string'
|
|
591
|
+
? { argumentsDigest: data.argumentsDigest, argumentsBytes: data.argumentsBytes }
|
|
592
|
+
: typeof value === 'string'
|
|
593
|
+
? { argumentsDigest: digestOf(value), argumentsBytes: byteLength(value) }
|
|
594
|
+
: {}
|
|
595
|
+
return {
|
|
596
|
+
callId: data.callId,
|
|
597
|
+
...(typeof data.name === 'string' ? { name: data.name } : {}),
|
|
598
|
+
...digested,
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
419
602
|
function correlationEvent(event) {
|
|
420
603
|
return timelineJson({ seq: event.seq, type: event.type,
|
|
421
604
|
...(['tool/ptc-dispatch-start', 'tool/ptc-dispatch'].includes(event.type) ? { data: {
|
|
@@ -423,7 +606,12 @@ function correlationEvent(event) {
|
|
|
423
606
|
parentCallId: event.data?.parentCallId,
|
|
424
607
|
subCallId: event.data?.subCallId,
|
|
425
608
|
name: event.data?.name,
|
|
426
|
-
|
|
609
|
+
// A compacted dispatch keeps its digest pair, so re-deriving this fact from a
|
|
610
|
+
// pruned observation reproduces exactly what was stored.
|
|
611
|
+
...(event.data?.arguments === undefined
|
|
612
|
+
? (event.data?.argumentsDigest === undefined ? {}
|
|
613
|
+
: { argumentsDigest: event.data.argumentsDigest, argumentsBytes: event.data.argumentsBytes })
|
|
614
|
+
: { arguments: event.data.arguments }),
|
|
427
615
|
...(event.type === 'tool/ptc-dispatch' ? {
|
|
428
616
|
isError: event.data?.isError,
|
|
429
617
|
...(event.data?.name === 'run_code' && event.data.meta?.[JOURNAL_KEY] !== undefined
|
|
@@ -431,7 +619,7 @@ function correlationEvent(event) {
|
|
|
431
619
|
} : {}),
|
|
432
620
|
} } : {}),
|
|
433
621
|
...(event.type === 'tool/call' && typeof event.data?.callId === 'string'
|
|
434
|
-
? { data:
|
|
622
|
+
? { data: callFactData(event) } : {}),
|
|
435
623
|
...(event.type === 'tool/result' ? { data: { message: { source: {
|
|
436
624
|
...(typeof event.data?.message?.source?.callId === 'string'
|
|
437
625
|
? { callId: event.data.message.source.callId } : {}),
|
|
@@ -501,13 +689,245 @@ function timelineJson(value) {
|
|
|
501
689
|
return deepFreeze(JSON.parse(JSON.stringify(value ?? null)))
|
|
502
690
|
}
|
|
503
691
|
|
|
504
|
-
|
|
692
|
+
/**
|
|
693
|
+
* Resolve one executable call entry from either the live table or the
|
|
694
|
+
* serialized checkpoint shape.
|
|
695
|
+
*/
|
|
696
|
+
function executableEntry(timeline, seq) {
|
|
697
|
+
const table = timeline.executableCalls
|
|
698
|
+
return typeof table?.get === 'function' ? table.get(seq) : table?.[`k${seq}`]?.[1]
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* The ordered executable call events.
|
|
703
|
+
*
|
|
704
|
+
* `calls` stores only the sequence order; the events themselves live once in
|
|
705
|
+
* `executableCalls`, so a checkpoint no longer serializes every call a second
|
|
706
|
+
* time.
|
|
707
|
+
*/
|
|
708
|
+
function timelineCalls(timeline) {
|
|
709
|
+
const calls = []
|
|
710
|
+
for (const seq of timeline.calls) {
|
|
711
|
+
const event = executableEntry(timeline, seq)?.event
|
|
712
|
+
if (event !== undefined) calls.push(event)
|
|
713
|
+
}
|
|
714
|
+
return calls
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* The canonical call event retained in `executableCalls`.
|
|
719
|
+
*
|
|
720
|
+
* This is the only place the argument text is kept. A replay that only has a
|
|
721
|
+
* digested fact resolves the text from the entry already stored for that seq, so
|
|
722
|
+
* a checkpointed call and a re-derived one stay byte-identical.
|
|
723
|
+
*/
|
|
724
|
+
function timelineCallEvent(event, executableCalls) {
|
|
725
|
+
const entry = executableCalls?.get?.(event.seq) ?? executableCalls?.[`k${event.seq}`]?.[1]
|
|
726
|
+
const stored = entry?.event?.data?.arguments
|
|
727
|
+
const text = typeof event.data.arguments === 'string'
|
|
728
|
+
? event.data.arguments
|
|
729
|
+
: typeof stored === 'string' ? stored : undefined
|
|
505
730
|
return timelineJson({ seq: event.seq, type: event.type, data: {
|
|
506
731
|
callId: event.data.callId, name: event.data.name,
|
|
507
|
-
...(REPL_TOOL_NAMES.has(event.data.name)
|
|
732
|
+
...(REPL_TOOL_NAMES.has(event.data.name)
|
|
733
|
+
? (text === undefined
|
|
734
|
+
? { argumentsDigest: event.data.argumentsDigest, argumentsBytes: event.data.argumentsBytes }
|
|
735
|
+
: { arguments: text })
|
|
736
|
+
: {}),
|
|
508
737
|
} })
|
|
509
738
|
}
|
|
510
739
|
|
|
740
|
+
/** Stable digest of a stored projection fact, used by checkpoint admission. */
|
|
741
|
+
export function factDigest(value) {
|
|
742
|
+
return createHash('sha256').update(JSON.stringify(value ?? null, (_key, field) => (
|
|
743
|
+
isRecord(field) ? Object.fromEntries(Object.keys(field).sort().map(key => [key, field[key]])) : field
|
|
744
|
+
))).digest('hex')
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function withoutFactDigests(state) {
|
|
748
|
+
// Admission compares what the projection describes, not bookkeeping whose exact
|
|
749
|
+
// value depends on when pruning happened: per-fact digests and the retained-byte
|
|
750
|
+
// counter are accounting, not session state.
|
|
751
|
+
const { retainedBytes: _retainedBytes, retentionSummary: _retentionSummary, ...rest } = state
|
|
752
|
+
const facts = rest.eventFacts
|
|
753
|
+
if (!facts.some(fact => fact.digest !== undefined)) return rest
|
|
754
|
+
return { ...rest, eventFacts: facts.map(({ digest, ...fact }) => fact) }
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* The retention summary an admitted checkpoint carries.
|
|
759
|
+
*
|
|
760
|
+
* A compacted checkpoint keeps no per-settlement stubs at all: dropping the whole
|
|
761
|
+
* cluster is what makes the file size independent of how many cells the session
|
|
762
|
+
* ran. What survives is one chained summary of what was dropped, so admission can
|
|
763
|
+
* still tell "never existed" from "existed and was dropped".
|
|
764
|
+
*/
|
|
765
|
+
export function retentionSummaryOf(state) {
|
|
766
|
+
const summary = state?.retentionSummary
|
|
767
|
+
if (summary === undefined || summary === null) return undefined
|
|
768
|
+
return summary
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Prove a compacted checkpoint without replaying it.
|
|
773
|
+
*
|
|
774
|
+
* The retained window must be contiguous with the summary boundary: every
|
|
775
|
+
* settlement above `toSeq` is present and intact, every executable call that a
|
|
776
|
+
* retained settlement names is present, and the summary is a well-formed chain.
|
|
777
|
+
* Below the boundary nothing survives, which is exactly the contract of dropping
|
|
778
|
+
* a cluster. This is the weaker, stated claim admission makes for compacted
|
|
779
|
+
* states; uncompacted states keep the byte-for-byte replay equality.
|
|
780
|
+
*/
|
|
781
|
+
export function validateRetentionSummary(timeline, summary, logFacts) {
|
|
782
|
+
if (!isRecord(summary)) throw new TypeError('invalid PTC session-log projection retention summary')
|
|
783
|
+
const keys = Object.keys(summary)
|
|
784
|
+
if (keys.some(key => !['fromSeq', 'toSeq', 'count', 'chainDigest', 'entries'].includes(key))) {
|
|
785
|
+
throw new TypeError('invalid PTC session-log projection retention summary')
|
|
786
|
+
}
|
|
787
|
+
if (!isCanonicalSequence(summary.fromSeq) || !isCanonicalSequence(summary.toSeq)
|
|
788
|
+
|| summary.fromSeq > summary.toSeq || !Number.isSafeInteger(summary.count) || summary.count < 1
|
|
789
|
+
|| typeof summary.chainDigest !== 'string' || !/^[a-f0-9]{64}$/u.test(summary.chainDigest)) {
|
|
790
|
+
throw new TypeError('invalid PTC session-log projection retention summary')
|
|
791
|
+
}
|
|
792
|
+
for (const [key, pair] of Object.entries(timeline.settledResultsByEventSeq ?? {})) {
|
|
793
|
+
const seq = pair?.[0]
|
|
794
|
+
const settlement = pair?.[1]
|
|
795
|
+
if (!isCanonicalSequence(seq) || settlement === undefined) {
|
|
796
|
+
throw new TypeError('invalid PTC session-log projection retention window')
|
|
797
|
+
}
|
|
798
|
+
if (seq <= summary.toSeq) {
|
|
799
|
+
throw new TypeError(`PTC session-log projection retains settlement ${key} below its retention boundary`)
|
|
800
|
+
}
|
|
801
|
+
const callSeq = settlement.call?.seq
|
|
802
|
+
if (!isCanonicalSequence(callSeq) || timeline.executableCalls?.[`k${callSeq}`] === undefined) {
|
|
803
|
+
throw new TypeError('PTC session-log projection lost the call of a retained settlement')
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
// Admission receives no session log, so it cannot fold the dropped range. What it
|
|
807
|
+
// can prove is that the summary is internally consistent: one record per dropped
|
|
808
|
+
// cluster, ordered by result sequence, with the range and the chain derived from
|
|
809
|
+
// those records. Trusting a summary without a record per cluster is what made an
|
|
810
|
+
// earlier version forgeable. The stronger claim — that a record describes the same
|
|
811
|
+
// cluster the log still holds — is checked when a dropped cluster is refolded
|
|
812
|
+
// (`verifyRefoldedSettlement`), because that is the only point where the log and
|
|
813
|
+
// the record meet.
|
|
814
|
+
const records = summary.entries
|
|
815
|
+
if (!Array.isArray(records) || records.length !== summary.count) {
|
|
816
|
+
throw new TypeError('inconsistent PTC session-log projection retention summary')
|
|
817
|
+
}
|
|
818
|
+
let previous = -1
|
|
819
|
+
for (const record of records) {
|
|
820
|
+
if (!isRecord(record)
|
|
821
|
+
|| !isCanonicalSequence(record.resultSeq) || !isCanonicalSequence(record.callSeq)
|
|
822
|
+
|| typeof record.argumentsDigest !== 'string' || !/^[a-f0-9]{64}$/u.test(record.argumentsDigest)
|
|
823
|
+
|| record.resultSeq <= previous) {
|
|
824
|
+
throw new TypeError('inconsistent PTC session-log projection retention summary')
|
|
825
|
+
}
|
|
826
|
+
previous = record.resultSeq
|
|
827
|
+
}
|
|
828
|
+
if (records[0].resultSeq !== summary.fromSeq || records[records.length - 1].resultSeq !== summary.toSeq
|
|
829
|
+
|| chainOfEntries(records) !== summary.chainDigest) {
|
|
830
|
+
throw new TypeError('inconsistent PTC session-log projection retention summary')
|
|
831
|
+
}
|
|
832
|
+
return summary
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Prove the recorded chain against the raw session log.
|
|
837
|
+
*
|
|
838
|
+
* Folds the events up to the boundary with retention disabled, derives the summary
|
|
839
|
+
* the fold would have produced for the dropped range, and requires it to equal the
|
|
840
|
+
* recorded one. A caller without the log must say so: this function throws
|
|
841
|
+
* `unverifiable PTC session-log projection retention summary` instead of accepting.
|
|
842
|
+
*/
|
|
843
|
+
export function verifyRetentionSummaryAgainstEvents(summary, events) {
|
|
844
|
+
if (!Array.isArray(events)) {
|
|
845
|
+
throw new TypeError('unverifiable PTC session-log projection retention summary')
|
|
846
|
+
}
|
|
847
|
+
const expected = retentionSummaryFromEvents(events, summary.toSeq)
|
|
848
|
+
if (expected === undefined
|
|
849
|
+
|| expected.count !== summary.count || expected.fromSeq !== summary.fromSeq
|
|
850
|
+
|| expected.toSeq !== summary.toSeq || expected.chainDigest !== summary.chainDigest) {
|
|
851
|
+
throw new TypeError('inconsistent PTC session-log projection retention summary')
|
|
852
|
+
}
|
|
853
|
+
return summary
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Refold the editable cell from the log and check it against the summary.
|
|
858
|
+
*
|
|
859
|
+
* The checkpoint cannot prove that its summary describes the clusters the log
|
|
860
|
+
* still holds — admission has no log. This is the one place where both sides meet:
|
|
861
|
+
* the log is folded with pruning disabled, the settlement behind the editable run
|
|
862
|
+
* is turned into a summary record, and the record stored in the summary must equal
|
|
863
|
+
* it. A mismatch means the summary and the log disagree, which is reported to the
|
|
864
|
+
* caller instead of yielding an edit target that was never recorded.
|
|
865
|
+
*/
|
|
866
|
+
export function refoldEditableSettlement(events, summary) {
|
|
867
|
+
if (!Array.isArray(events)) return { run: undefined, mismatch: false }
|
|
868
|
+
let state
|
|
869
|
+
withFullRetention(() => {
|
|
870
|
+
state = createSessionTimelineState()
|
|
871
|
+
events.forEach((event, index) => {
|
|
872
|
+
if (!isRecord(event)) return
|
|
873
|
+
state = advanceSessionTimeline(state, event, isCanonicalSequence(event.seq) ? event.seq : index)
|
|
874
|
+
})
|
|
875
|
+
})
|
|
876
|
+
const run = state?.editableRun
|
|
877
|
+
if (run === undefined) return { run: undefined, mismatch: false }
|
|
878
|
+
const settled = Object.entries(state?.settledResultsByEventSeq ?? {})
|
|
879
|
+
.map(([, pair]) => pair)
|
|
880
|
+
.find(([, settlement]) => settlement?.call?.seq === run.callSeq)
|
|
881
|
+
if (settled === undefined) return { run, mismatch: false }
|
|
882
|
+
const [resultSeq, settlement] = settled
|
|
883
|
+
const recorded = summaryRecordOf(summary, Number(resultSeq))
|
|
884
|
+
if (recorded === undefined) return { run, mismatch: false }
|
|
885
|
+
const refolded = summaryEntry(settlement, Number(resultSeq))
|
|
886
|
+
const mismatch = recorded.callSeq !== refolded.callSeq || recorded.argumentsDigest !== refolded.argumentsDigest
|
|
887
|
+
return { run, mismatch }
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** Recompute the summary a fold of the dropped range would have produced. */
|
|
891
|
+
function retentionSummaryFromEvents(events, toSeq) {
|
|
892
|
+
let state
|
|
893
|
+
withFullRetention(() => {
|
|
894
|
+
state = createSessionTimelineState()
|
|
895
|
+
events.forEach((event, index) => {
|
|
896
|
+
if (!isRecord(event)) return
|
|
897
|
+
if (isCanonicalSequence(event.seq) && event.seq > toSeq) return
|
|
898
|
+
state = advanceSessionTimeline(state, event, isCanonicalSequence(event.seq) ? event.seq : index)
|
|
899
|
+
})
|
|
900
|
+
})
|
|
901
|
+
const dropped = Object.entries(state?.settledResultsByEventSeq ?? {})
|
|
902
|
+
.map(([, pair]) => pair)
|
|
903
|
+
.filter(([resultSeq]) => Number(resultSeq) <= toSeq)
|
|
904
|
+
// The prune orders dropped entries by RESULT sequence before folding them into the
|
|
905
|
+
// chain, so summing here must use the same order; observedIndex can disagree when
|
|
906
|
+
// a nested call settles out of order.
|
|
907
|
+
.sort((left, right) => Number(left[0]) - Number(right[0]))
|
|
908
|
+
.map(([resultSeq, settlement]) => summaryEntry(settlement, Number(resultSeq)))
|
|
909
|
+
if (dropped.length === 0) return undefined
|
|
910
|
+
return extendSummary(undefined, dropped)
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* The identity of a call is what survives the payload window: sequence, type,
|
|
915
|
+
* callId and name. The argument text is deliberately excluded, because retention
|
|
916
|
+
* replaces it with `argumentsDigest` and an identity that hashed the text would make
|
|
917
|
+
* every observation describe a different call before and after pruning. The text is
|
|
918
|
+
* still bound by its own digest in the call entry, so excluding it hides nothing.
|
|
919
|
+
*/
|
|
920
|
+
function eventDigest(event) {
|
|
921
|
+
// Identity only, and only the fields every call keeps: a payload the window
|
|
922
|
+
// digested must hash the same before and after pruning, which is why the source
|
|
923
|
+
// text and the result are not part of it. A call always carries its `callId`
|
|
924
|
+
// (executable calls are admitted with one), so no second shape exists to hash.
|
|
925
|
+
const data = isRecord(event?.data) ? event.data : {}
|
|
926
|
+
const identity = { seq: event?.seq, type: event?.type,
|
|
927
|
+
data: { callId: data.callId, name: data.name ?? null } }
|
|
928
|
+
return createHash('sha256').update(JSON.stringify(identity)).digest('hex')
|
|
929
|
+
}
|
|
930
|
+
|
|
511
931
|
function resultIdentity(data) {
|
|
512
932
|
const identity = timelineJson(isRecord(data) && isRecord(data.message)
|
|
513
933
|
? { ...data, message: { ...data.message, content: undefined } } : data)
|
|
@@ -516,6 +936,23 @@ function resultIdentity(data) {
|
|
|
516
936
|
))).digest('hex')
|
|
517
937
|
}
|
|
518
938
|
|
|
939
|
+
/**
|
|
940
|
+
* How many bytes of retained payload admission may replay.
|
|
941
|
+
*
|
|
942
|
+
* A stored checkpoint is proved two ways: replaying its facts reproduces it
|
|
943
|
+
* byte-for-byte, or — because that replay costs a second fold of everything the
|
|
944
|
+
* session wrote — its per-fact digests and identity chain prove continuity. This
|
|
945
|
+
* budget decides which claim a given checkpoint gets, and both replays read it
|
|
946
|
+
* through `admissionReplayBudget`, so the timeline replay here and the
|
|
947
|
+
* log-identity replay in the session-log projection cannot disagree about it.
|
|
948
|
+
*/
|
|
949
|
+
const ADMISSION_REPLAY_BUDGET_BYTES = 4 * 1024 * 1024
|
|
950
|
+
|
|
951
|
+
/** Resolve a caller's replay budget, falling back to the shipped one. */
|
|
952
|
+
export function admissionReplayBudget(bytes) {
|
|
953
|
+
return Number.isSafeInteger(bytes) && bytes > 0 ? bytes : ADMISSION_REPLAY_BUDGET_BYTES
|
|
954
|
+
}
|
|
955
|
+
|
|
519
956
|
export function createSessionTimelineState() {
|
|
520
957
|
return {
|
|
521
958
|
openTurn: false,
|
|
@@ -536,11 +973,34 @@ export function createSessionTimelineState() {
|
|
|
536
973
|
runSelectionHistory: [],
|
|
537
974
|
lastEventIndex: null,
|
|
538
975
|
eventFacts: [],
|
|
976
|
+
// Payload bytes the retained settlements still carry: the retention window
|
|
977
|
+
// keeps this bounded so a checkpoint never scales with written content.
|
|
978
|
+
retainedBytes: 0,
|
|
979
|
+
// One chained summary of the clusters the window dropped. Per-settlement stubs
|
|
980
|
+
// would still scale with the number of cells the session ran.
|
|
981
|
+
retentionSummary: null,
|
|
982
|
+
// Set once any cluster is dropped, so admission can tell "nothing was ever
|
|
983
|
+
// dropped" from "a summary was deleted". A compacted state must carry its summary.
|
|
984
|
+
compacted: false,
|
|
985
|
+
// Set when the newest cell alone exceeded the byte budget and had to go. The
|
|
986
|
+
// consumers that would have read it must say so instead of looking empty.
|
|
987
|
+
hotCellDropped: false,
|
|
539
988
|
scope: 0,
|
|
540
989
|
}
|
|
541
990
|
}
|
|
542
991
|
|
|
543
|
-
|
|
992
|
+
/**
|
|
993
|
+
* Prove a stored timeline checkpoint, given the session log's own facts when the
|
|
994
|
+
* caller holds them.
|
|
995
|
+
*
|
|
996
|
+
* `replayBudgetBytes` is the caller's replay budget: a checkpoint above it is
|
|
997
|
+
* admitted on its per-fact digests and identity chain instead of a replay, and a
|
|
998
|
+
* caller that lowers the budget (a test, or a host with a cheaper fold) must
|
|
999
|
+
* lower this same claim, not a second copy of it.
|
|
1000
|
+
*/
|
|
1001
|
+
export function validateSessionTimelineState(value, logFacts, options = {}) {
|
|
1002
|
+
const replayBudget = admissionReplayBudget(options.replayBudgetBytes)
|
|
1003
|
+
|
|
544
1004
|
const requireFact = condition => {
|
|
545
1005
|
if (!condition) throw new TypeError('invalid PTC session-log projection timeline checkpoint')
|
|
546
1006
|
}
|
|
@@ -549,17 +1009,36 @@ export function validateSessionTimelineState(value) {
|
|
|
549
1009
|
const target = field => fields(field, ['callSeq', 'source']) && isCanonicalSequence(field.callSeq) && typeof field.source === 'string'
|
|
550
1010
|
const callEvent = event => fields(event, ['seq', 'type', 'data']) && isCanonicalSequence(event.seq) && event.type === 'tool/call'
|
|
551
1011
|
&& isRecord(event.data) && typeof event.data.callId === 'string'
|
|
552
|
-
&& fields(event.data, ['callId', 'name', 'arguments'])
|
|
1012
|
+
&& fields(event.data, ['callId', 'name', 'arguments', 'argumentsDigest', 'argumentsBytes'])
|
|
553
1013
|
&& optional(event.data.name, name => typeof name === 'string')
|
|
554
|
-
&&
|
|
1014
|
+
&& (event.data.arguments === undefined
|
|
1015
|
+
? (event.data.argumentsDigest === undefined
|
|
1016
|
+
|| (typeof event.data.argumentsDigest === 'string' && /^[a-f0-9]{64}$/u.test(event.data.argumentsDigest)
|
|
1017
|
+
&& isCanonicalSequence(event.data.argumentsBytes)))
|
|
1018
|
+
: (typeof event.data.arguments === 'string' && event.data.argumentsDigest === undefined
|
|
1019
|
+
&& event.data.argumentsBytes === undefined))
|
|
555
1020
|
const callEntry = entry => fields(entry, ['event', 'eventIndex', 'scope', 'ambiguous', 'editTarget']) && callEvent(entry.event)
|
|
556
1021
|
&& isCanonicalSequence(entry.eventIndex) && isCanonicalSequence(entry.scope) && entry.scope <= value.scope
|
|
557
1022
|
&& optional(entry.ambiguous, field => field === true) && optional(entry.editTarget, target)
|
|
1023
|
+
// A settlement references its call through `call` and keeps only the entry's
|
|
1024
|
+
// position and claims. Storing `entry.event` as well would serialize the call
|
|
1025
|
+
// event a second time — the duplication the retention work exists to remove.
|
|
1026
|
+
const settlementEntry = entry => fields(entry, ['eventIndex', 'scope', 'ambiguous', 'editTarget'])
|
|
1027
|
+
&& isCanonicalSequence(entry.eventIndex) && isCanonicalSequence(entry.scope) && entry.scope <= value.scope
|
|
1028
|
+
&& optional(entry.ambiguous, field => field === true) && optional(entry.editTarget, target)
|
|
558
1029
|
const table = (name, keyValid, payloadValid) => {
|
|
559
|
-
|
|
1030
|
+
if (!isRecord(value[name])) {
|
|
1031
|
+
throw new TypeError(`invalid PTC session-log projection timeline checkpoint: table ${name} is missing`)
|
|
1032
|
+
}
|
|
560
1033
|
for (const [key, pair] of Object.entries(value[name])) {
|
|
561
|
-
|
|
562
|
-
|
|
1034
|
+
if (!Array.isArray(pair) || pair.length !== 2 || !keyValid(pair[0]) || key !== `k${pair[0]}`) {
|
|
1035
|
+
throw new TypeError(`invalid PTC session-log projection timeline checkpoint: table ${name} entry ${key}`)
|
|
1036
|
+
}
|
|
1037
|
+
if (!payloadValid(pair[1], pair[0])) {
|
|
1038
|
+
const problem = name === 'settledResultsByEventSeq' ? settlementProblem(pair[1]) : undefined
|
|
1039
|
+
const shape = isRecord(pair[1]) ? ` keys=${Object.keys(pair[1]).join(',')}` : ` value=${JSON.stringify(pair[1])?.slice(0, 80)}`
|
|
1040
|
+
throw new TypeError(`invalid PTC session-log projection timeline checkpoint: table ${name} payload ${key}${shape}${problem === undefined ? '' : ` problem=${problem}`}`)
|
|
1041
|
+
}
|
|
563
1042
|
}
|
|
564
1043
|
return new Map(Object.values(value[name]))
|
|
565
1044
|
}
|
|
@@ -567,9 +1046,14 @@ export function validateSessionTimelineState(value) {
|
|
|
567
1046
|
'cordisTranscript', 'editTargets', 'pendingByCallId', 'seenCallIds', 'claimedEditTargets', 'editClaims',
|
|
568
1047
|
'ordinaryResultSeqs', 'settledResultsByEventSeq', 'scope', 'latestRun', 'editableRun', 'lastSuccessfulRunIndex',
|
|
569
1048
|
'unavailableResultSeq', 'unavailableResultReason', 'pruneReplacementWindow', 'runSelectionFrontier',
|
|
570
|
-
'runSelectionHistory', 'lastEventIndex', 'eventFacts'
|
|
1049
|
+
'runSelectionHistory', 'lastEventIndex', 'eventFacts', 'retainedBytes', 'retentionSummary', 'compacted',
|
|
1050
|
+
'hotCellDropped']) && typeof value.openTurn === 'boolean'
|
|
571
1051
|
&& typeof value.found === 'boolean' && isCanonicalSequence(value.scope)
|
|
572
|
-
&&
|
|
1052
|
+
&& typeof value.retainedBytes === 'number' && value.retainedBytes >= 0
|
|
1053
|
+
&& (value.retentionSummary === undefined || value.retentionSummary === null || isRecord(value.retentionSummary))
|
|
1054
|
+
&& typeof value.compacted === 'boolean' && typeof value.hotCellDropped === 'boolean'
|
|
1055
|
+
&& Array.isArray(value.calls) && value.calls.every(seq => isCanonicalSequence(seq))
|
|
1056
|
+
&& Array.isArray(value.boundaries))
|
|
573
1057
|
requireFact(value.lastEventIndex === null || isCanonicalSequence(value.lastEventIndex))
|
|
574
1058
|
requireFact(Array.isArray(value.runSelectionHistory))
|
|
575
1059
|
let boundaryScope = 0
|
|
@@ -593,9 +1077,10 @@ export function validateSessionTimelineState(value) {
|
|
|
593
1077
|
&& entry.event.seq === seq && REPL_TOOL_NAMES.has(entry.event.data.name))
|
|
594
1078
|
requireFact(value.calls.length === executable.size)
|
|
595
1079
|
const callSeqs = new Set()
|
|
596
|
-
for (const
|
|
597
|
-
|
|
598
|
-
callSeqs.
|
|
1080
|
+
for (const seq of value.calls) {
|
|
1081
|
+
// The order list may only name stored entries, and may not repeat one.
|
|
1082
|
+
requireFact(!callSeqs.has(seq) && executable.has(seq))
|
|
1083
|
+
callSeqs.add(seq)
|
|
599
1084
|
}
|
|
600
1085
|
const journalFacts = result => {
|
|
601
1086
|
if (!isRecord(result.journal)) return false
|
|
@@ -606,7 +1091,7 @@ export function validateSessionTimelineState(value) {
|
|
|
606
1091
|
if (result.derived !== undefined) {
|
|
607
1092
|
const derived = result.derived
|
|
608
1093
|
if (!isRecord(derived)) return false
|
|
609
|
-
const normalized =
|
|
1094
|
+
const normalized = normalizeDeferredEditSettlement({
|
|
610
1095
|
[JOURNAL_KEY]: result.journal,
|
|
611
1096
|
[EDIT_TARGET_KEY]: { targetCallSeq: derived.targetCallSeq },
|
|
612
1097
|
[DERIVED_RUN_KEY]: { code: derived.code, description: derived.description },
|
|
@@ -618,6 +1103,8 @@ export function validateSessionTimelineState(value) {
|
|
|
618
1103
|
return true
|
|
619
1104
|
}
|
|
620
1105
|
const normalizedResult = result => {
|
|
1106
|
+
// Every stored result keeps its full shape: a dropped cluster takes its result
|
|
1107
|
+
// entry with it, so a digest-only result would be a shape nothing produces.
|
|
621
1108
|
if (!fields(result, ['eventSeq', 'eventIndex', 'positionSeq', 'error', 'journal', 'derived', 'userBindings',
|
|
622
1109
|
'postEdit', 'postEditError'])
|
|
623
1110
|
|| !isCanonicalSequence(result.eventSeq) || !isCanonicalSequence(result.eventIndex)
|
|
@@ -628,7 +1115,7 @@ export function validateSessionTimelineState(value) {
|
|
|
628
1115
|
&& optional(result.postEdit, post => fields(post, ['startSeq', 'executionStartSeq', 'targetCallSeq', 'derived', 'rewrites'])
|
|
629
1116
|
&& isCanonicalSequence(post.startSeq) && isCanonicalSequence(post.executionStartSeq)
|
|
630
1117
|
&& isCanonicalSequence(post.targetCallSeq)
|
|
631
|
-
&& deepEqualJson(
|
|
1118
|
+
&& deepEqualJson(normalizeDeferredEditSettlement({
|
|
632
1119
|
[JOURNAL_KEY]: post.derived?.journal,
|
|
633
1120
|
[EDIT_TARGET_KEY]: { targetCallSeq: post.targetCallSeq },
|
|
634
1121
|
[DERIVED_RUN_KEY]: { code: post.derived?.code, description: post.derived?.description },
|
|
@@ -657,49 +1144,75 @@ export function validateSessionTimelineState(value) {
|
|
|
657
1144
|
const claimed = table('claimedEditTargets', isCanonicalSequence, (field, seq) => trueValue(field) && executable.has(seq))
|
|
658
1145
|
const edits = table('editClaims', isCanonicalSequence, (seq, editSeq) => isCanonicalSequence(seq)
|
|
659
1146
|
&& claimed.has(seq) && targets.get(editSeq)?.callSeq === seq)
|
|
660
|
-
const ordinaryObservations = table('ordinaryResultSeqs', isCanonicalSequence, (observation, seq) =>
|
|
661
|
-
['call', 'eventSeq', 'eventIndex']) && callEvent(observation.call) && observation.call.seq === seq
|
|
1147
|
+
const ordinaryObservations = table('ordinaryResultSeqs', isCanonicalSequence, (observation, seq) =>
|
|
1148
|
+
((fields(observation, ['call', 'eventSeq', 'eventIndex']) && callEvent(observation.call) && observation.call.seq === seq
|
|
1149
|
+
&& value.executableCalls[`k${seq}`] === undefined)
|
|
1150
|
+
|| (fields(observation, ['callDigest', 'eventSeq', 'eventIndex'])
|
|
1151
|
+
&& typeof observation.callDigest === 'string' && /^[a-f0-9]{64}$/u.test(observation.callDigest)
|
|
1152
|
+
&& value.executableCalls[`k${seq}`] !== undefined
|
|
1153
|
+
&& eventDigest(value.executableCalls[`k${seq}`][1].event) === observation.callDigest))
|
|
662
1154
|
&& isCanonicalSequence(observation.eventSeq) && isCanonicalSequence(observation.eventIndex)
|
|
663
1155
|
&& value.lastEventIndex !== null && observation.eventIndex <= value.lastEventIndex
|
|
664
|
-
&& (!REPL_TOOL_NAMES.has(observation.call.data.name)
|
|
1156
|
+
&& (observation.callDigest !== undefined || !REPL_TOOL_NAMES.has(observation.call.data.name)
|
|
665
1157
|
|| (deepEqualJson(executable.get(seq)?.event, observation.call)
|
|
666
1158
|
&& observation.eventIndex >= executable.get(seq).eventIndex)))
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
&& deepEqualJson(
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
1159
|
+
/**
|
|
1160
|
+
* Name the clause a settlement fails, so a rejected checkpoint says which relation
|
|
1161
|
+
* broke instead of only which table. The clauses are the same ones the inline
|
|
1162
|
+
* predicate used to run; this only reports them.
|
|
1163
|
+
*/
|
|
1164
|
+
const settlementProblem = settlement => {
|
|
1165
|
+
if (!fields(settlement, ['call', 'entry', 'identity', 'publication', 'observedIndex', 'ordinary', 'normalized'])) return 'fields'
|
|
1166
|
+
if (!callEvent(settlement.call)) return 'callEvent'
|
|
1167
|
+
if (!settlementEntry(settlement.entry)) return 'entry'
|
|
1168
|
+
if (!optional(settlement.normalized, normalizedResult)) return 'normalized'
|
|
1169
|
+
if (typeof settlement.ordinary !== 'boolean') return 'ordinary'
|
|
1170
|
+
if (!isCanonicalSequence(settlement.observedIndex) || value.lastEventIndex === null) return 'observedIndex'
|
|
1171
|
+
if (settlement.observedIndex < settlement.entry.eventIndex || settlement.observedIndex > value.lastEventIndex) return 'observedIndex range'
|
|
1172
|
+
if (typeof settlement.identity !== 'string' || !/^[a-f0-9]{64}$/u.test(settlement.identity)) return 'identity'
|
|
1173
|
+
if (REPL_TOOL_NAMES.has(settlement.call.data.name)
|
|
1174
|
+
&& !deepEqualJson(executable.get(settlement.call.seq)?.event, settlement.call)) {
|
|
1175
|
+
const stored = executable.get(settlement.call.seq)?.event
|
|
1176
|
+
return `executable call (stored=${JSON.stringify(stored)?.slice(0, 220)} settlement=${JSON.stringify(settlement.call)?.slice(0, 220)})`
|
|
1177
|
+
}
|
|
1178
|
+
const publicationProblem = publication => {
|
|
1179
|
+
if (!fields(publication, ['index', 'journal', 'derived', 'userBindings', 'rewrites', 'postEdit', 'postEditRewrites'])) return 'publication fields'
|
|
1180
|
+
if (!deepEqualJson(publication.journal ?? null, settlement.normalized?.journal ?? null)) return 'publication journal'
|
|
1181
|
+
if (!deepEqualJson(publication.derived ?? null, settlement.normalized?.derived ?? null)) return 'publication derived'
|
|
1182
|
+
if (!deepEqualJson(publication.userBindings ?? null, settlement.normalized?.userBindings ?? null)) return 'publication userBindings'
|
|
1183
|
+
if (!deepEqualJson(publication.postEdit ?? null, settlement.normalized?.postEdit ?? null)) return 'publication postEdit'
|
|
1184
|
+
if (!deepEqualJson(publication.postEditRewrites ?? null, settlement.normalized?.postEdit?.rewrites ?? null)) return 'publication postEditRewrites'
|
|
1185
|
+
if (settlement.entry.ambiguous) return 'publication ambiguity'
|
|
1186
|
+
if (!isCanonicalSequence(publication.index)) return 'publication index'
|
|
1187
|
+
if (publication.index !== (settlement.ordinary ? settlement.observedIndex : settlement.entry.eventIndex)) return 'publication position'
|
|
1188
|
+
if (settlement.call.data.name === 'run_code' && publication.derived !== undefined) return 'publication derived target'
|
|
1189
|
+
if (settlement.call.data.name === 'edit_run_code'
|
|
1190
|
+
&& (publication.derived === undefined
|
|
1191
|
+
|| publication.derived.targetCallSeq !== settlement.entry.editTarget?.callSeq)) return 'publication edit target'
|
|
1192
|
+
if (publication.journal === undefined
|
|
1193
|
+
? publication.derived !== undefined || publication.userBindings !== undefined
|
|
1194
|
+
: !journalFacts(publication)) return 'publication journal facts'
|
|
1195
|
+
if (!deepEqualJson(validatedRewrites({ [REWRITES_KEY]: publication.rewrites }) ?? null, publication.rewrites ?? null)) return 'publication rewrites'
|
|
1196
|
+
return undefined
|
|
1197
|
+
}
|
|
1198
|
+
if (!optional(settlement.publication, publication => publicationProblem(publication) === undefined)) return 'publication'
|
|
1199
|
+
if (isRecord(settlement.publication)) {
|
|
1200
|
+
const problem = publicationProblem(settlement.publication)
|
|
1201
|
+
if (problem !== undefined) return problem
|
|
1202
|
+
}
|
|
1203
|
+
return undefined
|
|
1204
|
+
}
|
|
1205
|
+
const settlements = table('settledResultsByEventSeq', isCanonicalSequence, settlement => settlementProblem(settlement) === undefined)
|
|
695
1206
|
// Normalized event facts retain distinctions needed by the transition but
|
|
696
1207
|
// omit result content. Admission uses that same transition, not a second
|
|
697
1208
|
// partial interpretation of the materialized caches.
|
|
698
1209
|
requireFact(Array.isArray(value.eventFacts))
|
|
699
1210
|
let reconstructed = createSessionTimelineState()
|
|
700
1211
|
let correlationIndex = -1
|
|
1212
|
+
let replayBytes = 0
|
|
1213
|
+
let replayComplete = true
|
|
701
1214
|
for (const observation of value.eventFacts) {
|
|
702
|
-
requireFact(fields(observation, ['eventIndex', 'event', 'identity', 'hasJournal', 'normalized',
|
|
1215
|
+
requireFact(fields(observation, ['eventIndex', 'event', 'digest', 'identity', 'hasJournal', 'normalized',
|
|
703
1216
|
'boundaries', 'boundaryFailure', 'rewrites', 'selectedTarget'])
|
|
704
1217
|
&& isCanonicalSequence(observation.eventIndex) && observation.eventIndex > correlationIndex
|
|
705
1218
|
&& value.lastEventIndex !== null && observation.eventIndex <= value.lastEventIndex
|
|
@@ -728,14 +1241,48 @@ export function validateSessionTimelineState(value) {
|
|
|
728
1241
|
&& deepEqualJson(validatedRewrites({ [REWRITES_KEY]: observation.rewrites }) ?? null,
|
|
729
1242
|
observation.rewrites ?? null))
|
|
730
1243
|
correlationIndex = observation.eventIndex
|
|
731
|
-
|
|
1244
|
+
// Replay proves byte-for-byte equality, but its cost is proportional to the
|
|
1245
|
+
// retained payload of the whole session. Admission therefore replays only
|
|
1246
|
+
// while that payload stays inside the budget and falls back to the per-fact
|
|
1247
|
+
// digests plus the shape/identity checks above beyond it.
|
|
1248
|
+
replayBytes += factDigest(observation.event).length * 4
|
|
1249
|
+
if (replayBytes > replayBudget) {
|
|
1250
|
+
replayComplete = false
|
|
1251
|
+
continue
|
|
1252
|
+
}
|
|
1253
|
+
reconstructed = replaySessionTimelineFact(reconstructed, observation, executable)
|
|
1254
|
+
}
|
|
1255
|
+
const summary = retentionSummaryOf(value)
|
|
1256
|
+
// A compacted state that lost its summary must be rejected: dropping the summary
|
|
1257
|
+
// would erase the only trace of the clusters the window removed.
|
|
1258
|
+
if (value.compacted === true && summary === undefined) {
|
|
1259
|
+
throw new TypeError('inconsistent PTC session-log projection retention summary')
|
|
1260
|
+
}
|
|
1261
|
+
if (value.compacted !== true && summary !== undefined) {
|
|
1262
|
+
throw new TypeError('inconsistent PTC session-log projection retention summary')
|
|
1263
|
+
}
|
|
1264
|
+
if (summary !== undefined) {
|
|
1265
|
+
// A compacted checkpoint dropped whole clusters, so replay can never reproduce
|
|
1266
|
+
// it; the summary is proved instead — against the log when the caller has it.
|
|
1267
|
+
validateRetentionSummary(value, summary, logFacts)
|
|
1268
|
+
} else if (replayComplete) {
|
|
1269
|
+
// Non-compacted states keep the full replay equality.
|
|
1270
|
+
requireFact(deepEqualJson(withoutFactDigests(reconstructed), withoutFactDigests(value)))
|
|
1271
|
+
}
|
|
1272
|
+
// Semantic checks run first so their diagnostics stay specific; the digest is
|
|
1273
|
+
// the last line of defence and rejects a payload edit without replaying it.
|
|
1274
|
+
for (const observation of value.eventFacts) {
|
|
1275
|
+
if (observation.digest === undefined) continue
|
|
1276
|
+
const { digest, ...rest } = observation
|
|
1277
|
+
requireFact(digest === factDigest(rest))
|
|
732
1278
|
}
|
|
733
|
-
requireFact(deepEqualJson(reconstructed, value))
|
|
734
1279
|
for (const observation of ordinaryObservations.values()) {
|
|
735
1280
|
const settlement = settlements.get(observation.eventSeq)
|
|
736
1281
|
requireFact(settlement !== undefined
|
|
737
1282
|
? settlement.ordinary && settlement.observedIndex === observation.eventIndex
|
|
738
|
-
&&
|
|
1283
|
+
&& (observation.callDigest === undefined
|
|
1284
|
+
? deepEqualJson(settlement.call, observation.call)
|
|
1285
|
+
: eventDigest(settlement.call) === observation.callDigest)
|
|
739
1286
|
: value.runSelectionHistory.some(frontier => frontier.index === observation.eventIndex
|
|
740
1287
|
&& frontier.reason === 'result-identity-mismatch'))
|
|
741
1288
|
}
|
|
@@ -743,7 +1290,9 @@ export function validateSessionTimelineState(value) {
|
|
|
743
1290
|
if (!settlement.ordinary) continue
|
|
744
1291
|
const observation = ordinaryObservations.get(settlement.call.seq)
|
|
745
1292
|
requireFact(observation !== undefined && observation.eventIndex === settlement.observedIndex
|
|
746
|
-
&&
|
|
1293
|
+
&& (observation.callDigest === undefined
|
|
1294
|
+
? deepEqualJson(observation.call, settlement.call)
|
|
1295
|
+
: eventDigest(settlement.call) === observation.callDigest))
|
|
747
1296
|
}
|
|
748
1297
|
const publications = [...settlements.values()].filter(settlement => settlement.publication !== undefined)
|
|
749
1298
|
// Dispatch targets are derived from first-observed settlements and retained
|
|
@@ -789,7 +1338,7 @@ export function validateSessionTimelineState(value) {
|
|
|
789
1338
|
dispatchEdits.delete(settlement.call.seq)
|
|
790
1339
|
if (settlement.publication?.derived === undefined) dispatchClaims.delete(claim)
|
|
791
1340
|
}
|
|
792
|
-
dispatchRun = selectSettlementRun(dispatchRun, settlement, dispatchScope, dispatchFrontier)
|
|
1341
|
+
dispatchRun = selectSettlementRun(dispatchRun, settlement, dispatchScope, dispatchFrontier, executable)
|
|
793
1342
|
}
|
|
794
1343
|
}
|
|
795
1344
|
requireFact(claimed.size === dispatchClaims.size && [...dispatchClaims].every(seq => claimed.has(seq)))
|
|
@@ -797,14 +1346,14 @@ export function validateSessionTimelineState(value) {
|
|
|
797
1346
|
let selectedRun
|
|
798
1347
|
let lastSuccessfulRunIndex
|
|
799
1348
|
for (const settlement of publications) {
|
|
800
|
-
selectedRun = selectSettlementRun(selectedRun, settlement, value.scope, value.runSelectionFrontier)
|
|
801
|
-
if (successfulTimelineRun(settlementRun(settlement))) {
|
|
1349
|
+
selectedRun = selectSettlementRun(selectedRun, settlement, value.scope, value.runSelectionFrontier, executable)
|
|
1350
|
+
if (successfulTimelineRun(settlementRun(settlement, executable))) {
|
|
802
1351
|
lastSuccessfulRunIndex = Math.max(lastSuccessfulRunIndex ?? -1, settlement.publication.index)
|
|
803
1352
|
}
|
|
804
1353
|
}
|
|
805
1354
|
requireFact(selectedRun === undefined || publications.some(settlement =>
|
|
806
1355
|
settlement.entry.scope === value.scope && !settlement.entry.ambiguous
|
|
807
|
-
&& settlementRun(settlement)
|
|
1356
|
+
&& settlementRun(settlement, executable)?.callSeq === selectedRun.callSeq))
|
|
808
1357
|
requireFact(selectedRun === undefined ? value.latestRun === undefined && value.editableRun === undefined
|
|
809
1358
|
: deepEqualJson(value.latestRun ?? null, selectedRun) && deepEqualJson(value.editableRun ?? null, selectedRun))
|
|
810
1359
|
requireFact(value.lastSuccessfulRunIndex === lastSuccessfulRunIndex
|
|
@@ -838,8 +1387,8 @@ export function advanceSessionTimeline(previous, inputEvent, eventIndex = inputE
|
|
|
838
1387
|
}
|
|
839
1388
|
|
|
840
1389
|
/** Reconstruct an already validated normalized fact at its recorded cut. */
|
|
841
|
-
export function replaySessionTimelineFact(previous, fact) {
|
|
842
|
-
return advanceTimelineEvent(previous, fact.event, fact.eventIndex, fact)
|
|
1390
|
+
export function replaySessionTimelineFact(previous, fact, retainedCalls) {
|
|
1391
|
+
return advanceTimelineEvent(previous, fact.event, fact.eventIndex, fact, retainedCalls)
|
|
843
1392
|
}
|
|
844
1393
|
|
|
845
1394
|
function dispatchPairWithinRoot(facts, call, resultEvent, startSeq) {
|
|
@@ -860,7 +1409,7 @@ function dispatchPairWithinRoot(facts, call, resultEvent, startSeq) {
|
|
|
860
1409
|
return { start: starts[0], settled: settlements[0] }
|
|
861
1410
|
}
|
|
862
1411
|
|
|
863
|
-
function normalizePostEditFact(facts, call, resultEvent, result, replayPostEdit) {
|
|
1412
|
+
function normalizePostEditFact(facts, call, resultEvent, result, replayPostEdit, executableCalls) {
|
|
864
1413
|
const post = replayPostEdit === undefined ? resultEvent.data?.meta?.[POST_EDIT_KEY] : {
|
|
865
1414
|
startSeq: replayPostEdit.startSeq,
|
|
866
1415
|
executionStartSeq: replayPostEdit.executionStartSeq,
|
|
@@ -909,11 +1458,11 @@ function normalizePostEditFact(facts, call, resultEvent, result, replayPostEdit)
|
|
|
909
1458
|
}
|
|
910
1459
|
let derived
|
|
911
1460
|
try {
|
|
912
|
-
derived =
|
|
1461
|
+
derived = normalizeDeferredEditSettlement(post.meta, target?.callSeq)
|
|
913
1462
|
} catch (error) {
|
|
914
1463
|
return fail(error.message)
|
|
915
1464
|
}
|
|
916
|
-
if (target?.source === undefined || sourceForRunCall(call) === undefined) {
|
|
1465
|
+
if (target?.source === undefined || sourceForRunCall(call, executableCalls) === undefined) {
|
|
917
1466
|
return fail('nested edit has no selected source')
|
|
918
1467
|
}
|
|
919
1468
|
const edit = editRejectedCell(start.data.arguments, target.source)
|
|
@@ -946,7 +1495,29 @@ function normalizePostEditFact(facts, call, resultEvent, result, replayPostEdit)
|
|
|
946
1495
|
...(rewrites === undefined ? {} : { rewrites }) }
|
|
947
1496
|
}
|
|
948
1497
|
|
|
949
|
-
|
|
1498
|
+
/**
|
|
1499
|
+
* A published journal records the digest of the wording its diagnostics state.
|
|
1500
|
+
*
|
|
1501
|
+
* Recovery reports a failed cell by re-rendering its recorded diagnostic, and the
|
|
1502
|
+
* completion message and that diagnostic are two copies of one settlement, so a
|
|
1503
|
+
* record whose diagnostic text was edited can re-derive both consistently. The
|
|
1504
|
+
* digest is taken when the settlement is published, before either copy can change.
|
|
1505
|
+
* A record that carries no digest predates it and keeps the older evidence; one
|
|
1506
|
+
* whose diagnostics no longer hash to it is not evidence for its cell's bindings.
|
|
1507
|
+
*/
|
|
1508
|
+
function requireRecordedDiagnosticWording(meta, journal) {
|
|
1509
|
+
if (!isRecord(meta) || !Object.hasOwn(meta, DIAGNOSTIC_WORDING_KEY)) return
|
|
1510
|
+
const recorded = meta[DIAGNOSTIC_WORDING_KEY]
|
|
1511
|
+
if (!isRecord(recorded) || Reflect.ownKeys(recorded).length !== DIAGNOSTIC_WORDING_FIELDS.size
|
|
1512
|
+
|| Reflect.ownKeys(recorded).some(key => !DIAGNOSTIC_WORDING_FIELDS.has(key))) {
|
|
1513
|
+
throw new Error('invalid recorded diagnostic wording')
|
|
1514
|
+
}
|
|
1515
|
+
if (recorded.digest !== diagnosticWordingDigest(journal)) {
|
|
1516
|
+
throw new Error('journal diagnostic wording does not match its recorded digest')
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact, retainedCalls) {
|
|
950
1521
|
const nested = inputEvent?.type === 'tool/ptc-dispatch-start'
|
|
951
1522
|
|| inputEvent?.type === 'tool/ptc-dispatch'
|
|
952
1523
|
if (nested && inputEvent.data?.name !== 'edit_run_code'
|
|
@@ -1044,7 +1615,7 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
|
|
|
1044
1615
|
if (event?.type === 'tool/ptc-dispatch') continue
|
|
1045
1616
|
if (event?.type === 'tool/call' && typeof event.data?.callId === 'string') {
|
|
1046
1617
|
const executable = REPL_TOOL_NAMES.has(event.data.name)
|
|
1047
|
-
let entry = { event: timelineCallEvent(event), eventIndex, scope }
|
|
1618
|
+
let entry = { event: timelineCallEvent(event, retainedCalls ?? state.executableCalls), eventIndex, scope }
|
|
1048
1619
|
if (executable) {
|
|
1049
1620
|
if (isCanonicalSequence(event.seq) && state.executableCalls.has(event.seq)) {
|
|
1050
1621
|
// A sequence collision disproves both sources, but not the earlier
|
|
@@ -1073,7 +1644,7 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
|
|
|
1073
1644
|
editClaims.set(event.seq, target.callSeq)
|
|
1074
1645
|
}
|
|
1075
1646
|
}
|
|
1076
|
-
state.calls = [...state.calls,
|
|
1647
|
+
state.calls = [...state.calls, event.seq]
|
|
1077
1648
|
if (isCanonicalSequence(event.seq)) {
|
|
1078
1649
|
state.executableCalls.set(event.seq, entry)
|
|
1079
1650
|
}
|
|
@@ -1136,7 +1707,12 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
|
|
|
1136
1707
|
clearRunSelection('duplicate-result')
|
|
1137
1708
|
continue
|
|
1138
1709
|
}
|
|
1139
|
-
|
|
1710
|
+
// Non-REPL tools never enter `executableCalls` (only executable REPL calls
|
|
1711
|
+
// are retained there), so their observation keeps the call event itself.
|
|
1712
|
+
// REPL calls keep the digest and resolve the event through executableCalls,
|
|
1713
|
+
// which is why one table has two legal shapes.
|
|
1714
|
+
ordinaryResultSeqs.set(callSeq, timelineJson({
|
|
1715
|
+
...(state.executableCalls.get(callSeq) === undefined ? { call } : { callDigest: eventDigest(call) }),
|
|
1140
1716
|
eventSeq: isCanonicalSequence(event.seq) ? event.seq : callSeq, eventIndex }))
|
|
1141
1717
|
}
|
|
1142
1718
|
if (call !== undefined && typeof callId === 'string' && call.data.callId !== callId) {
|
|
@@ -1183,13 +1759,14 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
|
|
|
1183
1759
|
}
|
|
1184
1760
|
try {
|
|
1185
1761
|
if (call.data.name === 'edit_run_code') {
|
|
1186
|
-
const derived =
|
|
1762
|
+
const derived = normalizeDeferredEditSettlement(meta, entry.editTarget?.callSeq)
|
|
1187
1763
|
normalized = {
|
|
1188
1764
|
...raw,
|
|
1189
1765
|
journal: derived.journal,
|
|
1190
1766
|
derived,
|
|
1191
1767
|
...(derived.userBindings === undefined ? {} : { userBindings: derived.userBindings }),
|
|
1192
1768
|
}
|
|
1769
|
+
requireRecordedDiagnosticWording(meta, derived.journal)
|
|
1193
1770
|
} else if (call.data.name === 'run_code') {
|
|
1194
1771
|
const rawJournal = meta[JOURNAL_KEY]
|
|
1195
1772
|
const resolveLegacyConfirm = rawJournal?.version === LEGACY_JOURNAL_VERSION
|
|
@@ -1203,15 +1780,29 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
|
|
|
1203
1780
|
}
|
|
1204
1781
|
: undefined
|
|
1205
1782
|
const journal = normalizeJournal(rawJournal, { resolveLegacyConfirm })
|
|
1783
|
+
// A throw cell's completion message is the rendering of its own diagnostic.
|
|
1784
|
+
// A record whose message no longer matches was edited after the fact, so it
|
|
1785
|
+
// is not evidence for the bindings that cell declared.
|
|
1786
|
+
{
|
|
1787
|
+
let code
|
|
1788
|
+
try { code = JSON.parse(call.data.arguments)?.code } catch { code = undefined }
|
|
1789
|
+
const firstDiagnostic = journal.diagnostics?.[0]
|
|
1790
|
+
if (journal.completion?.kind === 'throw' && firstDiagnostic !== undefined
|
|
1791
|
+
&& typeof code === 'string'
|
|
1792
|
+
&& journal.completion.error?.message !== renderDiagnostic(firstDiagnostic, code)) {
|
|
1793
|
+
throw new Error('journal completion does not match its diagnostic')
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1206
1796
|
const userBindings = userBindingsForJournal(meta, journal)
|
|
1207
1797
|
normalized = {
|
|
1208
1798
|
...raw,
|
|
1209
1799
|
journal,
|
|
1210
1800
|
...(userBindings === undefined ? {} : { userBindings }),
|
|
1211
1801
|
}
|
|
1802
|
+
requireRecordedDiagnosticWording(meta, journal)
|
|
1212
1803
|
let postEdit
|
|
1213
1804
|
try {
|
|
1214
|
-
postEdit = normalizePostEditFact(state.eventFacts, call, event, normalized)
|
|
1805
|
+
postEdit = normalizePostEditFact(state.eventFacts, call, event, normalized, undefined, state.executableCalls)
|
|
1215
1806
|
} catch (error) {
|
|
1216
1807
|
postEdit = { error: `unproved deferred edit: ${error.message}` }
|
|
1217
1808
|
}
|
|
@@ -1235,7 +1826,7 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
|
|
|
1235
1826
|
if (replayFact.boundaries !== undefined) observation.boundaries = replayFact.boundaries
|
|
1236
1827
|
if (replayFact.boundaryFailure !== undefined) observation.boundaryFailure = replayFact.boundaryFailure
|
|
1237
1828
|
if (normalized.postEdit !== undefined) {
|
|
1238
|
-
const checked = normalizePostEditFact(state.eventFacts, call, event, normalized, normalized.postEdit)
|
|
1829
|
+
const checked = normalizePostEditFact(state.eventFacts, call, event, normalized, normalized.postEdit, state.executableCalls)
|
|
1239
1830
|
if (checked?.error !== undefined || !deepEqualJson(checked, normalized.postEdit)) {
|
|
1240
1831
|
throw new TypeError(`invalid PTC session-log projection deferred edit fact: ${checked?.error ?? 'derived fact changed'}`)
|
|
1241
1832
|
}
|
|
@@ -1251,7 +1842,11 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
|
|
|
1251
1842
|
if (isCanonicalSequence(resultSeq)) state.results.set(resultSeq, timelineJson(normalized))
|
|
1252
1843
|
state.found = true
|
|
1253
1844
|
}
|
|
1254
|
-
const settlement = call === undefined ? undefined : { call, entry
|
|
1845
|
+
const settlement = call === undefined ? undefined : { call, entry: {
|
|
1846
|
+
eventIndex: entry?.eventIndex, scope: entry?.scope,
|
|
1847
|
+
...(entry?.ambiguous === true ? { ambiguous: true } : {}),
|
|
1848
|
+
...(entry?.editTarget === undefined ? {} : { editTarget: entry.editTarget }),
|
|
1849
|
+
}, identity: observation.identity,
|
|
1255
1850
|
observedIndex: eventIndex, ordinary: !prunedReplacement,
|
|
1256
1851
|
...(normalized === undefined ? {} : { normalized }) }
|
|
1257
1852
|
if (settlement !== undefined && entry.scope === scope
|
|
@@ -1272,6 +1867,7 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
|
|
|
1272
1867
|
}
|
|
1273
1868
|
if (settlement !== undefined && isCanonicalSequence(event.seq)) {
|
|
1274
1869
|
settledResultsByEventSeq.set(event.seq, settlement)
|
|
1870
|
+
state.retainedBytes += settlementBytes(settlement)
|
|
1275
1871
|
}
|
|
1276
1872
|
|
|
1277
1873
|
const claimedTarget = editClaims.get(callSeq)
|
|
@@ -1281,8 +1877,9 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
|
|
|
1281
1877
|
}
|
|
1282
1878
|
if (entry === undefined || entry.scope !== scope) continue
|
|
1283
1879
|
const publishRun = () => {
|
|
1284
|
-
const run = settlementRun(settlement)
|
|
1285
|
-
state.latestRun = selectSettlementRun(state.latestRun, settlement, scope, state.runSelectionFrontier
|
|
1880
|
+
const run = settlementRun(settlement, state.executableCalls)
|
|
1881
|
+
state.latestRun = selectSettlementRun(state.latestRun, settlement, scope, state.runSelectionFrontier,
|
|
1882
|
+
state.executableCalls)
|
|
1286
1883
|
state.editableRun = state.latestRun
|
|
1287
1884
|
if (successfulTimelineRun(run)) {
|
|
1288
1885
|
state.lastSuccessfulRunIndex = Math.max(state.lastSuccessfulRunIndex ?? -1, run.index)
|
|
@@ -1302,7 +1899,13 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
|
|
|
1302
1899
|
}
|
|
1303
1900
|
publishRun()
|
|
1304
1901
|
}
|
|
1305
|
-
|
|
1902
|
+
// Digest the serialized fact (undefined fields dropped) so admission can
|
|
1903
|
+
// re-digest exactly the bytes it received.
|
|
1904
|
+
const plainObservation = JSON.parse(JSON.stringify(observation))
|
|
1905
|
+
plainObservation.digest = factDigest(plainObservation)
|
|
1906
|
+
state.eventFacts[state.eventFacts.length - 1] = timelineJson(plainObservation)
|
|
1907
|
+
pruneRetention(state, { settled: settledResultsByEventSeq, executable: state.executableCalls, results: state.results,
|
|
1908
|
+
ordinary: ordinaryResultSeqs })
|
|
1306
1909
|
return Object.fromEntries(Object.entries({ ...state,
|
|
1307
1910
|
executableCalls: state.executableCalls.table, results: state.results.table, editTargets: state.editTargets.table,
|
|
1308
1911
|
pendingByCallId: pendingByCallId.table, seenCallIds: seenCallIds.table,
|
|
@@ -1312,6 +1915,285 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
|
|
|
1312
1915
|
}).filter(([_key, value]) => value !== undefined))
|
|
1313
1916
|
}
|
|
1314
1917
|
|
|
1918
|
+
/**
|
|
1919
|
+
* Refold a session while keeping every payload.
|
|
1920
|
+
*
|
|
1921
|
+
* A consumer that needs a cluster the retention window dropped cannot rebuild it
|
|
1922
|
+
* from the checkpoint. The session log still has the events, so the caller refolds
|
|
1923
|
+
* that slice with pruning disabled and reads the cluster from there. The flag is
|
|
1924
|
+
* scoped to one synchronous fold; the checkpointed state never sees it.
|
|
1925
|
+
*/
|
|
1926
|
+
let fullRetentionDepth = 0
|
|
1927
|
+
export function withFullRetention(run) {
|
|
1928
|
+
fullRetentionDepth += 1
|
|
1929
|
+
try {
|
|
1930
|
+
return run()
|
|
1931
|
+
} finally {
|
|
1932
|
+
fullRetentionDepth -= 1
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
/**
|
|
1937
|
+
* Drop the oldest settlements entirely and fold them into one summary.
|
|
1938
|
+
*
|
|
1939
|
+
* The projection is a fold shortcut, not the log. Keeping a digest stub per settled
|
|
1940
|
+
* cell still made a checkpoint proportional to how many cells the session ran (450
|
|
1941
|
+
* stubs cost ~1.7 MB before any payload), so the whole cluster is dropped instead:
|
|
1942
|
+
* the settlement, its executable call, its ordinary observation and its normalized
|
|
1943
|
+
* result. What survives is one chained summary of what was dropped, which is what
|
|
1944
|
+
* makes the size a function of the window rather than of the session.
|
|
1945
|
+
*
|
|
1946
|
+
* Consumers that need a dropped cell must refold it from the session log; when the
|
|
1947
|
+
* log cannot supply it they report `retention-pruned` instead of guessing.
|
|
1948
|
+
*/
|
|
1949
|
+
/**
|
|
1950
|
+
* A call event whose argument text is gone keeps its identity and the digest of
|
|
1951
|
+
* exactly the bytes it used to carry, so a refold can prove the text it recovers
|
|
1952
|
+
* is the text the checkpoint once held.
|
|
1953
|
+
*/
|
|
1954
|
+
function digestArgumentData(data) {
|
|
1955
|
+
if (!isRecord(data) || typeof data.arguments !== 'string') return data
|
|
1956
|
+
const { arguments: text, ...rest } = data
|
|
1957
|
+
return { ...rest, argumentsDigest: digestOf(text), argumentsBytes: byteLength(text) }
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
/**
|
|
1961
|
+
* A republication clones a call event into its own settlement, so the same call can
|
|
1962
|
+
* appear twice: once in `executableCalls` and once in a settlement that names its
|
|
1963
|
+
* sequence. Whichever copy the retention window digests, every copy of that call must
|
|
1964
|
+
* end up in the same shape, or a checkpoint would describe one call two ways. The
|
|
1965
|
+
* pass is idempotent and runs on every prune, so a clone added after the window moved
|
|
1966
|
+
* still lands on the retained shape — the fold and the replay both go through it.
|
|
1967
|
+
*/
|
|
1968
|
+
function digestSettlementCallCopies(tables) {
|
|
1969
|
+
for (const pair of Object.values(tables.settled.table)) {
|
|
1970
|
+
const [, settlement] = pair
|
|
1971
|
+
if (!isRecord(settlement) || !isRecord(settlement.call)) continue
|
|
1972
|
+
if (typeof settlement.call.data?.arguments !== 'string') continue
|
|
1973
|
+
const retained = tables.executable.get(settlement.call.seq)
|
|
1974
|
+
if (typeof retained?.event?.data?.argumentsDigest !== 'string') continue
|
|
1975
|
+
tables.settled.set(pair[0], { ...settlement, call: { ...settlement.call, data: digestArgumentData(settlement.call.data) } })
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
/**
|
|
1980
|
+
* A journal outside the payload window keeps everything recovery reads — status,
|
|
1981
|
+
* operations, user binding evidence, confirms, completion and per-call metadata —
|
|
1982
|
+
* and replaces each call's argument wire with its digest. Byte-for-byte equality
|
|
1983
|
+
* of argument text is what the window gives up; identity is not.
|
|
1984
|
+
*/
|
|
1985
|
+
export function digestJournalArguments(journal) {
|
|
1986
|
+
if (process.env.DIGEST_SKIP_JOURNAL === '1') return journal
|
|
1987
|
+
if (!isRecord(journal) || !Array.isArray(journal.calls)) return journal
|
|
1988
|
+
return { ...journal, calls: journal.calls.map(call => {
|
|
1989
|
+
if (!isRecord(call) || !Object.hasOwn(call, 'args')) return call
|
|
1990
|
+
const { args, ...rest } = call
|
|
1991
|
+
return { ...rest, argsDigest: digestOf(args), argsBytes: byteLength(args) }
|
|
1992
|
+
}) }
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
function pruneRetention(state, tables) {
|
|
1996
|
+
if (fullRetentionDepth > 0) return
|
|
1997
|
+
const entries = Object.entries(tables.settled.table)
|
|
1998
|
+
.map(([, pair]) => ({ settlement: pair[1], resultSeq: pair[0] }))
|
|
1999
|
+
.filter(entry => entry.settlement !== undefined)
|
|
2000
|
+
.sort((left, right) => left.settlement.observedIndex - right.settlement.observedIndex)
|
|
2001
|
+
if (entries.length === 0) return
|
|
2002
|
+
const limits = retentionLimits()
|
|
2003
|
+
// The count window is order-only so a replayed fold prunes identically. A
|
|
2004
|
+
// configured cluster cap of 0 means the count window never folds anything and
|
|
2005
|
+
// the payload budget is the only bound.
|
|
2006
|
+
const keepClusters = limits.clusters === 0 ? entries.length : Math.min(entries.length, limits.clusters)
|
|
2007
|
+
const keepFrom = Math.max(0, entries.length - keepClusters)
|
|
2008
|
+
// The runs a consumer reads synchronously — the latest publication and the cell
|
|
2009
|
+
// an edit would capture — stay inside the window even when newer settlements
|
|
2010
|
+
// pushed them past the count. Dropping them would turn "editable" into "pruned"
|
|
2011
|
+
// for the cell the user is actually looking at.
|
|
2012
|
+
const pinnedCallSeqs = new Set([state.latestRun?.callSeq, state.editableRun?.callSeq]
|
|
2013
|
+
.filter(callSeq => isCanonicalSequence(callSeq)))
|
|
2014
|
+
// Only a cluster beyond the configured cluster cap is folded into the interval
|
|
2015
|
+
// summary. Every other cluster stays, with its payload text replaced by digests,
|
|
2016
|
+
// so recovery evidence never depends on refolding the session log.
|
|
2017
|
+
const folded = new Set(entries.slice(0, keepFrom)
|
|
2018
|
+
.filter(entry => pinnedCallSeqs.has(entry.settlement.call?.seq) === false)
|
|
2019
|
+
.map(entry => entry.resultSeq))
|
|
2020
|
+
const mustPrune = new Set(folded)
|
|
2021
|
+
// A cumulative byte cap, not only a per-entry one: the window has to be a constant
|
|
2022
|
+
// regardless of how large the retained cells are, otherwise 32 cells of 200 KB
|
|
2023
|
+
// still put 6.4 MB in every checkpoint. Walk newest-first and stop retaining once
|
|
2024
|
+
// the payload budget is spent. A single entry larger than the whole budget is
|
|
2025
|
+
// dropped too: the bound wins over keeping the hot cell, and the edit path refolds
|
|
2026
|
+
// that cell from the session log when it is needed.
|
|
2027
|
+
const retainedPayloadBytes = (entry, executable = tables.executable) => {
|
|
2028
|
+
const callSeq = entry.settlement.call?.seq
|
|
2029
|
+
const stored = callSeq === undefined ? undefined : executable.get(callSeq)
|
|
2030
|
+
const executableArguments = stored?.event?.data?.arguments
|
|
2031
|
+
return settlementBytes(entry.settlement)
|
|
2032
|
+
+ (typeof executableArguments === 'string' ? byteLength(executableArguments) : 0)
|
|
2033
|
+
}
|
|
2034
|
+
let retainedTotal = 0
|
|
2035
|
+
const newestFirst = [...entries].reverse()
|
|
2036
|
+
for (const [index, entry] of newestFirst.entries()) {
|
|
2037
|
+
const bytes = retainedPayloadBytes(entry)
|
|
2038
|
+
const pinned = pinnedCallSeqs.has(entry.settlement.call?.seq)
|
|
2039
|
+
// The run a consumer reads synchronously keeps its text even past the count
|
|
2040
|
+
// window: the window bounds how much history stays readable, never the cell the
|
|
2041
|
+
// user is looking at. Only the byte budget can digest a pinned cell.
|
|
2042
|
+
const outsidePayloadWindow = index >= limits.runs && !pinned
|
|
2043
|
+
const overBudget = bytes > limits.bytes
|
|
2044
|
+
if (overBudget || outsidePayloadWindow || (index > 0 && !pinned && retainedTotal + bytes > limits.bytes)) {
|
|
2045
|
+
mustPrune.add(entry.resultSeq)
|
|
2046
|
+
continue
|
|
2047
|
+
}
|
|
2048
|
+
retainedTotal += bytes
|
|
2049
|
+
}
|
|
2050
|
+
if (mustPrune.size === 0) return
|
|
2051
|
+
const droppedCallSeqs = new Set()
|
|
2052
|
+
const droppedResultSeqs = new Set()
|
|
2053
|
+
const dropped = []
|
|
2054
|
+
const digested = new Set([...mustPrune].filter(resultSeq => !folded.has(resultSeq)))
|
|
2055
|
+
const digestedResultSeqs = new Set()
|
|
2056
|
+
const digestedHotCell = state.latestRun !== undefined && entries.some(entry => (
|
|
2057
|
+
digested.has(entry.resultSeq) && entry.settlement.call?.seq === state.latestRun?.callSeq))
|
|
2058
|
+
for (const entry of entries) {
|
|
2059
|
+
if (!digested.has(entry.resultSeq)) continue
|
|
2060
|
+
const callSeq = entry.settlement.call?.seq
|
|
2061
|
+
if (callSeq !== undefined && tables.executable.has(callSeq)) {
|
|
2062
|
+
const stored = tables.executable.get(callSeq)
|
|
2063
|
+
tables.executable.set(callSeq, { ...stored, event: { ...stored.event, data: digestArgumentData(stored.event.data) } })
|
|
2064
|
+
digestSettlementCallCopies(tables)
|
|
2065
|
+
}
|
|
2066
|
+
// The result keeps every field recovery reads — status, operations, user
|
|
2067
|
+
// binding evidence, confirms, completion and call metadata — and loses only
|
|
2068
|
+
// the argument wire of each call.
|
|
2069
|
+
for (const key of [entry.resultSeq, callSeq]) {
|
|
2070
|
+
if (key === undefined || !tables.results.has(key)) continue
|
|
2071
|
+
const result = tables.results.get(key)
|
|
2072
|
+
if (!isRecord(result) || result.journal === undefined) continue
|
|
2073
|
+
tables.results.set(key, { ...result, journal: digestJournalArguments(result.journal) })
|
|
2074
|
+
}
|
|
2075
|
+
// The settlement keeps its normalized and publication entries because recovery
|
|
2076
|
+
// and the run derivations read them; only the journal argument wire and the
|
|
2077
|
+
// derived source are replaced by digests, which is exactly the payload the
|
|
2078
|
+
// window bounds. Identity, observations and result metadata stay.
|
|
2079
|
+
const digestJournalEntry = value => value === undefined ? undefined : {
|
|
2080
|
+
...value,
|
|
2081
|
+
...(value.journal === undefined ? {} : { journal: digestJournalArguments(value.journal) }),
|
|
2082
|
+
|
|
2083
|
+
}
|
|
2084
|
+
tables.settled.set(entry.resultSeq, {
|
|
2085
|
+
...entry.settlement,
|
|
2086
|
+
...(callSeq === undefined
|
|
2087
|
+
? {}
|
|
2088
|
+
: { call: { ...entry.settlement.call, data: digestArgumentData(entry.settlement.call.data) } }),
|
|
2089
|
+
...(entry.settlement.normalized === undefined
|
|
2090
|
+
? {}
|
|
2091
|
+
: { normalized: digestJournalEntry(entry.settlement.normalized) }),
|
|
2092
|
+
...(entry.settlement.publication === undefined
|
|
2093
|
+
? {}
|
|
2094
|
+
: { publication: digestJournalEntry(entry.settlement.publication) }),
|
|
2095
|
+
})
|
|
2096
|
+
digestedResultSeqs.add(Number(entry.resultSeq))
|
|
2097
|
+
state.retainedBytes = Math.max(0, state.retainedBytes - settlementBytes(entry.settlement))
|
|
2098
|
+
}
|
|
2099
|
+
for (const entry of entries) {
|
|
2100
|
+
if (!mustPrune.has(entry.resultSeq)) continue
|
|
2101
|
+
if (!folded.has(entry.resultSeq)) continue
|
|
2102
|
+
const callSeq = entry.settlement.call?.seq
|
|
2103
|
+
dropped.push(summaryEntry(entry.settlement, Number(entry.resultSeq)))
|
|
2104
|
+
tables.settled.delete(entry.resultSeq)
|
|
2105
|
+
if (callSeq !== undefined) {
|
|
2106
|
+
tables.executable.delete(callSeq)
|
|
2107
|
+
if (tables.ordinary?.has(callSeq)) tables.ordinary.delete(callSeq)
|
|
2108
|
+
droppedCallSeqs.add(callSeq)
|
|
2109
|
+
}
|
|
2110
|
+
// `results` is keyed by the RESULT sequence on the ordinary path and by the CALL
|
|
2111
|
+
// sequence on the republication path, so drop both keys when either exists.
|
|
2112
|
+
for (const key of [entry.resultSeq, callSeq]) {
|
|
2113
|
+
if (key === undefined) continue
|
|
2114
|
+
if (tables.results.has(key)) tables.results.delete(key)
|
|
2115
|
+
}
|
|
2116
|
+
droppedResultSeqs.add(entry.resultSeq)
|
|
2117
|
+
state.retainedBytes = Math.max(0, state.retainedBytes - settlementBytes(entry.settlement))
|
|
2118
|
+
}
|
|
2119
|
+
// A digested cluster kept its identity, so the ordered call list keeps its seq;
|
|
2120
|
+
// a folded cluster is gone and takes its seq with it.
|
|
2121
|
+
state.calls = state.calls.filter(callSeq => !droppedCallSeqs.has(callSeq))
|
|
2122
|
+
// The hot run can be lost either way: a folded cluster no longer exists, and a
|
|
2123
|
+
// digested one no longer holds the source a run is built from. Consumers that
|
|
2124
|
+
// need it refold from the session log, and report the notice until they do.
|
|
2125
|
+
if (state.latestRun !== undefined
|
|
2126
|
+
&& (droppedCallSeqs.has(state.latestRun.callSeq) || digestedHotCell)) {
|
|
2127
|
+
state.latestRun = undefined
|
|
2128
|
+
state.editableRun = undefined
|
|
2129
|
+
}
|
|
2130
|
+
// `hotCellDropped` describes the retained set, not the route the fold took to it.
|
|
2131
|
+
// It holds exactly when the newest settlement no longer carries its payload:
|
|
2132
|
+
// either it was folded away, or its argument text was digested. Recomputing it
|
|
2133
|
+
// from the table on every prune keeps a replayed fold — which reaches the same set
|
|
2134
|
+
// by a different route — from disagreeing with the stored one.
|
|
2135
|
+
const newestEntry = entries[entries.length - 1]
|
|
2136
|
+
const newestStored = newestEntry === undefined ? undefined : tables.settled.get(newestEntry.resultSeq)
|
|
2137
|
+
state.hotCellDropped = newestEntry !== undefined
|
|
2138
|
+
&& (newestStored === undefined || newestStored.call?.data?.arguments === undefined)
|
|
2139
|
+
// `retainedBytes` is a property of the retained set, not a running total: a fold
|
|
2140
|
+
// that digests a cluster after it was counted would otherwise carry a different
|
|
2141
|
+
// value than a fold that reaches the same set by another route. Recompute it from
|
|
2142
|
+
// the table so both sides agree, and so the number always describes what is there.
|
|
2143
|
+
state.retainedBytes = entries.reduce((total, entry) => {
|
|
2144
|
+
const stored = tables.settled.get(entry.resultSeq)
|
|
2145
|
+
return stored === undefined ? total : total + settlementBytes(stored)
|
|
2146
|
+
}, 0)
|
|
2147
|
+
if (dropped.length === 0 && digested.size === 0) return
|
|
2148
|
+
if (dropped.length !== 0) {
|
|
2149
|
+
state.compacted = true
|
|
2150
|
+
dropped.sort((left, right) => left.resultSeq - right.resultSeq)
|
|
2151
|
+
state.retentionSummary = extendSummary(state.retentionSummary, dropped)
|
|
2152
|
+
}
|
|
2153
|
+
// The facts of a folded cluster keep their identity and digests but lose the
|
|
2154
|
+
// payload text, for the same reason the cluster itself is gone.
|
|
2155
|
+
// A fact per event is itself linear in the session length, so a folded cluster
|
|
2156
|
+
// takes its tool/call and tool/result facts with it. Admission for a compacted
|
|
2157
|
+
// checkpoint proves the summary instead of replaying those facts.
|
|
2158
|
+
state.eventFacts = state.eventFacts.filter(fact => {
|
|
2159
|
+
if (fact.event?.type === 'tool/call' && droppedCallSeqs.has(fact.event.seq)) return false
|
|
2160
|
+
if (fact.normalized !== undefined && droppedResultSeqs.has(Number(fact.normalized.eventSeq))) return false
|
|
2161
|
+
return true
|
|
2162
|
+
})
|
|
2163
|
+
// A digested cluster keeps its facts, but they carry the digest-only journal too:
|
|
2164
|
+
// the fact list is a second copy of the same result content. Rewriting the payload
|
|
2165
|
+
// invalidates the fact's own digest, so it is recomputed here — admission checks
|
|
2166
|
+
// that digest against the bytes it receives.
|
|
2167
|
+
state.eventFacts = state.eventFacts.map(fact => {
|
|
2168
|
+
if (fact.normalized === undefined || !digestedResultSeqs.has(Number(fact.normalized.eventSeq))) return fact
|
|
2169
|
+
const { digest: _digest, ...rest } = fact
|
|
2170
|
+
const next = { ...rest, normalized: digestJournalArguments(fact.normalized) }
|
|
2171
|
+
next.digest = factDigest(next)
|
|
2172
|
+
return next
|
|
2173
|
+
})
|
|
2174
|
+
// A dispatch fact names the cell an edit would capture, and that target carries
|
|
2175
|
+
// the cell's source. Once the payload is digested the fold can no longer derive
|
|
2176
|
+
// that source, so the fact must record the same "no target" the fold would
|
|
2177
|
+
// produce now; leaving the old text behind would both duplicate the payload and
|
|
2178
|
+
// fail the round-trip check that compares the fact with its re-derivation.
|
|
2179
|
+
const digestedCallSeqs = new Set(entries
|
|
2180
|
+
.filter(entry => digested.has(entry.resultSeq))
|
|
2181
|
+
.map(entry => Number(entry.settlement.call?.seq)))
|
|
2182
|
+
if (digestedCallSeqs.size !== 0) {
|
|
2183
|
+
let rewritten = false
|
|
2184
|
+
state.eventFacts = state.eventFacts.map(fact => {
|
|
2185
|
+
const target = fact.selectedTarget
|
|
2186
|
+
if (!isRecord(target) || !digestedCallSeqs.has(Number(target.callSeq))) return fact
|
|
2187
|
+
const { digest: _digest, ...rest } = fact
|
|
2188
|
+
const next = { ...rest, selectedTarget: null }
|
|
2189
|
+
next.digest = factDigest(next)
|
|
2190
|
+
rewritten = true
|
|
2191
|
+
return timelineJson(next)
|
|
2192
|
+
})
|
|
2193
|
+
if (rewritten) state.eventFacts = state.eventFacts.map(fact => timelineJson(fact))
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
|
|
1315
2197
|
export function sessionTimelineValue(checkpoint) {
|
|
1316
2198
|
return { ...checkpoint,
|
|
1317
2199
|
executableCalls: new Map(Object.values(checkpoint.executableCalls)),
|
|
@@ -1334,10 +2216,17 @@ export function foldSessionTimeline(events) {
|
|
|
1334
2216
|
export function recoverJournal(session, currentCallSeq, options = {}) {
|
|
1335
2217
|
const events = sessionEvents(session)
|
|
1336
2218
|
if (!Array.isArray(events)) {
|
|
1337
|
-
return { nodes: [], head: undefined, checkpoints: new Map(), volatileSuffix: [], available: true }
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
|
-
|
|
2219
|
+
return { nodes: [], head: undefined, checkpoints: new Map(), volatileSuffix: [], provenBindings: [], available: true }
|
|
2220
|
+
}
|
|
2221
|
+
// Recovery rebuilds bindings from the cell sources and journals. A checkpoint
|
|
2222
|
+
// outside the payload window keeps those as digests, so the fold is repeated with
|
|
2223
|
+
// retention disabled on the same slice: the log is the authority, and reading it
|
|
2224
|
+
// costs one extra fold only when a digested cluster is present.
|
|
2225
|
+
const folded = foldSessionTimeline(events)
|
|
2226
|
+
const needsPayload = [...folded.executableCalls.values()]
|
|
2227
|
+
.some(entry => typeof entry?.event?.data?.arguments !== 'string')
|
|
2228
|
+
const timeline = needsPayload ? withFullRetention(() => foldSessionTimeline(events)) : folded
|
|
2229
|
+
const calls = timelineCalls(timeline).filter(call => call.seq !== currentCallSeq)
|
|
1341
2230
|
const { executableCalls, results, found } = timeline
|
|
1342
2231
|
const visibleCalls = options?.visibleCallSeqs
|
|
1343
2232
|
const appliedBoundaries = options?.[RECOVERY_BOUNDARY_EVIDENCE]
|
|
@@ -1407,7 +2296,11 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
|
|
|
1407
2296
|
const records = []
|
|
1408
2297
|
const orderedRecords = []
|
|
1409
2298
|
const contractedCallRanges = []
|
|
1410
|
-
|
|
2299
|
+
// A live environment that was released cannot lend its recorded live-only
|
|
2300
|
+
// values to the environment that replaces it. The flag belongs to the fold, so
|
|
2301
|
+
// every re-fold below carries it.
|
|
2302
|
+
const foldOptions = { liveOnlyLost: options?.liveOnlyLost === true }
|
|
2303
|
+
let state = foldRecords(records, invalidCallSeqs, foldOptions)
|
|
1411
2304
|
let boundaryIndex = 0
|
|
1412
2305
|
const carrierCallSeqForBoundary = boundary => boundary.carrierCallSeq
|
|
1413
2306
|
const isContractedCallSeq = (callSeq) => contractedCallRanges.some(range => (
|
|
@@ -1428,6 +2321,7 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
|
|
|
1428
2321
|
if (record.call.seq >= contractionSeq) invalidCallSeqs.add(record.call.seq)
|
|
1429
2322
|
}
|
|
1430
2323
|
state = foldRecords(records, invalidCallSeqs, {
|
|
2324
|
+
...foldOptions,
|
|
1431
2325
|
surfaceContracted: state.surfaceContracted,
|
|
1432
2326
|
})
|
|
1433
2327
|
registerUnavailable({ seq: carrierCallSeq ?? boundary.failedCallSeq, reason }, state)
|
|
@@ -1487,6 +2381,7 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
|
|
|
1487
2381
|
}
|
|
1488
2382
|
}
|
|
1489
2383
|
state = foldRecords(records, invalidCallSeqs, {
|
|
2384
|
+
...foldOptions,
|
|
1490
2385
|
surfaceContracted: resolvesUnavailable ? false : state.surfaceContracted,
|
|
1491
2386
|
})
|
|
1492
2387
|
if (resolvesUnavailable) {
|
|
@@ -1510,7 +2405,10 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
|
|
|
1510
2405
|
if (result.postEditError !== undefined) {
|
|
1511
2406
|
invalidResultReasons.set(call.seq, result.postEditError)
|
|
1512
2407
|
registerUnavailable({ seq: call.seq, reason: result.postEditError })
|
|
1513
|
-
} else if (result.postEdit !== undefined) {
|
|
2408
|
+
} else if (result.postEdit !== undefined && result.postEdit.derived.unexecuted !== true) {
|
|
2409
|
+
// A derived execution that never ran proves no state of its own, so it is
|
|
2410
|
+
// not folded as a record; the root cell's own record already carries the
|
|
2411
|
+
// proof of the accepted edit.
|
|
1514
2412
|
const postEdit = result.postEdit
|
|
1515
2413
|
orderedRecords.push({
|
|
1516
2414
|
call: timeline.eventFacts.find(fact => fact.event.seq === postEdit.executionStartSeq).event,
|
|
@@ -1566,13 +2464,25 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
|
|
|
1566
2464
|
}
|
|
1567
2465
|
applyBoundariesBefore(Number.POSITIVE_INFINITY)
|
|
1568
2466
|
if (unavailableBoundary !== undefined) registerUnavailable(unavailableBoundary, state)
|
|
1569
|
-
|
|
2467
|
+
// The names the session log proves stay off the enumerable history shape so
|
|
2468
|
+
// every existing consumer sees the same object it saw before; recovery-only
|
|
2469
|
+
// readers (the missing-binding notice) reach them through this property.
|
|
2470
|
+
const history = {
|
|
1570
2471
|
nodes: state.nodes,
|
|
1571
2472
|
head: state.head,
|
|
1572
2473
|
checkpoints: state.checkpoints,
|
|
1573
2474
|
volatileSuffix: state.volatileSuffix,
|
|
1574
2475
|
available: found && !state.surfaceContracted && unavailableBoundary === undefined,
|
|
1575
2476
|
}
|
|
2477
|
+
history.provenBindings = [...state.provenBindings]
|
|
2478
|
+
// Recovery-only, like the proved names: the enumerable history shape is a
|
|
2479
|
+
// contract other readers compare against, so the deletion set stays off it.
|
|
2480
|
+
Object.defineProperty(history, 'deletedBindings', {
|
|
2481
|
+
value: [...state.deletedBindings],
|
|
2482
|
+
enumerable: false,
|
|
2483
|
+
configurable: true,
|
|
2484
|
+
})
|
|
2485
|
+
return history
|
|
1576
2486
|
}
|
|
1577
2487
|
|
|
1578
2488
|
/** Prove that recovery consumes every persisted boundary at its declared frontier. */
|