dsh-ptc-plus 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +10 -4
- package/README.md +10 -4
- package/client.cjs +1623 -104
- package/compiler-core.cjs +132 -132
- package/compiler-core.cjs.map +3 -3
- package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +2 -0
- package/docs/adr/0017-track-the-latest-dsh-public-surface.md +4 -2
- package/docs/adr/0019-plugin-settings-and-kill-switch.md +2 -2
- package/docs/adr/0023-global-user-bindings.md +12 -0
- package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +11 -1
- package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +1 -1
- package/docs/adr/0030-serve-both-typert-codec-generations-from-one-codec.md +2 -2
- package/docs/adr/0032-own-session-log-facts-in-a-host-only-projection.md +31 -1
- package/docs/adr/0044-coexist-with-the-official-persistent-repl.md +4 -4
- package/docs/adr/0045-customize-model-visible-prompt-text.md +38 -0
- package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +35 -0
- package/docs/adr/0047-store-journal-argument-digests-in-the-projection.md +88 -0
- package/docs/adr/0048-supersede-a-rewritten-cell.md +48 -0
- package/docs/adr/0049-prove-a-settlement-against-its-published-record.md +53 -0
- package/docs/adr/0050-read-host-surfaces-through-their-published-contract.md +48 -0
- package/docs/adr/README.md +7 -1
- package/docs/architecture.md +3 -3
- package/docs/client-ui.md +5 -2
- package/docs/durability-design.md +15 -2
- package/docs/installation.md +9 -9
- package/docs/releases/v0.5.2.md +91 -0
- package/docs/releases/v0.5.3.md +77 -0
- package/docs/runtime-reference.md +43 -3
- package/docs/unmeasured-cell-recovery-repair-proposal.md +387 -0
- package/docs/user-bindings.en.md +15 -0
- package/docs/user-bindings.md +15 -0
- package/docs/v0.5.3-goals.md +117 -0
- package/docs/verification.md +7 -3
- package/index.js +100 -31
- package/internal/binding-adoption.js +136 -0
- package/internal/bounded-literal.js +85 -0
- package/internal/capability-command-receipt.js +18 -4
- package/internal/capability-delivery-proof.js +70 -0
- package/internal/capability-environment.js +200 -11
- package/internal/capability-plan.js +11 -8
- package/internal/cell-error.js +13 -0
- package/internal/config-spec.js +126 -6
- package/internal/cordis-tools-owner.js +2 -1
- package/internal/direct-surface-owner.js +39 -21
- package/internal/edit-transport-owner.js +81 -15
- package/internal/execution-provider-owner.js +6 -0
- package/internal/execution-seam-compat.js +25 -0
- package/internal/host-tool-sdk.js +308 -0
- package/internal/isolated-worker.js +134 -17
- package/internal/kernel-worker.js +167 -3
- package/internal/legacy-cell-analysis.js +14 -2
- package/internal/legacy-repl-convenience.js +16 -3
- package/internal/official-repl-priority.js +31 -10
- package/internal/program-bindings.js +12 -1
- package/internal/projection-retention.js +238 -0
- package/internal/prompt-text.js +858 -0
- package/internal/recovery-tips.js +50 -11
- package/internal/rejected-cell-editor.js +3 -2
- package/internal/repl-memory-projection.js +21 -2
- package/internal/runtime-bridge-owner.js +114 -9
- package/internal/runtime-config.js +2 -0
- package/internal/runtime-contexts.js +14 -5
- package/internal/runtime-messages.js +48 -15
- package/internal/session-cell-executor.js +132 -11
- package/internal/session-journal-recovery.js +1204 -116
- package/internal/session-journal-schema.js +10 -5
- package/internal/session-journal.js +214 -14
- package/internal/session-log-view.js +133 -26
- package/internal/session-runtime.js +746 -38
- package/internal/session-state.js +27 -0
- package/internal/stateful-root-compiler.js +11 -0
- package/internal/stateful-root-runtime.js +17 -0
- package/internal/user-binding-console.js +7 -0
- package/internal/user-bindings-owner.js +112 -6
- package/internal/user-bindings.js +2 -2
- package/internal/validated-parse-repair.js +6 -1
- package/internal/worker-client.js +27 -4
- package/package.json +16 -6
- package/src/client-activity.js +24 -4
- package/src/client-capabilities-view.js +504 -27
- package/src/client-copy.js +187 -2
- package/src/client-indicator-view.js +2 -1
- package/src/client-repl-view.js +35 -11
- package/src/client-styles.js +64 -2
- package/src/client.js +3 -1
|
@@ -7,6 +7,11 @@ import {
|
|
|
7
7
|
JOURNAL_KEY,
|
|
8
8
|
RECOVERY_BOUNDARY_KEY,
|
|
9
9
|
RECOVERY_BOUNDARY_EVENT,
|
|
10
|
+
DIAGNOSTIC_WORDING_KEY,
|
|
11
|
+
DIAGNOSTIC_WORDING_FIELDS,
|
|
12
|
+
SETTLEMENT_PROOF_KEY,
|
|
13
|
+
SETTLEMENT_PROOF_FIELDS,
|
|
14
|
+
SETTLEMENT_PROOF_VERSION,
|
|
10
15
|
LEGACY_JOURNAL_VERSION,
|
|
11
16
|
REPL_TOOL_NAMES,
|
|
12
17
|
REWRITES_KEY,
|
|
@@ -15,16 +20,23 @@ import {
|
|
|
15
20
|
POST_EDIT_KEY,
|
|
16
21
|
} from './session-journal-schema.js'
|
|
17
22
|
import {
|
|
23
|
+
diagnosticWordingDigest,
|
|
18
24
|
isCanonicalSequence,
|
|
19
|
-
|
|
25
|
+
normalizeDeferredEditSettlement,
|
|
20
26
|
normalizeJournal,
|
|
21
27
|
normalizeRecoveryBoundaries,
|
|
22
28
|
reduceStateOperations,
|
|
29
|
+
settlementProofDigest,
|
|
23
30
|
userBindingsForJournal,
|
|
24
31
|
validatedRewrites,
|
|
25
32
|
} from './session-journal.js'
|
|
26
33
|
import { decodeValue } from './value-wire.js'
|
|
27
34
|
import { editRejectedCell } from './rejected-cell-editor.js'
|
|
35
|
+
import { renderDiagnostic } from './diagnostic.js'
|
|
36
|
+
import {
|
|
37
|
+
byteLength, chainOfEntries, digestOf, extendSummary, retainedClusterCount, retentionLimits, settlementBytes,
|
|
38
|
+
summaryEntry, summaryRecordOf,
|
|
39
|
+
} from './projection-retention.js'
|
|
28
40
|
|
|
29
41
|
const RECOVERY_BOUNDARY_EVIDENCE = Symbol('recoveryBoundaryEvidence')
|
|
30
42
|
const RUN_SELECTION_CLEAR_REASONS = new Set([
|
|
@@ -39,9 +51,26 @@ const MODEL_VISIBLE_SURFACE_EVENTS = new Set([
|
|
|
39
51
|
'tool/result',
|
|
40
52
|
])
|
|
41
53
|
|
|
42
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Argument text for a call, resolved through its canonical owner.
|
|
56
|
+
*
|
|
57
|
+
* `executableCalls` retains the text once; a call event that still carries its
|
|
58
|
+
* own copy (the live fold, or an uncompacted checkpoint) is read directly. A
|
|
59
|
+
* compacted event and a pruned entry hold only digests, so derivations see
|
|
60
|
+
* `undefined` and must treat the run as unavailable rather than guess.
|
|
61
|
+
*/
|
|
62
|
+
function callArguments(call, executableCalls) {
|
|
63
|
+
if (typeof call?.data?.arguments === 'string') return call.data.arguments
|
|
64
|
+
const entry = executableCalls?.get?.(call?.seq)
|
|
65
|
+
const stored = entry?.event?.data?.arguments
|
|
66
|
+
return typeof stored === 'string' ? stored : undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sourceForRunCall(call, executableCalls) {
|
|
70
|
+
const text = callArguments(call, executableCalls)
|
|
71
|
+
if (text === undefined) return undefined
|
|
43
72
|
try {
|
|
44
|
-
const args = JSON.parse(
|
|
73
|
+
const args = JSON.parse(text)
|
|
45
74
|
return isRecord(args) && typeof args.code === 'string' ? args.code : undefined
|
|
46
75
|
} catch {
|
|
47
76
|
return undefined
|
|
@@ -68,8 +97,54 @@ export function isConfirmableNoop(timeline, callSeq, resultEventIndex) {
|
|
|
68
97
|
return call !== undefined && call.eventIndex < resultEventIndex && !timeline.results.has(callSeq)
|
|
69
98
|
}
|
|
70
99
|
|
|
100
|
+
/** Node indices reachable from a head by following parents. */
|
|
101
|
+
function reachableFrom(nodes, head) {
|
|
102
|
+
const reachable = new Set()
|
|
103
|
+
for (let cursor = head; cursor !== undefined && !reachable.has(cursor);) {
|
|
104
|
+
reachable.add(cursor)
|
|
105
|
+
cursor = nodes[cursor]?.parent
|
|
106
|
+
}
|
|
107
|
+
return reachable
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Binding names a settlement journal recorded.
|
|
112
|
+
*
|
|
113
|
+
* The recorded values are the only evidence that names a durable binding today;
|
|
114
|
+
* `unrecordedBindings` names the ones the codec could not transfer, and a
|
|
115
|
+
* `kind: "binding"` save names one explicitly. A checkpoint save never counts.
|
|
116
|
+
*/
|
|
117
|
+
function journalBindingNames(journal) {
|
|
118
|
+
if (journal === undefined) return []
|
|
119
|
+
return [...new Set([
|
|
120
|
+
...(journal.values ?? []).map(value => value.name),
|
|
121
|
+
...(journal.unrecordedBindings ?? []),
|
|
122
|
+
...bindingNamesOfOperations(journal.operations),
|
|
123
|
+
].filter(name => typeof name === 'string'))]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Binding names an installed node recorded. */
|
|
127
|
+
function recordedBindingNames(node) {
|
|
128
|
+
return journalBindingNames(node?.journal)
|
|
129
|
+
}
|
|
130
|
+
|
|
71
131
|
function applyOperations(state, operations, nodeIndex) {
|
|
132
|
+
const previousHead = state.head
|
|
72
133
|
const transition = reduceStateOperations(state, operations, nodeIndex)
|
|
134
|
+
if (transition.restored) {
|
|
135
|
+
const reachableBefore = reachableFrom(state.nodes, previousHead)
|
|
136
|
+
const reachableAfter = reachableFrom(state.nodes, transition.head)
|
|
137
|
+
for (const index of reachableBefore) {
|
|
138
|
+
if (reachableAfter.has(index)) continue
|
|
139
|
+
for (const name of recordedBindingNames(state.nodes[index])) state.deletedBindings.add(name)
|
|
140
|
+
}
|
|
141
|
+
// The cutting node is not reachable from the new head either: the rewind it
|
|
142
|
+
// performed ends whatever that same cell created before the cut, so its own
|
|
143
|
+
// names belong to the recorded deletion rather than to an omission.
|
|
144
|
+
if (nodeIndex !== undefined) {
|
|
145
|
+
for (const name of recordedBindingNames(state.nodes[nodeIndex])) state.deletedBindings.add(name)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
73
148
|
state.head = transition.head
|
|
74
149
|
state.checkpoints = transition.checkpoints
|
|
75
150
|
if (transition.restored) {
|
|
@@ -78,44 +153,217 @@ function applyOperations(state, operations, nodeIndex) {
|
|
|
78
153
|
}
|
|
79
154
|
}
|
|
80
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Adopt the relation a record states about the cell it revised.
|
|
158
|
+
*
|
|
159
|
+
* The relation belongs to the fold, because only the fold knows the path that
|
|
160
|
+
* builds the environment. An accepted edit claims the revision *replaces* the cell
|
|
161
|
+
* it edited, so the revision takes that cell's place in the recorded order: it
|
|
162
|
+
* continues from the target's parent and every cell that followed the target
|
|
163
|
+
* continues from the revision. Appending the revision after those cells instead
|
|
164
|
+
* leaves them replaying an environment the edit already rewrote, with the
|
|
165
|
+
* declarations they read gone and the revision not yet in place.
|
|
166
|
+
*
|
|
167
|
+
* `installed` says whether this settlement puts a cell on the path. A contracted
|
|
168
|
+
* revision installs nothing, so the cells that followed the target bridge to the
|
|
169
|
+
* target's parent, and only a head that was the target itself has to move.
|
|
170
|
+
*
|
|
171
|
+
* The returned record of what changed is what lets a rejected settlement put the
|
|
172
|
+
* path back: nothing this adoption did survives a settlement the fold refuses.
|
|
173
|
+
*/
|
|
174
|
+
function adoptSupersession(state, record, { installed }) {
|
|
175
|
+
const superseded = state.editRewritesHistory && record.supersedes !== undefined
|
|
176
|
+
? state.nodes.findIndex(node => node.callSeq === record.supersedes) : -1
|
|
177
|
+
const adoption = {
|
|
178
|
+
superseded,
|
|
179
|
+
previousHead: state.head,
|
|
180
|
+
supersededBindings: new Set(state.supersededBindings),
|
|
181
|
+
supersessions: state.supersessions.length,
|
|
182
|
+
children: [],
|
|
183
|
+
parent: state.head,
|
|
184
|
+
head: undefined,
|
|
185
|
+
}
|
|
186
|
+
if (superseded < 0) {
|
|
187
|
+
// No relation: the settlement appends to the head, as it always has.
|
|
188
|
+
adoption.head = installed ? undefined : state.head
|
|
189
|
+
return adoption
|
|
190
|
+
}
|
|
191
|
+
const parent = state.nodes[superseded].parent
|
|
192
|
+
adoption.parent = parent
|
|
193
|
+
for (let child = 0; child < state.nodes.length; child += 1) {
|
|
194
|
+
if (state.nodes[child].parent === superseded) adoption.children.push(child)
|
|
195
|
+
}
|
|
196
|
+
// The head is the last recorded cell. It keeps its place when the target is on
|
|
197
|
+
// its path behind other cells, because those cells now reach the same head
|
|
198
|
+
// through the revision; otherwise the revision is the newest cell and the head.
|
|
199
|
+
const targetOnPath = reachableFrom(state.nodes, state.head).has(superseded)
|
|
200
|
+
if (installed) adoption.head = targetOnPath && state.head !== superseded ? state.head : undefined
|
|
201
|
+
else adoption.head = state.head === superseded ? parent : state.head
|
|
202
|
+
return adoption
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Record the relation as the fact the model is owed.
|
|
207
|
+
*
|
|
208
|
+
* Only names the record proves count. A durable declaration that the replay
|
|
209
|
+
* re-establishes is not evidence about the superseded cell, and claiming it
|
|
210
|
+
* would report a binding this fold cannot see. What the revision supplies is the
|
|
211
|
+
* caller's answer, because a settlement the fold refused to install supplies
|
|
212
|
+
* nothing; a cell the fold installs supplies the names its journal recorded.
|
|
213
|
+
*/
|
|
214
|
+
function recordSupersession(state, record, adoption, suppliedNames) {
|
|
215
|
+
if (adoption.superseded < 0) return
|
|
216
|
+
const supplied = new Set(suppliedNames)
|
|
217
|
+
for (const name of recordedBindingNames(state.nodes[adoption.superseded])) {
|
|
218
|
+
if (!supplied.has(name)) state.supersededBindings.add(name)
|
|
219
|
+
}
|
|
220
|
+
state.supersessions.push(Object.freeze({
|
|
221
|
+
supersededCallSeq: record.supersedes,
|
|
222
|
+
supersedingCallSeq: record.call.seq,
|
|
223
|
+
}))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Fold one accepted record as a settlement of the recorded path.
|
|
228
|
+
*
|
|
229
|
+
* `installed` is false for a settlement the fold contracts: it puts no cell on the
|
|
230
|
+
* path and supplies no name, while the relation it states is still the log's fact.
|
|
231
|
+
* Either way the relation is adopted first and the state operations are applied
|
|
232
|
+
* last, so a settlement that throws leaves the path, the children it re-parented
|
|
233
|
+
* and the relation it recorded exactly as it found them.
|
|
234
|
+
*/
|
|
235
|
+
function applyAdoptedRecord(state, record, { journal, installed = true, restoredFromRecord = false }) {
|
|
236
|
+
const adoption = adoptSupersession(state, record, { installed })
|
|
237
|
+
const index = installed
|
|
238
|
+
? state.nodes.push(Object.freeze({
|
|
239
|
+
code: record.code,
|
|
240
|
+
journal,
|
|
241
|
+
callSeq: record.call.seq,
|
|
242
|
+
parent: adoption.parent,
|
|
243
|
+
...(restoredFromRecord ? { restoredFromRecord: true } : {}),
|
|
244
|
+
...(record.result.userBindings === undefined ? {} : { userBindings: record.result.userBindings }),
|
|
245
|
+
})) - 1
|
|
246
|
+
: undefined
|
|
247
|
+
const inherited = installed ? index : adoption.parent
|
|
248
|
+
for (const child of adoption.children) {
|
|
249
|
+
state.nodes[child] = Object.freeze({ ...state.nodes[child], parent: inherited })
|
|
250
|
+
}
|
|
251
|
+
state.head = adoption.head === undefined ? index : adoption.head
|
|
252
|
+
try {
|
|
253
|
+
recordSupersession(state, record, adoption, installed ? journalBindingNames(journal) : [])
|
|
254
|
+
applyOperations(state, journal.operations, index)
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (index !== undefined) state.nodes.pop()
|
|
257
|
+
state.head = adoption.previousHead
|
|
258
|
+
for (const child of adoption.children) {
|
|
259
|
+
state.nodes[child] = Object.freeze({ ...state.nodes[child], parent: adoption.superseded })
|
|
260
|
+
}
|
|
261
|
+
state.supersededBindings = adoption.supersededBindings
|
|
262
|
+
state.supersessions.length = adoption.supersessions
|
|
263
|
+
throw error
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Binding names a recorded journal operation list saves.
|
|
269
|
+
*
|
|
270
|
+
* Only a save with `kind: "binding"` creates a binding. `repl.state({ action:
|
|
271
|
+
* "save" })` records a checkpoint name in the same `save` shape, which is why an
|
|
272
|
+
* untagged save is a checkpoint: every operation recorded before the kind existed
|
|
273
|
+
* came from that control call. Treating those names as bindings made a contraction
|
|
274
|
+
* report a branch name as a dropped variable.
|
|
275
|
+
*/
|
|
276
|
+
export function bindingNamesOfOperations(operations) {
|
|
277
|
+
const names = new Set()
|
|
278
|
+
for (const operation of operations ?? []) {
|
|
279
|
+
if (operation.action === 'save' && operation.kind === 'binding' && typeof operation.name === 'string') {
|
|
280
|
+
names.add(operation.name)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return [...names]
|
|
284
|
+
}
|
|
285
|
+
|
|
81
286
|
function applyRecord(state, record, invalidCallSeqs) {
|
|
82
287
|
const { call, code, result } = record
|
|
288
|
+
// Every binding a journal names is a binding the session log proves. Recording
|
|
289
|
+
// the names here lets recovery report a binding it could not install instead of
|
|
290
|
+
// letting an unreplayed node take it silently.
|
|
291
|
+
// Only a volatile record proves a binding today: it cannot be replayed, so its
|
|
292
|
+
// recorded values are the entire evidence. A durable settlement records values too,
|
|
293
|
+
// for the restore path that installs nodes the replay does not reach, but its
|
|
294
|
+
// bindings are still proved by replaying it; counting them here would report a
|
|
295
|
+
// binding as dropped in every contraction whose replay is supposed to be complete.
|
|
296
|
+
// A released live environment proves nothing durable either: its live-only values
|
|
297
|
+
// are contracted below instead of being reported as a binding recovery owed.
|
|
298
|
+
const recordNames = [
|
|
299
|
+
...(result?.journal?.status === 'volatile' && state.liveOnlyLost !== true
|
|
300
|
+
? (result.journal.values ?? []).map(value => value.name)
|
|
301
|
+
: []),
|
|
302
|
+
...bindingNamesOfOperations(result?.journal?.operations),
|
|
303
|
+
]
|
|
304
|
+
for (const name of recordNames) state.provenBindings.add(name)
|
|
83
305
|
if (invalidCallSeqs.has(call.seq)) return
|
|
84
306
|
const journal = result.journal
|
|
85
307
|
if (journal.status === 'noop') return
|
|
86
308
|
if (journal.status === 'discarded') {
|
|
87
309
|
if (journal.volatileReason !== undefined) {
|
|
88
310
|
state.trusted = false
|
|
89
|
-
|
|
311
|
+
const discardedBindings = bindingNamesOfOperations(journal.operations)
|
|
312
|
+
state.volatileSuffix.push({ seq: call.seq, code, reason: journal.volatileReason,
|
|
313
|
+
...(discardedBindings.length === 0 ? {} : { bindings: discardedBindings }) })
|
|
90
314
|
}
|
|
91
315
|
return
|
|
92
316
|
}
|
|
93
317
|
if (journal.status === 'volatile') {
|
|
94
318
|
state.trusted = false
|
|
95
|
-
|
|
96
|
-
|
|
319
|
+
// A volatile settlement drops its `save` operations, so the names it created
|
|
320
|
+
// survive only as the bindings the record could not transfer.
|
|
321
|
+
const volatileBindings = [...new Set([...bindingNamesOfOperations(journal.operations),
|
|
322
|
+
...(journal.unrecordedBindings ?? [])])]
|
|
323
|
+
// A volatile cell that recorded its values keeps its place in the durable
|
|
324
|
+
// frontier: recovery installs those values instead of replaying the cell, so
|
|
325
|
+
// the completion is unverified while the bindings survive. A volatile cell
|
|
326
|
+
// whose values the codec could not transfer has nothing to install and is
|
|
327
|
+
// reported as a contraction instead of vanishing silently.
|
|
328
|
+
//
|
|
329
|
+
// Those values are the live environment's own observation, so a fold that
|
|
330
|
+
// knows the environment was released (`liveOnlyLost`) cannot install them:
|
|
331
|
+
// the owner that read `Date.now()` or `process.pid` is gone, and the next
|
|
332
|
+
// environment inherits the durable computation, not the live cache.
|
|
333
|
+
if (!state.liveOnlyLost && journal.values !== undefined && journal.values.length > 0) {
|
|
334
|
+
// The relation is adopted for this settlement too: an accepted revision that
|
|
335
|
+
// settled volatile is still the revision the record names, so it replaces its
|
|
336
|
+
// target on the path and installs its own recorded values.
|
|
337
|
+
applyAdoptedRecord(state, record, { journal, restoredFromRecord: true })
|
|
338
|
+
return
|
|
339
|
+
}
|
|
340
|
+
// A released live environment is reported as a contracted cell: the model
|
|
341
|
+
// loses whatever the live cache held, and naming those bindings would claim
|
|
342
|
+
// they were proved by the durable frontier when they never were.
|
|
343
|
+
state.volatileSuffix.push({
|
|
344
|
+
seq: call.seq,
|
|
345
|
+
code,
|
|
346
|
+
reason: state.liveOnlyLost === true
|
|
347
|
+
? 'the live environment that observed this cell was released'
|
|
348
|
+
: journal.volatileReason ?? 'volatile cell',
|
|
349
|
+
...(volatileBindings.length === 0 || state.liveOnlyLost === true ? {} : { bindings: volatileBindings }),
|
|
350
|
+
})
|
|
351
|
+
// A contracted revision still replaces the cell it edited. Leaving the target
|
|
352
|
+
// on the path would keep building an environment the accepted edit already
|
|
353
|
+
// ended, and the relation is what tells the model those names are withdrawn
|
|
354
|
+
// rather than lost. The settlement installs nothing, so it supplies no name:
|
|
355
|
+
// every name the target recorded is withdrawn by this record.
|
|
356
|
+
applyAdoptedRecord(state, record, { journal, installed: false })
|
|
97
357
|
return
|
|
98
358
|
}
|
|
99
359
|
if (!state.trusted && !state.surfaceContracted) {
|
|
100
360
|
state.trusted = true
|
|
101
361
|
state.volatileSuffix.length = 0
|
|
102
362
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
parent: state.head,
|
|
108
|
-
...(result.userBindings === undefined ? {} : { userBindings: result.userBindings }),
|
|
109
|
-
})
|
|
110
|
-
const index = state.nodes.push(node) - 1
|
|
111
|
-
state.head = index
|
|
112
|
-
try {
|
|
113
|
-
applyOperations(state, journal.operations, index)
|
|
114
|
-
} catch (error) {
|
|
115
|
-
state.nodes.pop()
|
|
116
|
-
state.head = node.parent
|
|
117
|
-
throw error
|
|
118
|
-
}
|
|
363
|
+
// A record may declare that it is the revision of an earlier cell. Adoption of
|
|
364
|
+
// that relation, its re-parented children and the node this record puts on the
|
|
365
|
+
// path are one settlement: a record the fold refuses leaves none of them behind.
|
|
366
|
+
applyAdoptedRecord(state, record, { journal })
|
|
119
367
|
}
|
|
120
368
|
|
|
121
369
|
function recordEventSeq(record) {
|
|
@@ -132,8 +380,17 @@ function foldRecords(records, invalidCallSeqs, options = {}) {
|
|
|
132
380
|
head: undefined,
|
|
133
381
|
checkpoints: new Map(),
|
|
134
382
|
volatileSuffix: [],
|
|
383
|
+
provenBindings: new Set(),
|
|
384
|
+
deletedBindings: new Set(),
|
|
385
|
+
// One recorded revision replaced the cell it edited. The relation, not a
|
|
386
|
+
// deletion, is what the log proves: the superseded cell stays in the record
|
|
387
|
+
// and off the path to the head.
|
|
388
|
+
supersessions: [],
|
|
389
|
+
supersededBindings: new Set(),
|
|
135
390
|
trusted: true,
|
|
136
391
|
surfaceContracted: options.surfaceContracted === true,
|
|
392
|
+
liveOnlyLost: options.liveOnlyLost === true,
|
|
393
|
+
editRewritesHistory: options.editRewritesHistory === true,
|
|
137
394
|
}
|
|
138
395
|
for (const record of records) {
|
|
139
396
|
applyRecord(state, record, invalidCallSeqs)
|
|
@@ -224,22 +481,29 @@ export function visibleExecutableCallSeqs(session) {
|
|
|
224
481
|
if (visibleAssistantCallSeqs.has(event.seq)) calls.add(event.seq)
|
|
225
482
|
}
|
|
226
483
|
const timeline = foldSessionTimeline(events)
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
484
|
+
if (process.env.DEBUG_RECOVER) console.error('DEBUG events', events.length, 'results', timeline.results.size, 'calls', timelineCalls(timeline).length, 'exec', timeline.executableCalls.size)
|
|
485
|
+
for (const seq of timeline.calls) {
|
|
486
|
+
const post = timeline.results.get(seq)?.postEdit
|
|
487
|
+
// A derived execution that never ran was never published as a cell the model
|
|
488
|
+
// can see, so it is not model-visible executable history.
|
|
489
|
+
if (post !== undefined && post.derived?.unexecuted !== true
|
|
490
|
+
&& calls.has(seq) && calls.has(post.targetCallSeq)) {
|
|
230
491
|
calls.add(post.executionStartSeq)
|
|
231
492
|
}
|
|
232
493
|
}
|
|
233
494
|
return calls
|
|
234
495
|
}
|
|
235
496
|
|
|
236
|
-
function timelineRun(call, result, eventIndex, journal) {
|
|
497
|
+
function timelineRun(call, result, eventIndex, journal, executableCalls) {
|
|
237
498
|
let args
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
499
|
+
const text = callArguments(call, executableCalls)
|
|
500
|
+
if (text !== undefined) {
|
|
501
|
+
try {
|
|
502
|
+
const parsed = JSON.parse(text)
|
|
503
|
+
if (isRecord(parsed)) args = Object.freeze(parsed)
|
|
504
|
+
} catch {
|
|
505
|
+
// Invalid persisted arguments cannot support derived source facts.
|
|
506
|
+
}
|
|
243
507
|
}
|
|
244
508
|
return Object.freeze({
|
|
245
509
|
index: eventIndex,
|
|
@@ -263,27 +527,35 @@ function timelineDerivedRun(call, result, eventIndex, derived) {
|
|
|
263
527
|
}
|
|
264
528
|
|
|
265
529
|
function successfulTimelineRun(run) {
|
|
266
|
-
|
|
530
|
+
// A pruned settlement yields no run, so it cannot certify success either.
|
|
531
|
+
return run?.journal?.completion?.kind === 'return' && run.journal.status !== 'noop'
|
|
267
532
|
}
|
|
268
533
|
|
|
269
|
-
function settlementRun(settlement) {
|
|
534
|
+
function settlementRun(settlement, executableCalls) {
|
|
270
535
|
const publication = settlement.publication
|
|
271
536
|
if (publication === undefined) return undefined
|
|
272
537
|
const result = { data: { meta: { [REWRITES_KEY]: publication.postEditRewrites ?? publication.rewrites } } }
|
|
538
|
+
// A revision that never ran publishes no source of its own, so the settlement
|
|
539
|
+
// keeps its own call's source: the root cell stays the editable target and the
|
|
540
|
+
// unexecuted revision never becomes one.
|
|
541
|
+
if (publication.postEdit?.derived?.unexecuted === true || publication.derived?.unexecuted === true) {
|
|
542
|
+
return timelineJson(timelineRun(settlement.call, result, publication.index, publication.journal, executableCalls))
|
|
543
|
+
}
|
|
273
544
|
if (publication.postEdit !== undefined) {
|
|
274
545
|
return timelineJson(timelineDerivedRun({ seq: publication.postEdit.executionStartSeq }, result,
|
|
275
546
|
publication.index, publication.postEdit.derived))
|
|
276
547
|
}
|
|
277
548
|
return timelineJson(publication.derived === undefined
|
|
278
|
-
? timelineRun(settlement.call, result, publication.index, publication.journal)
|
|
549
|
+
? timelineRun(settlement.call, result, publication.index, publication.journal, executableCalls)
|
|
279
550
|
: timelineDerivedRun(settlement.call, result, publication.index, publication.derived))
|
|
280
551
|
}
|
|
281
552
|
|
|
282
|
-
function selectSettlementRun(selected, settlement, scope, frontier) {
|
|
553
|
+
function selectSettlementRun(selected, settlement, scope, frontier, executableCalls) {
|
|
283
554
|
const publication = settlement.publication
|
|
284
555
|
if (publication === undefined || settlement.entry.scope !== scope
|
|
285
556
|
|| (frontier !== null && publication.index <= frontier.index)) return selected
|
|
286
|
-
return selected === undefined || publication.index >= selected.index
|
|
557
|
+
return selected === undefined || publication.index >= selected.index
|
|
558
|
+
? settlementRun(settlement, executableCalls) : selected
|
|
287
559
|
}
|
|
288
560
|
|
|
289
561
|
function editTargetForSelection(run, claimedTargets) {
|
|
@@ -416,6 +688,28 @@ function republishedTimelineResult(result, event) {
|
|
|
416
688
|
return { ...result, eventSeq: event.seq, positionSeq: result.positionSeq ?? result.eventSeq }
|
|
417
689
|
}
|
|
418
690
|
|
|
691
|
+
/**
|
|
692
|
+
* Fact-shaped data for a `tool/call`: the arguments are represented by their
|
|
693
|
+
* digest pair so a stored event fact never carries the text. The text itself is
|
|
694
|
+
* retained once, in `timelineCallEvent` inside `executableCalls`. A fact that is
|
|
695
|
+
* already digested passes through unchanged, which keeps re-deriving a fact from
|
|
696
|
+
* a compacted observation byte-identical.
|
|
697
|
+
*/
|
|
698
|
+
function callFactData(event) {
|
|
699
|
+
const data = event?.data ?? {}
|
|
700
|
+
const value = data.arguments
|
|
701
|
+
const digested = value === undefined && typeof data.argumentsDigest === 'string'
|
|
702
|
+
? { argumentsDigest: data.argumentsDigest, argumentsBytes: data.argumentsBytes }
|
|
703
|
+
: typeof value === 'string'
|
|
704
|
+
? { argumentsDigest: digestOf(value), argumentsBytes: byteLength(value) }
|
|
705
|
+
: {}
|
|
706
|
+
return {
|
|
707
|
+
callId: data.callId,
|
|
708
|
+
...(typeof data.name === 'string' ? { name: data.name } : {}),
|
|
709
|
+
...digested,
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
419
713
|
function correlationEvent(event) {
|
|
420
714
|
return timelineJson({ seq: event.seq, type: event.type,
|
|
421
715
|
...(['tool/ptc-dispatch-start', 'tool/ptc-dispatch'].includes(event.type) ? { data: {
|
|
@@ -423,7 +717,12 @@ function correlationEvent(event) {
|
|
|
423
717
|
parentCallId: event.data?.parentCallId,
|
|
424
718
|
subCallId: event.data?.subCallId,
|
|
425
719
|
name: event.data?.name,
|
|
426
|
-
|
|
720
|
+
// A compacted dispatch keeps its digest pair, so re-deriving this fact from a
|
|
721
|
+
// pruned observation reproduces exactly what was stored.
|
|
722
|
+
...(event.data?.arguments === undefined
|
|
723
|
+
? (event.data?.argumentsDigest === undefined ? {}
|
|
724
|
+
: { argumentsDigest: event.data.argumentsDigest, argumentsBytes: event.data.argumentsBytes })
|
|
725
|
+
: { arguments: event.data.arguments }),
|
|
427
726
|
...(event.type === 'tool/ptc-dispatch' ? {
|
|
428
727
|
isError: event.data?.isError,
|
|
429
728
|
...(event.data?.name === 'run_code' && event.data.meta?.[JOURNAL_KEY] !== undefined
|
|
@@ -431,7 +730,7 @@ function correlationEvent(event) {
|
|
|
431
730
|
} : {}),
|
|
432
731
|
} } : {}),
|
|
433
732
|
...(event.type === 'tool/call' && typeof event.data?.callId === 'string'
|
|
434
|
-
? { data:
|
|
733
|
+
? { data: callFactData(event) } : {}),
|
|
435
734
|
...(event.type === 'tool/result' ? { data: { message: { source: {
|
|
436
735
|
...(typeof event.data?.message?.source?.callId === 'string'
|
|
437
736
|
? { callId: event.data.message.source.callId } : {}),
|
|
@@ -501,13 +800,245 @@ function timelineJson(value) {
|
|
|
501
800
|
return deepFreeze(JSON.parse(JSON.stringify(value ?? null)))
|
|
502
801
|
}
|
|
503
802
|
|
|
504
|
-
|
|
803
|
+
/**
|
|
804
|
+
* Resolve one executable call entry from either the live table or the
|
|
805
|
+
* serialized checkpoint shape.
|
|
806
|
+
*/
|
|
807
|
+
function executableEntry(timeline, seq) {
|
|
808
|
+
const table = timeline.executableCalls
|
|
809
|
+
return typeof table?.get === 'function' ? table.get(seq) : table?.[`k${seq}`]?.[1]
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* The ordered executable call events.
|
|
814
|
+
*
|
|
815
|
+
* `calls` stores only the sequence order; the events themselves live once in
|
|
816
|
+
* `executableCalls`, so a checkpoint no longer serializes every call a second
|
|
817
|
+
* time.
|
|
818
|
+
*/
|
|
819
|
+
function timelineCalls(timeline) {
|
|
820
|
+
const calls = []
|
|
821
|
+
for (const seq of timeline.calls) {
|
|
822
|
+
const event = executableEntry(timeline, seq)?.event
|
|
823
|
+
if (event !== undefined) calls.push(event)
|
|
824
|
+
}
|
|
825
|
+
return calls
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* The canonical call event retained in `executableCalls`.
|
|
830
|
+
*
|
|
831
|
+
* This is the only place the argument text is kept. A replay that only has a
|
|
832
|
+
* digested fact resolves the text from the entry already stored for that seq, so
|
|
833
|
+
* a checkpointed call and a re-derived one stay byte-identical.
|
|
834
|
+
*/
|
|
835
|
+
function timelineCallEvent(event, executableCalls) {
|
|
836
|
+
const entry = executableCalls?.get?.(event.seq) ?? executableCalls?.[`k${event.seq}`]?.[1]
|
|
837
|
+
const stored = entry?.event?.data?.arguments
|
|
838
|
+
const text = typeof event.data.arguments === 'string'
|
|
839
|
+
? event.data.arguments
|
|
840
|
+
: typeof stored === 'string' ? stored : undefined
|
|
505
841
|
return timelineJson({ seq: event.seq, type: event.type, data: {
|
|
506
842
|
callId: event.data.callId, name: event.data.name,
|
|
507
|
-
...(REPL_TOOL_NAMES.has(event.data.name)
|
|
843
|
+
...(REPL_TOOL_NAMES.has(event.data.name)
|
|
844
|
+
? (text === undefined
|
|
845
|
+
? { argumentsDigest: event.data.argumentsDigest, argumentsBytes: event.data.argumentsBytes }
|
|
846
|
+
: { arguments: text })
|
|
847
|
+
: {}),
|
|
508
848
|
} })
|
|
509
849
|
}
|
|
510
850
|
|
|
851
|
+
/** Stable digest of a stored projection fact, used by checkpoint admission. */
|
|
852
|
+
export function factDigest(value) {
|
|
853
|
+
return createHash('sha256').update(JSON.stringify(value ?? null, (_key, field) => (
|
|
854
|
+
isRecord(field) ? Object.fromEntries(Object.keys(field).sort().map(key => [key, field[key]])) : field
|
|
855
|
+
))).digest('hex')
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function withoutFactDigests(state) {
|
|
859
|
+
// Admission compares what the projection describes, not bookkeeping whose exact
|
|
860
|
+
// value depends on when pruning happened: per-fact digests and the retained-byte
|
|
861
|
+
// counter are accounting, not session state.
|
|
862
|
+
const { retainedBytes: _retainedBytes, retentionSummary: _retentionSummary, ...rest } = state
|
|
863
|
+
const facts = rest.eventFacts
|
|
864
|
+
if (!facts.some(fact => fact.digest !== undefined)) return rest
|
|
865
|
+
return { ...rest, eventFacts: facts.map(({ digest, ...fact }) => fact) }
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* The retention summary an admitted checkpoint carries.
|
|
870
|
+
*
|
|
871
|
+
* A compacted checkpoint keeps no per-settlement stubs at all: dropping the whole
|
|
872
|
+
* cluster is what makes the file size independent of how many cells the session
|
|
873
|
+
* ran. What survives is one chained summary of what was dropped, so admission can
|
|
874
|
+
* still tell "never existed" from "existed and was dropped".
|
|
875
|
+
*/
|
|
876
|
+
export function retentionSummaryOf(state) {
|
|
877
|
+
const summary = state?.retentionSummary
|
|
878
|
+
if (summary === undefined || summary === null) return undefined
|
|
879
|
+
return summary
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Prove a compacted checkpoint without replaying it.
|
|
884
|
+
*
|
|
885
|
+
* The retained window must be contiguous with the summary boundary: every
|
|
886
|
+
* settlement above `toSeq` is present and intact, every executable call that a
|
|
887
|
+
* retained settlement names is present, and the summary is a well-formed chain.
|
|
888
|
+
* Below the boundary nothing survives, which is exactly the contract of dropping
|
|
889
|
+
* a cluster. This is the weaker, stated claim admission makes for compacted
|
|
890
|
+
* states; uncompacted states keep the byte-for-byte replay equality.
|
|
891
|
+
*/
|
|
892
|
+
export function validateRetentionSummary(timeline, summary, logFacts) {
|
|
893
|
+
if (!isRecord(summary)) throw new TypeError('invalid PTC session-log projection retention summary')
|
|
894
|
+
const keys = Object.keys(summary)
|
|
895
|
+
if (keys.some(key => !['fromSeq', 'toSeq', 'count', 'chainDigest', 'entries'].includes(key))) {
|
|
896
|
+
throw new TypeError('invalid PTC session-log projection retention summary')
|
|
897
|
+
}
|
|
898
|
+
if (!isCanonicalSequence(summary.fromSeq) || !isCanonicalSequence(summary.toSeq)
|
|
899
|
+
|| summary.fromSeq > summary.toSeq || !Number.isSafeInteger(summary.count) || summary.count < 1
|
|
900
|
+
|| typeof summary.chainDigest !== 'string' || !/^[a-f0-9]{64}$/u.test(summary.chainDigest)) {
|
|
901
|
+
throw new TypeError('invalid PTC session-log projection retention summary')
|
|
902
|
+
}
|
|
903
|
+
for (const [key, pair] of Object.entries(timeline.settledResultsByEventSeq ?? {})) {
|
|
904
|
+
const seq = pair?.[0]
|
|
905
|
+
const settlement = pair?.[1]
|
|
906
|
+
if (!isCanonicalSequence(seq) || settlement === undefined) {
|
|
907
|
+
throw new TypeError('invalid PTC session-log projection retention window')
|
|
908
|
+
}
|
|
909
|
+
if (seq <= summary.toSeq) {
|
|
910
|
+
throw new TypeError(`PTC session-log projection retains settlement ${key} below its retention boundary`)
|
|
911
|
+
}
|
|
912
|
+
const callSeq = settlement.call?.seq
|
|
913
|
+
if (!isCanonicalSequence(callSeq) || timeline.executableCalls?.[`k${callSeq}`] === undefined) {
|
|
914
|
+
throw new TypeError('PTC session-log projection lost the call of a retained settlement')
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
// Admission receives no session log, so it cannot fold the dropped range. What it
|
|
918
|
+
// can prove is that the summary is internally consistent: one record per dropped
|
|
919
|
+
// cluster, ordered by result sequence, with the range and the chain derived from
|
|
920
|
+
// those records. Trusting a summary without a record per cluster is what made an
|
|
921
|
+
// earlier version forgeable. The stronger claim — that a record describes the same
|
|
922
|
+
// cluster the log still holds — is checked when a dropped cluster is refolded
|
|
923
|
+
// (`verifyRefoldedSettlement`), because that is the only point where the log and
|
|
924
|
+
// the record meet.
|
|
925
|
+
const records = summary.entries
|
|
926
|
+
if (!Array.isArray(records) || records.length !== summary.count) {
|
|
927
|
+
throw new TypeError('inconsistent PTC session-log projection retention summary')
|
|
928
|
+
}
|
|
929
|
+
let previous = -1
|
|
930
|
+
for (const record of records) {
|
|
931
|
+
if (!isRecord(record)
|
|
932
|
+
|| !isCanonicalSequence(record.resultSeq) || !isCanonicalSequence(record.callSeq)
|
|
933
|
+
|| typeof record.argumentsDigest !== 'string' || !/^[a-f0-9]{64}$/u.test(record.argumentsDigest)
|
|
934
|
+
|| record.resultSeq <= previous) {
|
|
935
|
+
throw new TypeError('inconsistent PTC session-log projection retention summary')
|
|
936
|
+
}
|
|
937
|
+
previous = record.resultSeq
|
|
938
|
+
}
|
|
939
|
+
if (records[0].resultSeq !== summary.fromSeq || records[records.length - 1].resultSeq !== summary.toSeq
|
|
940
|
+
|| chainOfEntries(records) !== summary.chainDigest) {
|
|
941
|
+
throw new TypeError('inconsistent PTC session-log projection retention summary')
|
|
942
|
+
}
|
|
943
|
+
return summary
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Prove the recorded chain against the raw session log.
|
|
948
|
+
*
|
|
949
|
+
* Folds the events up to the boundary with retention disabled, derives the summary
|
|
950
|
+
* the fold would have produced for the dropped range, and requires it to equal the
|
|
951
|
+
* recorded one. A caller without the log must say so: this function throws
|
|
952
|
+
* `unverifiable PTC session-log projection retention summary` instead of accepting.
|
|
953
|
+
*/
|
|
954
|
+
export function verifyRetentionSummaryAgainstEvents(summary, events) {
|
|
955
|
+
if (!Array.isArray(events)) {
|
|
956
|
+
throw new TypeError('unverifiable PTC session-log projection retention summary')
|
|
957
|
+
}
|
|
958
|
+
const expected = retentionSummaryFromEvents(events, summary.toSeq)
|
|
959
|
+
if (expected === undefined
|
|
960
|
+
|| expected.count !== summary.count || expected.fromSeq !== summary.fromSeq
|
|
961
|
+
|| expected.toSeq !== summary.toSeq || expected.chainDigest !== summary.chainDigest) {
|
|
962
|
+
throw new TypeError('inconsistent PTC session-log projection retention summary')
|
|
963
|
+
}
|
|
964
|
+
return summary
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Refold the editable cell from the log and check it against the summary.
|
|
969
|
+
*
|
|
970
|
+
* The checkpoint cannot prove that its summary describes the clusters the log
|
|
971
|
+
* still holds — admission has no log. This is the one place where both sides meet:
|
|
972
|
+
* the log is folded with pruning disabled, the settlement behind the editable run
|
|
973
|
+
* is turned into a summary record, and the record stored in the summary must equal
|
|
974
|
+
* it. A mismatch means the summary and the log disagree, which is reported to the
|
|
975
|
+
* caller instead of yielding an edit target that was never recorded.
|
|
976
|
+
*/
|
|
977
|
+
export function refoldEditableSettlement(events, summary) {
|
|
978
|
+
if (!Array.isArray(events)) return { run: undefined, mismatch: false }
|
|
979
|
+
let state
|
|
980
|
+
withFullRetention(() => {
|
|
981
|
+
state = createSessionTimelineState()
|
|
982
|
+
events.forEach((event, index) => {
|
|
983
|
+
if (!isRecord(event)) return
|
|
984
|
+
state = advanceSessionTimeline(state, event, isCanonicalSequence(event.seq) ? event.seq : index)
|
|
985
|
+
})
|
|
986
|
+
})
|
|
987
|
+
const run = state?.editableRun
|
|
988
|
+
if (run === undefined) return { run: undefined, mismatch: false }
|
|
989
|
+
const settled = Object.entries(state?.settledResultsByEventSeq ?? {})
|
|
990
|
+
.map(([, pair]) => pair)
|
|
991
|
+
.find(([, settlement]) => settlement?.call?.seq === run.callSeq)
|
|
992
|
+
if (settled === undefined) return { run, mismatch: false }
|
|
993
|
+
const [resultSeq, settlement] = settled
|
|
994
|
+
const recorded = summaryRecordOf(summary, Number(resultSeq))
|
|
995
|
+
if (recorded === undefined) return { run, mismatch: false }
|
|
996
|
+
const refolded = summaryEntry(settlement, Number(resultSeq))
|
|
997
|
+
const mismatch = recorded.callSeq !== refolded.callSeq || recorded.argumentsDigest !== refolded.argumentsDigest
|
|
998
|
+
return { run, mismatch }
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/** Recompute the summary a fold of the dropped range would have produced. */
|
|
1002
|
+
function retentionSummaryFromEvents(events, toSeq) {
|
|
1003
|
+
let state
|
|
1004
|
+
withFullRetention(() => {
|
|
1005
|
+
state = createSessionTimelineState()
|
|
1006
|
+
events.forEach((event, index) => {
|
|
1007
|
+
if (!isRecord(event)) return
|
|
1008
|
+
if (isCanonicalSequence(event.seq) && event.seq > toSeq) return
|
|
1009
|
+
state = advanceSessionTimeline(state, event, isCanonicalSequence(event.seq) ? event.seq : index)
|
|
1010
|
+
})
|
|
1011
|
+
})
|
|
1012
|
+
const dropped = Object.entries(state?.settledResultsByEventSeq ?? {})
|
|
1013
|
+
.map(([, pair]) => pair)
|
|
1014
|
+
.filter(([resultSeq]) => Number(resultSeq) <= toSeq)
|
|
1015
|
+
// The prune orders dropped entries by RESULT sequence before folding them into the
|
|
1016
|
+
// chain, so summing here must use the same order; observedIndex can disagree when
|
|
1017
|
+
// a nested call settles out of order.
|
|
1018
|
+
.sort((left, right) => Number(left[0]) - Number(right[0]))
|
|
1019
|
+
.map(([resultSeq, settlement]) => summaryEntry(settlement, Number(resultSeq)))
|
|
1020
|
+
if (dropped.length === 0) return undefined
|
|
1021
|
+
return extendSummary(undefined, dropped)
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* The identity of a call is what survives the payload window: sequence, type,
|
|
1026
|
+
* callId and name. The argument text is deliberately excluded, because retention
|
|
1027
|
+
* replaces it with `argumentsDigest` and an identity that hashed the text would make
|
|
1028
|
+
* every observation describe a different call before and after pruning. The text is
|
|
1029
|
+
* still bound by its own digest in the call entry, so excluding it hides nothing.
|
|
1030
|
+
*/
|
|
1031
|
+
function eventDigest(event) {
|
|
1032
|
+
// Identity only, and only the fields every call keeps: a payload the window
|
|
1033
|
+
// digested must hash the same before and after pruning, which is why the source
|
|
1034
|
+
// text and the result are not part of it. A call always carries its `callId`
|
|
1035
|
+
// (executable calls are admitted with one), so no second shape exists to hash.
|
|
1036
|
+
const data = isRecord(event?.data) ? event.data : {}
|
|
1037
|
+
const identity = { seq: event?.seq, type: event?.type,
|
|
1038
|
+
data: { callId: data.callId, name: data.name ?? null } }
|
|
1039
|
+
return createHash('sha256').update(JSON.stringify(identity)).digest('hex')
|
|
1040
|
+
}
|
|
1041
|
+
|
|
511
1042
|
function resultIdentity(data) {
|
|
512
1043
|
const identity = timelineJson(isRecord(data) && isRecord(data.message)
|
|
513
1044
|
? { ...data, message: { ...data.message, content: undefined } } : data)
|
|
@@ -516,6 +1047,23 @@ function resultIdentity(data) {
|
|
|
516
1047
|
))).digest('hex')
|
|
517
1048
|
}
|
|
518
1049
|
|
|
1050
|
+
/**
|
|
1051
|
+
* How many bytes of retained payload admission may replay.
|
|
1052
|
+
*
|
|
1053
|
+
* A stored checkpoint is proved two ways: replaying its facts reproduces it
|
|
1054
|
+
* byte-for-byte, or — because that replay costs a second fold of everything the
|
|
1055
|
+
* session wrote — its per-fact digests and identity chain prove continuity. This
|
|
1056
|
+
* budget decides which claim a given checkpoint gets, and both replays read it
|
|
1057
|
+
* through `admissionReplayBudget`, so the timeline replay here and the
|
|
1058
|
+
* log-identity replay in the session-log projection cannot disagree about it.
|
|
1059
|
+
*/
|
|
1060
|
+
const ADMISSION_REPLAY_BUDGET_BYTES = 4 * 1024 * 1024
|
|
1061
|
+
|
|
1062
|
+
/** Resolve a caller's replay budget, falling back to the shipped one. */
|
|
1063
|
+
export function admissionReplayBudget(bytes) {
|
|
1064
|
+
return Number.isSafeInteger(bytes) && bytes > 0 ? bytes : ADMISSION_REPLAY_BUDGET_BYTES
|
|
1065
|
+
}
|
|
1066
|
+
|
|
519
1067
|
export function createSessionTimelineState() {
|
|
520
1068
|
return {
|
|
521
1069
|
openTurn: false,
|
|
@@ -536,11 +1084,34 @@ export function createSessionTimelineState() {
|
|
|
536
1084
|
runSelectionHistory: [],
|
|
537
1085
|
lastEventIndex: null,
|
|
538
1086
|
eventFacts: [],
|
|
1087
|
+
// Payload bytes the retained settlements still carry: the retention window
|
|
1088
|
+
// keeps this bounded so a checkpoint never scales with written content.
|
|
1089
|
+
retainedBytes: 0,
|
|
1090
|
+
// One chained summary of the clusters the window dropped. Per-settlement stubs
|
|
1091
|
+
// would still scale with the number of cells the session ran.
|
|
1092
|
+
retentionSummary: null,
|
|
1093
|
+
// Set once any cluster is dropped, so admission can tell "nothing was ever
|
|
1094
|
+
// dropped" from "a summary was deleted". A compacted state must carry its summary.
|
|
1095
|
+
compacted: false,
|
|
1096
|
+
// Set when the newest cell alone exceeded the byte budget and had to go. The
|
|
1097
|
+
// consumers that would have read it must say so instead of looking empty.
|
|
1098
|
+
hotCellDropped: false,
|
|
539
1099
|
scope: 0,
|
|
540
1100
|
}
|
|
541
1101
|
}
|
|
542
1102
|
|
|
543
|
-
|
|
1103
|
+
/**
|
|
1104
|
+
* Prove a stored timeline checkpoint, given the session log's own facts when the
|
|
1105
|
+
* caller holds them.
|
|
1106
|
+
*
|
|
1107
|
+
* `replayBudgetBytes` is the caller's replay budget: a checkpoint above it is
|
|
1108
|
+
* admitted on its per-fact digests and identity chain instead of a replay, and a
|
|
1109
|
+
* caller that lowers the budget (a test, or a host with a cheaper fold) must
|
|
1110
|
+
* lower this same claim, not a second copy of it.
|
|
1111
|
+
*/
|
|
1112
|
+
export function validateSessionTimelineState(value, logFacts, options = {}) {
|
|
1113
|
+
const replayBudget = admissionReplayBudget(options.replayBudgetBytes)
|
|
1114
|
+
|
|
544
1115
|
const requireFact = condition => {
|
|
545
1116
|
if (!condition) throw new TypeError('invalid PTC session-log projection timeline checkpoint')
|
|
546
1117
|
}
|
|
@@ -549,17 +1120,36 @@ export function validateSessionTimelineState(value) {
|
|
|
549
1120
|
const target = field => fields(field, ['callSeq', 'source']) && isCanonicalSequence(field.callSeq) && typeof field.source === 'string'
|
|
550
1121
|
const callEvent = event => fields(event, ['seq', 'type', 'data']) && isCanonicalSequence(event.seq) && event.type === 'tool/call'
|
|
551
1122
|
&& isRecord(event.data) && typeof event.data.callId === 'string'
|
|
552
|
-
&& fields(event.data, ['callId', 'name', 'arguments'])
|
|
1123
|
+
&& fields(event.data, ['callId', 'name', 'arguments', 'argumentsDigest', 'argumentsBytes'])
|
|
553
1124
|
&& optional(event.data.name, name => typeof name === 'string')
|
|
554
|
-
&&
|
|
1125
|
+
&& (event.data.arguments === undefined
|
|
1126
|
+
? (event.data.argumentsDigest === undefined
|
|
1127
|
+
|| (typeof event.data.argumentsDigest === 'string' && /^[a-f0-9]{64}$/u.test(event.data.argumentsDigest)
|
|
1128
|
+
&& isCanonicalSequence(event.data.argumentsBytes)))
|
|
1129
|
+
: (typeof event.data.arguments === 'string' && event.data.argumentsDigest === undefined
|
|
1130
|
+
&& event.data.argumentsBytes === undefined))
|
|
555
1131
|
const callEntry = entry => fields(entry, ['event', 'eventIndex', 'scope', 'ambiguous', 'editTarget']) && callEvent(entry.event)
|
|
556
1132
|
&& isCanonicalSequence(entry.eventIndex) && isCanonicalSequence(entry.scope) && entry.scope <= value.scope
|
|
557
1133
|
&& optional(entry.ambiguous, field => field === true) && optional(entry.editTarget, target)
|
|
1134
|
+
// A settlement references its call through `call` and keeps only the entry's
|
|
1135
|
+
// position and claims. Storing `entry.event` as well would serialize the call
|
|
1136
|
+
// event a second time — the duplication the retention work exists to remove.
|
|
1137
|
+
const settlementEntry = entry => fields(entry, ['eventIndex', 'scope', 'ambiguous', 'editTarget'])
|
|
1138
|
+
&& isCanonicalSequence(entry.eventIndex) && isCanonicalSequence(entry.scope) && entry.scope <= value.scope
|
|
1139
|
+
&& optional(entry.ambiguous, field => field === true) && optional(entry.editTarget, target)
|
|
558
1140
|
const table = (name, keyValid, payloadValid) => {
|
|
559
|
-
|
|
1141
|
+
if (!isRecord(value[name])) {
|
|
1142
|
+
throw new TypeError(`invalid PTC session-log projection timeline checkpoint: table ${name} is missing`)
|
|
1143
|
+
}
|
|
560
1144
|
for (const [key, pair] of Object.entries(value[name])) {
|
|
561
|
-
|
|
562
|
-
|
|
1145
|
+
if (!Array.isArray(pair) || pair.length !== 2 || !keyValid(pair[0]) || key !== `k${pair[0]}`) {
|
|
1146
|
+
throw new TypeError(`invalid PTC session-log projection timeline checkpoint: table ${name} entry ${key}`)
|
|
1147
|
+
}
|
|
1148
|
+
if (!payloadValid(pair[1], pair[0])) {
|
|
1149
|
+
const problem = name === 'settledResultsByEventSeq' ? settlementProblem(pair[1]) : undefined
|
|
1150
|
+
const shape = isRecord(pair[1]) ? ` keys=${Object.keys(pair[1]).join(',')}` : ` value=${JSON.stringify(pair[1])?.slice(0, 80)}`
|
|
1151
|
+
throw new TypeError(`invalid PTC session-log projection timeline checkpoint: table ${name} payload ${key}${shape}${problem === undefined ? '' : ` problem=${problem}`}`)
|
|
1152
|
+
}
|
|
563
1153
|
}
|
|
564
1154
|
return new Map(Object.values(value[name]))
|
|
565
1155
|
}
|
|
@@ -567,9 +1157,14 @@ export function validateSessionTimelineState(value) {
|
|
|
567
1157
|
'cordisTranscript', 'editTargets', 'pendingByCallId', 'seenCallIds', 'claimedEditTargets', 'editClaims',
|
|
568
1158
|
'ordinaryResultSeqs', 'settledResultsByEventSeq', 'scope', 'latestRun', 'editableRun', 'lastSuccessfulRunIndex',
|
|
569
1159
|
'unavailableResultSeq', 'unavailableResultReason', 'pruneReplacementWindow', 'runSelectionFrontier',
|
|
570
|
-
'runSelectionHistory', 'lastEventIndex', 'eventFacts'
|
|
1160
|
+
'runSelectionHistory', 'lastEventIndex', 'eventFacts', 'retainedBytes', 'retentionSummary', 'compacted',
|
|
1161
|
+
'hotCellDropped']) && typeof value.openTurn === 'boolean'
|
|
571
1162
|
&& typeof value.found === 'boolean' && isCanonicalSequence(value.scope)
|
|
572
|
-
&&
|
|
1163
|
+
&& typeof value.retainedBytes === 'number' && value.retainedBytes >= 0
|
|
1164
|
+
&& (value.retentionSummary === undefined || value.retentionSummary === null || isRecord(value.retentionSummary))
|
|
1165
|
+
&& typeof value.compacted === 'boolean' && typeof value.hotCellDropped === 'boolean'
|
|
1166
|
+
&& Array.isArray(value.calls) && value.calls.every(seq => isCanonicalSequence(seq))
|
|
1167
|
+
&& Array.isArray(value.boundaries))
|
|
573
1168
|
requireFact(value.lastEventIndex === null || isCanonicalSequence(value.lastEventIndex))
|
|
574
1169
|
requireFact(Array.isArray(value.runSelectionHistory))
|
|
575
1170
|
let boundaryScope = 0
|
|
@@ -593,9 +1188,10 @@ export function validateSessionTimelineState(value) {
|
|
|
593
1188
|
&& entry.event.seq === seq && REPL_TOOL_NAMES.has(entry.event.data.name))
|
|
594
1189
|
requireFact(value.calls.length === executable.size)
|
|
595
1190
|
const callSeqs = new Set()
|
|
596
|
-
for (const
|
|
597
|
-
|
|
598
|
-
callSeqs.
|
|
1191
|
+
for (const seq of value.calls) {
|
|
1192
|
+
// The order list may only name stored entries, and may not repeat one.
|
|
1193
|
+
requireFact(!callSeqs.has(seq) && executable.has(seq))
|
|
1194
|
+
callSeqs.add(seq)
|
|
599
1195
|
}
|
|
600
1196
|
const journalFacts = result => {
|
|
601
1197
|
if (!isRecord(result.journal)) return false
|
|
@@ -606,7 +1202,7 @@ export function validateSessionTimelineState(value) {
|
|
|
606
1202
|
if (result.derived !== undefined) {
|
|
607
1203
|
const derived = result.derived
|
|
608
1204
|
if (!isRecord(derived)) return false
|
|
609
|
-
const normalized =
|
|
1205
|
+
const normalized = normalizeDeferredEditSettlement({
|
|
610
1206
|
[JOURNAL_KEY]: result.journal,
|
|
611
1207
|
[EDIT_TARGET_KEY]: { targetCallSeq: derived.targetCallSeq },
|
|
612
1208
|
[DERIVED_RUN_KEY]: { code: derived.code, description: derived.description },
|
|
@@ -618,6 +1214,8 @@ export function validateSessionTimelineState(value) {
|
|
|
618
1214
|
return true
|
|
619
1215
|
}
|
|
620
1216
|
const normalizedResult = result => {
|
|
1217
|
+
// Every stored result keeps its full shape: a dropped cluster takes its result
|
|
1218
|
+
// entry with it, so a digest-only result would be a shape nothing produces.
|
|
621
1219
|
if (!fields(result, ['eventSeq', 'eventIndex', 'positionSeq', 'error', 'journal', 'derived', 'userBindings',
|
|
622
1220
|
'postEdit', 'postEditError'])
|
|
623
1221
|
|| !isCanonicalSequence(result.eventSeq) || !isCanonicalSequence(result.eventIndex)
|
|
@@ -628,7 +1226,7 @@ export function validateSessionTimelineState(value) {
|
|
|
628
1226
|
&& optional(result.postEdit, post => fields(post, ['startSeq', 'executionStartSeq', 'targetCallSeq', 'derived', 'rewrites'])
|
|
629
1227
|
&& isCanonicalSequence(post.startSeq) && isCanonicalSequence(post.executionStartSeq)
|
|
630
1228
|
&& isCanonicalSequence(post.targetCallSeq)
|
|
631
|
-
&& deepEqualJson(
|
|
1229
|
+
&& deepEqualJson(normalizeDeferredEditSettlement({
|
|
632
1230
|
[JOURNAL_KEY]: post.derived?.journal,
|
|
633
1231
|
[EDIT_TARGET_KEY]: { targetCallSeq: post.targetCallSeq },
|
|
634
1232
|
[DERIVED_RUN_KEY]: { code: post.derived?.code, description: post.derived?.description },
|
|
@@ -657,49 +1255,75 @@ export function validateSessionTimelineState(value) {
|
|
|
657
1255
|
const claimed = table('claimedEditTargets', isCanonicalSequence, (field, seq) => trueValue(field) && executable.has(seq))
|
|
658
1256
|
const edits = table('editClaims', isCanonicalSequence, (seq, editSeq) => isCanonicalSequence(seq)
|
|
659
1257
|
&& 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
|
|
1258
|
+
const ordinaryObservations = table('ordinaryResultSeqs', isCanonicalSequence, (observation, seq) =>
|
|
1259
|
+
((fields(observation, ['call', 'eventSeq', 'eventIndex']) && callEvent(observation.call) && observation.call.seq === seq
|
|
1260
|
+
&& value.executableCalls[`k${seq}`] === undefined)
|
|
1261
|
+
|| (fields(observation, ['callDigest', 'eventSeq', 'eventIndex'])
|
|
1262
|
+
&& typeof observation.callDigest === 'string' && /^[a-f0-9]{64}$/u.test(observation.callDigest)
|
|
1263
|
+
&& value.executableCalls[`k${seq}`] !== undefined
|
|
1264
|
+
&& eventDigest(value.executableCalls[`k${seq}`][1].event) === observation.callDigest))
|
|
662
1265
|
&& isCanonicalSequence(observation.eventSeq) && isCanonicalSequence(observation.eventIndex)
|
|
663
1266
|
&& value.lastEventIndex !== null && observation.eventIndex <= value.lastEventIndex
|
|
664
|
-
&& (!REPL_TOOL_NAMES.has(observation.call.data.name)
|
|
1267
|
+
&& (observation.callDigest !== undefined || !REPL_TOOL_NAMES.has(observation.call.data.name)
|
|
665
1268
|
|| (deepEqualJson(executable.get(seq)?.event, observation.call)
|
|
666
1269
|
&& 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
|
-
|
|
1270
|
+
/**
|
|
1271
|
+
* Name the clause a settlement fails, so a rejected checkpoint says which relation
|
|
1272
|
+
* broke instead of only which table. The clauses are the same ones the inline
|
|
1273
|
+
* predicate used to run; this only reports them.
|
|
1274
|
+
*/
|
|
1275
|
+
const settlementProblem = settlement => {
|
|
1276
|
+
if (!fields(settlement, ['call', 'entry', 'identity', 'publication', 'observedIndex', 'ordinary', 'normalized'])) return 'fields'
|
|
1277
|
+
if (!callEvent(settlement.call)) return 'callEvent'
|
|
1278
|
+
if (!settlementEntry(settlement.entry)) return 'entry'
|
|
1279
|
+
if (!optional(settlement.normalized, normalizedResult)) return 'normalized'
|
|
1280
|
+
if (typeof settlement.ordinary !== 'boolean') return 'ordinary'
|
|
1281
|
+
if (!isCanonicalSequence(settlement.observedIndex) || value.lastEventIndex === null) return 'observedIndex'
|
|
1282
|
+
if (settlement.observedIndex < settlement.entry.eventIndex || settlement.observedIndex > value.lastEventIndex) return 'observedIndex range'
|
|
1283
|
+
if (typeof settlement.identity !== 'string' || !/^[a-f0-9]{64}$/u.test(settlement.identity)) return 'identity'
|
|
1284
|
+
if (REPL_TOOL_NAMES.has(settlement.call.data.name)
|
|
1285
|
+
&& !deepEqualJson(executable.get(settlement.call.seq)?.event, settlement.call)) {
|
|
1286
|
+
const stored = executable.get(settlement.call.seq)?.event
|
|
1287
|
+
return `executable call (stored=${JSON.stringify(stored)?.slice(0, 220)} settlement=${JSON.stringify(settlement.call)?.slice(0, 220)})`
|
|
1288
|
+
}
|
|
1289
|
+
const publicationProblem = publication => {
|
|
1290
|
+
if (!fields(publication, ['index', 'journal', 'derived', 'userBindings', 'rewrites', 'postEdit', 'postEditRewrites'])) return 'publication fields'
|
|
1291
|
+
if (!deepEqualJson(publication.journal ?? null, settlement.normalized?.journal ?? null)) return 'publication journal'
|
|
1292
|
+
if (!deepEqualJson(publication.derived ?? null, settlement.normalized?.derived ?? null)) return 'publication derived'
|
|
1293
|
+
if (!deepEqualJson(publication.userBindings ?? null, settlement.normalized?.userBindings ?? null)) return 'publication userBindings'
|
|
1294
|
+
if (!deepEqualJson(publication.postEdit ?? null, settlement.normalized?.postEdit ?? null)) return 'publication postEdit'
|
|
1295
|
+
if (!deepEqualJson(publication.postEditRewrites ?? null, settlement.normalized?.postEdit?.rewrites ?? null)) return 'publication postEditRewrites'
|
|
1296
|
+
if (settlement.entry.ambiguous) return 'publication ambiguity'
|
|
1297
|
+
if (!isCanonicalSequence(publication.index)) return 'publication index'
|
|
1298
|
+
if (publication.index !== (settlement.ordinary ? settlement.observedIndex : settlement.entry.eventIndex)) return 'publication position'
|
|
1299
|
+
if (settlement.call.data.name === 'run_code' && publication.derived !== undefined) return 'publication derived target'
|
|
1300
|
+
if (settlement.call.data.name === 'edit_run_code'
|
|
1301
|
+
&& (publication.derived === undefined
|
|
1302
|
+
|| publication.derived.targetCallSeq !== settlement.entry.editTarget?.callSeq)) return 'publication edit target'
|
|
1303
|
+
if (publication.journal === undefined
|
|
1304
|
+
? publication.derived !== undefined || publication.userBindings !== undefined
|
|
1305
|
+
: !journalFacts(publication)) return 'publication journal facts'
|
|
1306
|
+
if (!deepEqualJson(validatedRewrites({ [REWRITES_KEY]: publication.rewrites }) ?? null, publication.rewrites ?? null)) return 'publication rewrites'
|
|
1307
|
+
return undefined
|
|
1308
|
+
}
|
|
1309
|
+
if (!optional(settlement.publication, publication => publicationProblem(publication) === undefined)) return 'publication'
|
|
1310
|
+
if (isRecord(settlement.publication)) {
|
|
1311
|
+
const problem = publicationProblem(settlement.publication)
|
|
1312
|
+
if (problem !== undefined) return problem
|
|
1313
|
+
}
|
|
1314
|
+
return undefined
|
|
1315
|
+
}
|
|
1316
|
+
const settlements = table('settledResultsByEventSeq', isCanonicalSequence, settlement => settlementProblem(settlement) === undefined)
|
|
695
1317
|
// Normalized event facts retain distinctions needed by the transition but
|
|
696
1318
|
// omit result content. Admission uses that same transition, not a second
|
|
697
1319
|
// partial interpretation of the materialized caches.
|
|
698
1320
|
requireFact(Array.isArray(value.eventFacts))
|
|
699
1321
|
let reconstructed = createSessionTimelineState()
|
|
700
1322
|
let correlationIndex = -1
|
|
1323
|
+
let replayBytes = 0
|
|
1324
|
+
let replayComplete = true
|
|
701
1325
|
for (const observation of value.eventFacts) {
|
|
702
|
-
requireFact(fields(observation, ['eventIndex', 'event', 'identity', 'hasJournal', 'normalized',
|
|
1326
|
+
requireFact(fields(observation, ['eventIndex', 'event', 'digest', 'identity', 'hasJournal', 'normalized',
|
|
703
1327
|
'boundaries', 'boundaryFailure', 'rewrites', 'selectedTarget'])
|
|
704
1328
|
&& isCanonicalSequence(observation.eventIndex) && observation.eventIndex > correlationIndex
|
|
705
1329
|
&& value.lastEventIndex !== null && observation.eventIndex <= value.lastEventIndex
|
|
@@ -728,14 +1352,48 @@ export function validateSessionTimelineState(value) {
|
|
|
728
1352
|
&& deepEqualJson(validatedRewrites({ [REWRITES_KEY]: observation.rewrites }) ?? null,
|
|
729
1353
|
observation.rewrites ?? null))
|
|
730
1354
|
correlationIndex = observation.eventIndex
|
|
731
|
-
|
|
1355
|
+
// Replay proves byte-for-byte equality, but its cost is proportional to the
|
|
1356
|
+
// retained payload of the whole session. Admission therefore replays only
|
|
1357
|
+
// while that payload stays inside the budget and falls back to the per-fact
|
|
1358
|
+
// digests plus the shape/identity checks above beyond it.
|
|
1359
|
+
replayBytes += factDigest(observation.event).length * 4
|
|
1360
|
+
if (replayBytes > replayBudget) {
|
|
1361
|
+
replayComplete = false
|
|
1362
|
+
continue
|
|
1363
|
+
}
|
|
1364
|
+
reconstructed = replaySessionTimelineFact(reconstructed, observation, executable)
|
|
1365
|
+
}
|
|
1366
|
+
const summary = retentionSummaryOf(value)
|
|
1367
|
+
// A compacted state that lost its summary must be rejected: dropping the summary
|
|
1368
|
+
// would erase the only trace of the clusters the window removed.
|
|
1369
|
+
if (value.compacted === true && summary === undefined) {
|
|
1370
|
+
throw new TypeError('inconsistent PTC session-log projection retention summary')
|
|
1371
|
+
}
|
|
1372
|
+
if (value.compacted !== true && summary !== undefined) {
|
|
1373
|
+
throw new TypeError('inconsistent PTC session-log projection retention summary')
|
|
1374
|
+
}
|
|
1375
|
+
if (summary !== undefined) {
|
|
1376
|
+
// A compacted checkpoint dropped whole clusters, so replay can never reproduce
|
|
1377
|
+
// it; the summary is proved instead — against the log when the caller has it.
|
|
1378
|
+
validateRetentionSummary(value, summary, logFacts)
|
|
1379
|
+
} else if (replayComplete) {
|
|
1380
|
+
// Non-compacted states keep the full replay equality.
|
|
1381
|
+
requireFact(deepEqualJson(withoutFactDigests(reconstructed), withoutFactDigests(value)))
|
|
1382
|
+
}
|
|
1383
|
+
// Semantic checks run first so their diagnostics stay specific; the digest is
|
|
1384
|
+
// the last line of defence and rejects a payload edit without replaying it.
|
|
1385
|
+
for (const observation of value.eventFacts) {
|
|
1386
|
+
if (observation.digest === undefined) continue
|
|
1387
|
+
const { digest, ...rest } = observation
|
|
1388
|
+
requireFact(digest === factDigest(rest))
|
|
732
1389
|
}
|
|
733
|
-
requireFact(deepEqualJson(reconstructed, value))
|
|
734
1390
|
for (const observation of ordinaryObservations.values()) {
|
|
735
1391
|
const settlement = settlements.get(observation.eventSeq)
|
|
736
1392
|
requireFact(settlement !== undefined
|
|
737
1393
|
? settlement.ordinary && settlement.observedIndex === observation.eventIndex
|
|
738
|
-
&&
|
|
1394
|
+
&& (observation.callDigest === undefined
|
|
1395
|
+
? deepEqualJson(settlement.call, observation.call)
|
|
1396
|
+
: eventDigest(settlement.call) === observation.callDigest)
|
|
739
1397
|
: value.runSelectionHistory.some(frontier => frontier.index === observation.eventIndex
|
|
740
1398
|
&& frontier.reason === 'result-identity-mismatch'))
|
|
741
1399
|
}
|
|
@@ -743,7 +1401,9 @@ export function validateSessionTimelineState(value) {
|
|
|
743
1401
|
if (!settlement.ordinary) continue
|
|
744
1402
|
const observation = ordinaryObservations.get(settlement.call.seq)
|
|
745
1403
|
requireFact(observation !== undefined && observation.eventIndex === settlement.observedIndex
|
|
746
|
-
&&
|
|
1404
|
+
&& (observation.callDigest === undefined
|
|
1405
|
+
? deepEqualJson(observation.call, settlement.call)
|
|
1406
|
+
: eventDigest(settlement.call) === observation.callDigest))
|
|
747
1407
|
}
|
|
748
1408
|
const publications = [...settlements.values()].filter(settlement => settlement.publication !== undefined)
|
|
749
1409
|
// Dispatch targets are derived from first-observed settlements and retained
|
|
@@ -789,7 +1449,7 @@ export function validateSessionTimelineState(value) {
|
|
|
789
1449
|
dispatchEdits.delete(settlement.call.seq)
|
|
790
1450
|
if (settlement.publication?.derived === undefined) dispatchClaims.delete(claim)
|
|
791
1451
|
}
|
|
792
|
-
dispatchRun = selectSettlementRun(dispatchRun, settlement, dispatchScope, dispatchFrontier)
|
|
1452
|
+
dispatchRun = selectSettlementRun(dispatchRun, settlement, dispatchScope, dispatchFrontier, executable)
|
|
793
1453
|
}
|
|
794
1454
|
}
|
|
795
1455
|
requireFact(claimed.size === dispatchClaims.size && [...dispatchClaims].every(seq => claimed.has(seq)))
|
|
@@ -797,14 +1457,14 @@ export function validateSessionTimelineState(value) {
|
|
|
797
1457
|
let selectedRun
|
|
798
1458
|
let lastSuccessfulRunIndex
|
|
799
1459
|
for (const settlement of publications) {
|
|
800
|
-
selectedRun = selectSettlementRun(selectedRun, settlement, value.scope, value.runSelectionFrontier)
|
|
801
|
-
if (successfulTimelineRun(settlementRun(settlement))) {
|
|
1460
|
+
selectedRun = selectSettlementRun(selectedRun, settlement, value.scope, value.runSelectionFrontier, executable)
|
|
1461
|
+
if (successfulTimelineRun(settlementRun(settlement, executable))) {
|
|
802
1462
|
lastSuccessfulRunIndex = Math.max(lastSuccessfulRunIndex ?? -1, settlement.publication.index)
|
|
803
1463
|
}
|
|
804
1464
|
}
|
|
805
1465
|
requireFact(selectedRun === undefined || publications.some(settlement =>
|
|
806
1466
|
settlement.entry.scope === value.scope && !settlement.entry.ambiguous
|
|
807
|
-
&& settlementRun(settlement)
|
|
1467
|
+
&& settlementRun(settlement, executable)?.callSeq === selectedRun.callSeq))
|
|
808
1468
|
requireFact(selectedRun === undefined ? value.latestRun === undefined && value.editableRun === undefined
|
|
809
1469
|
: deepEqualJson(value.latestRun ?? null, selectedRun) && deepEqualJson(value.editableRun ?? null, selectedRun))
|
|
810
1470
|
requireFact(value.lastSuccessfulRunIndex === lastSuccessfulRunIndex
|
|
@@ -838,8 +1498,8 @@ export function advanceSessionTimeline(previous, inputEvent, eventIndex = inputE
|
|
|
838
1498
|
}
|
|
839
1499
|
|
|
840
1500
|
/** 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)
|
|
1501
|
+
export function replaySessionTimelineFact(previous, fact, retainedCalls) {
|
|
1502
|
+
return advanceTimelineEvent(previous, fact.event, fact.eventIndex, fact, retainedCalls)
|
|
843
1503
|
}
|
|
844
1504
|
|
|
845
1505
|
function dispatchPairWithinRoot(facts, call, resultEvent, startSeq) {
|
|
@@ -860,7 +1520,7 @@ function dispatchPairWithinRoot(facts, call, resultEvent, startSeq) {
|
|
|
860
1520
|
return { start: starts[0], settled: settlements[0] }
|
|
861
1521
|
}
|
|
862
1522
|
|
|
863
|
-
function normalizePostEditFact(facts, call, resultEvent, result, replayPostEdit) {
|
|
1523
|
+
function normalizePostEditFact(facts, call, resultEvent, result, replayPostEdit, executableCalls) {
|
|
864
1524
|
const post = replayPostEdit === undefined ? resultEvent.data?.meta?.[POST_EDIT_KEY] : {
|
|
865
1525
|
startSeq: replayPostEdit.startSeq,
|
|
866
1526
|
executionStartSeq: replayPostEdit.executionStartSeq,
|
|
@@ -909,11 +1569,17 @@ function normalizePostEditFact(facts, call, resultEvent, result, replayPostEdit)
|
|
|
909
1569
|
}
|
|
910
1570
|
let derived
|
|
911
1571
|
try {
|
|
912
|
-
derived =
|
|
1572
|
+
derived = normalizeDeferredEditSettlement(post.meta, target?.callSeq)
|
|
1573
|
+
// The derived metadata is a published settlement of its own, so it carries the
|
|
1574
|
+
// same proof the root journal does. Comparing the two recorded journal copies
|
|
1575
|
+
// proves only that they agree with each other; an edit that changed both and
|
|
1576
|
+
// left this proof behind no longer reproduces the settlement it published, and
|
|
1577
|
+
// its journal is then not evidence for the bindings the revision declares.
|
|
1578
|
+
requireRecordedSettlementProof(post.meta, derived.journal)
|
|
913
1579
|
} catch (error) {
|
|
914
1580
|
return fail(error.message)
|
|
915
1581
|
}
|
|
916
|
-
if (target?.source === undefined || sourceForRunCall(call) === undefined) {
|
|
1582
|
+
if (target?.source === undefined || sourceForRunCall(call, executableCalls) === undefined) {
|
|
917
1583
|
return fail('nested edit has no selected source')
|
|
918
1584
|
}
|
|
919
1585
|
const edit = editRejectedCell(start.data.arguments, target.source)
|
|
@@ -946,7 +1612,63 @@ function normalizePostEditFact(facts, call, resultEvent, result, replayPostEdit)
|
|
|
946
1612
|
...(rewrites === undefined ? {} : { rewrites }) }
|
|
947
1613
|
}
|
|
948
1614
|
|
|
949
|
-
|
|
1615
|
+
/**
|
|
1616
|
+
* A published journal records the digest of the wording its diagnostics state.
|
|
1617
|
+
*
|
|
1618
|
+
* Recovery reports a failed cell by re-rendering its recorded diagnostic, and the
|
|
1619
|
+
* completion message and that diagnostic are two copies of one settlement, so a
|
|
1620
|
+
* record whose diagnostic text was edited can re-derive both consistently. The
|
|
1621
|
+
* digest is taken when the settlement is published, before either copy can change.
|
|
1622
|
+
* A record that carries no digest predates it and keeps the older evidence; one
|
|
1623
|
+
* whose diagnostics no longer hash to it is not evidence for its cell's bindings.
|
|
1624
|
+
*/
|
|
1625
|
+
function requireRecordedDiagnosticWording(meta, journal) {
|
|
1626
|
+
if (!isRecord(meta) || !Object.hasOwn(meta, DIAGNOSTIC_WORDING_KEY)) return
|
|
1627
|
+
const recorded = meta[DIAGNOSTIC_WORDING_KEY]
|
|
1628
|
+
if (!isRecord(recorded) || Reflect.ownKeys(recorded).length !== DIAGNOSTIC_WORDING_FIELDS.size
|
|
1629
|
+
|| Reflect.ownKeys(recorded).some(key => !DIAGNOSTIC_WORDING_FIELDS.has(key))) {
|
|
1630
|
+
throw new Error('invalid recorded diagnostic wording')
|
|
1631
|
+
}
|
|
1632
|
+
if (recorded.digest !== diagnosticWordingDigest(journal)) {
|
|
1633
|
+
throw new Error('journal diagnostic wording does not match its recorded digest')
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
/**
|
|
1638
|
+
* A published settlement proves its own facts, and the Host's copy of its outcome
|
|
1639
|
+
* must still agree with them.
|
|
1640
|
+
*
|
|
1641
|
+
* Recovery reads a record as evidence for the bindings its cell declared, and the
|
|
1642
|
+
* record is the only account of its cell: editing the status, the completion or a
|
|
1643
|
+
* diagnostic leaves the record self-consistent, because the completion is rendered
|
|
1644
|
+
* from the same diagnostic that was edited. The proof recorded at publication is
|
|
1645
|
+
* one digest over exactly those facts, so an edited record no longer reproduces it.
|
|
1646
|
+
* A record that carries no proof predates it and keeps the older evidence.
|
|
1647
|
+
*
|
|
1648
|
+
* The retained `isError` is the Host's copy of the same outcome and not a fact this
|
|
1649
|
+
* plugin can recompute, so an edit that also rewrites the proof still has to agree
|
|
1650
|
+
* with it. That relation is the one a deferred edit already proves before it is
|
|
1651
|
+
* published.
|
|
1652
|
+
*/
|
|
1653
|
+
function requireRecordedSettlementProof(meta, journal, isError = undefined) {
|
|
1654
|
+
if (isError !== undefined && isError !== (journal.status === 'discarded'
|
|
1655
|
+
|| journal.completion?.kind === 'throw')) {
|
|
1656
|
+
throw new Error('journal settlement does not agree with the tool result it settled')
|
|
1657
|
+
}
|
|
1658
|
+
if (!isRecord(meta) || !Object.hasOwn(meta, SETTLEMENT_PROOF_KEY)) return
|
|
1659
|
+
const recorded = meta[SETTLEMENT_PROOF_KEY]
|
|
1660
|
+
if (!isRecord(recorded) || Reflect.ownKeys(recorded).length !== SETTLEMENT_PROOF_FIELDS.size
|
|
1661
|
+
|| Reflect.ownKeys(recorded).some(key => !SETTLEMENT_PROOF_FIELDS.has(key))
|
|
1662
|
+
|| recorded.version !== SETTLEMENT_PROOF_VERSION
|
|
1663
|
+
|| typeof recorded.recordedMessage !== 'string') {
|
|
1664
|
+
throw new Error('invalid recorded settlement proof')
|
|
1665
|
+
}
|
|
1666
|
+
if (recorded.recordedMessage !== settlementProofDigest(journal)) {
|
|
1667
|
+
throw new Error('journal facts do not match their recorded settlement proof')
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact, retainedCalls) {
|
|
950
1672
|
const nested = inputEvent?.type === 'tool/ptc-dispatch-start'
|
|
951
1673
|
|| inputEvent?.type === 'tool/ptc-dispatch'
|
|
952
1674
|
if (nested && inputEvent.data?.name !== 'edit_run_code'
|
|
@@ -1044,7 +1766,7 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
|
|
|
1044
1766
|
if (event?.type === 'tool/ptc-dispatch') continue
|
|
1045
1767
|
if (event?.type === 'tool/call' && typeof event.data?.callId === 'string') {
|
|
1046
1768
|
const executable = REPL_TOOL_NAMES.has(event.data.name)
|
|
1047
|
-
let entry = { event: timelineCallEvent(event), eventIndex, scope }
|
|
1769
|
+
let entry = { event: timelineCallEvent(event, retainedCalls ?? state.executableCalls), eventIndex, scope }
|
|
1048
1770
|
if (executable) {
|
|
1049
1771
|
if (isCanonicalSequence(event.seq) && state.executableCalls.has(event.seq)) {
|
|
1050
1772
|
// A sequence collision disproves both sources, but not the earlier
|
|
@@ -1073,7 +1795,7 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
|
|
|
1073
1795
|
editClaims.set(event.seq, target.callSeq)
|
|
1074
1796
|
}
|
|
1075
1797
|
}
|
|
1076
|
-
state.calls = [...state.calls,
|
|
1798
|
+
state.calls = [...state.calls, event.seq]
|
|
1077
1799
|
if (isCanonicalSequence(event.seq)) {
|
|
1078
1800
|
state.executableCalls.set(event.seq, entry)
|
|
1079
1801
|
}
|
|
@@ -1136,7 +1858,12 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
|
|
|
1136
1858
|
clearRunSelection('duplicate-result')
|
|
1137
1859
|
continue
|
|
1138
1860
|
}
|
|
1139
|
-
|
|
1861
|
+
// Non-REPL tools never enter `executableCalls` (only executable REPL calls
|
|
1862
|
+
// are retained there), so their observation keeps the call event itself.
|
|
1863
|
+
// REPL calls keep the digest and resolve the event through executableCalls,
|
|
1864
|
+
// which is why one table has two legal shapes.
|
|
1865
|
+
ordinaryResultSeqs.set(callSeq, timelineJson({
|
|
1866
|
+
...(state.executableCalls.get(callSeq) === undefined ? { call } : { callDigest: eventDigest(call) }),
|
|
1140
1867
|
eventSeq: isCanonicalSequence(event.seq) ? event.seq : callSeq, eventIndex }))
|
|
1141
1868
|
}
|
|
1142
1869
|
if (call !== undefined && typeof callId === 'string' && call.data.callId !== callId) {
|
|
@@ -1183,13 +1910,15 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
|
|
|
1183
1910
|
}
|
|
1184
1911
|
try {
|
|
1185
1912
|
if (call.data.name === 'edit_run_code') {
|
|
1186
|
-
const derived =
|
|
1913
|
+
const derived = normalizeDeferredEditSettlement(meta, entry.editTarget?.callSeq)
|
|
1187
1914
|
normalized = {
|
|
1188
1915
|
...raw,
|
|
1189
1916
|
journal: derived.journal,
|
|
1190
1917
|
derived,
|
|
1191
1918
|
...(derived.userBindings === undefined ? {} : { userBindings: derived.userBindings }),
|
|
1192
1919
|
}
|
|
1920
|
+
requireRecordedDiagnosticWording(meta, derived.journal)
|
|
1921
|
+
requireRecordedSettlementProof(meta, derived.journal)
|
|
1193
1922
|
} else if (call.data.name === 'run_code') {
|
|
1194
1923
|
const rawJournal = meta[JOURNAL_KEY]
|
|
1195
1924
|
const resolveLegacyConfirm = rawJournal?.version === LEGACY_JOURNAL_VERSION
|
|
@@ -1203,15 +1932,38 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
|
|
|
1203
1932
|
}
|
|
1204
1933
|
: undefined
|
|
1205
1934
|
const journal = normalizeJournal(rawJournal, { resolveLegacyConfirm })
|
|
1935
|
+
// A throw cell's completion message is the rendering of its own diagnostic.
|
|
1936
|
+
// A record whose message no longer matches was edited after the fact, so it
|
|
1937
|
+
// is not evidence for the bindings that cell declared.
|
|
1938
|
+
{
|
|
1939
|
+
let code
|
|
1940
|
+
try { code = JSON.parse(call.data.arguments)?.code } catch { code = undefined }
|
|
1941
|
+
const firstDiagnostic = journal.diagnostics?.[0]
|
|
1942
|
+
if (journal.completion?.kind === 'throw' && firstDiagnostic !== undefined
|
|
1943
|
+
&& typeof code === 'string'
|
|
1944
|
+
&& journal.completion.error?.message !== renderDiagnostic(firstDiagnostic, code)) {
|
|
1945
|
+
throw new Error('journal completion does not match its diagnostic')
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1206
1948
|
const userBindings = userBindingsForJournal(meta, journal)
|
|
1207
1949
|
normalized = {
|
|
1208
1950
|
...raw,
|
|
1209
1951
|
journal,
|
|
1210
1952
|
...(userBindings === undefined ? {} : { userBindings }),
|
|
1211
1953
|
}
|
|
1954
|
+
requireRecordedDiagnosticWording(meta, journal)
|
|
1955
|
+
// The Host states the outcome of the cell it settled; a record whose
|
|
1956
|
+
// completion or status contradicts it was edited after publication. The
|
|
1957
|
+
// Host records that outcome on the result message it appends, so the
|
|
1958
|
+
// message is read first; a log that carries it on the event instead is
|
|
1959
|
+
// read the same way.
|
|
1960
|
+
const hostIsError = typeof event.data?.message?.isError === 'boolean'
|
|
1961
|
+
? event.data.message.isError
|
|
1962
|
+
: typeof event.data?.isError === 'boolean' ? event.data.isError : undefined
|
|
1963
|
+
requireRecordedSettlementProof(meta, journal, hostIsError)
|
|
1212
1964
|
let postEdit
|
|
1213
1965
|
try {
|
|
1214
|
-
postEdit = normalizePostEditFact(state.eventFacts, call, event, normalized)
|
|
1966
|
+
postEdit = normalizePostEditFact(state.eventFacts, call, event, normalized, undefined, state.executableCalls)
|
|
1215
1967
|
} catch (error) {
|
|
1216
1968
|
postEdit = { error: `unproved deferred edit: ${error.message}` }
|
|
1217
1969
|
}
|
|
@@ -1235,7 +1987,7 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
|
|
|
1235
1987
|
if (replayFact.boundaries !== undefined) observation.boundaries = replayFact.boundaries
|
|
1236
1988
|
if (replayFact.boundaryFailure !== undefined) observation.boundaryFailure = replayFact.boundaryFailure
|
|
1237
1989
|
if (normalized.postEdit !== undefined) {
|
|
1238
|
-
const checked = normalizePostEditFact(state.eventFacts, call, event, normalized, normalized.postEdit)
|
|
1990
|
+
const checked = normalizePostEditFact(state.eventFacts, call, event, normalized, normalized.postEdit, state.executableCalls)
|
|
1239
1991
|
if (checked?.error !== undefined || !deepEqualJson(checked, normalized.postEdit)) {
|
|
1240
1992
|
throw new TypeError(`invalid PTC session-log projection deferred edit fact: ${checked?.error ?? 'derived fact changed'}`)
|
|
1241
1993
|
}
|
|
@@ -1251,7 +2003,11 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
|
|
|
1251
2003
|
if (isCanonicalSequence(resultSeq)) state.results.set(resultSeq, timelineJson(normalized))
|
|
1252
2004
|
state.found = true
|
|
1253
2005
|
}
|
|
1254
|
-
const settlement = call === undefined ? undefined : { call, entry
|
|
2006
|
+
const settlement = call === undefined ? undefined : { call, entry: {
|
|
2007
|
+
eventIndex: entry?.eventIndex, scope: entry?.scope,
|
|
2008
|
+
...(entry?.ambiguous === true ? { ambiguous: true } : {}),
|
|
2009
|
+
...(entry?.editTarget === undefined ? {} : { editTarget: entry.editTarget }),
|
|
2010
|
+
}, identity: observation.identity,
|
|
1255
2011
|
observedIndex: eventIndex, ordinary: !prunedReplacement,
|
|
1256
2012
|
...(normalized === undefined ? {} : { normalized }) }
|
|
1257
2013
|
if (settlement !== undefined && entry.scope === scope
|
|
@@ -1272,6 +2028,7 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
|
|
|
1272
2028
|
}
|
|
1273
2029
|
if (settlement !== undefined && isCanonicalSequence(event.seq)) {
|
|
1274
2030
|
settledResultsByEventSeq.set(event.seq, settlement)
|
|
2031
|
+
state.retainedBytes += settlementBytes(settlement)
|
|
1275
2032
|
}
|
|
1276
2033
|
|
|
1277
2034
|
const claimedTarget = editClaims.get(callSeq)
|
|
@@ -1281,8 +2038,9 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
|
|
|
1281
2038
|
}
|
|
1282
2039
|
if (entry === undefined || entry.scope !== scope) continue
|
|
1283
2040
|
const publishRun = () => {
|
|
1284
|
-
const run = settlementRun(settlement)
|
|
1285
|
-
state.latestRun = selectSettlementRun(state.latestRun, settlement, scope, state.runSelectionFrontier
|
|
2041
|
+
const run = settlementRun(settlement, state.executableCalls)
|
|
2042
|
+
state.latestRun = selectSettlementRun(state.latestRun, settlement, scope, state.runSelectionFrontier,
|
|
2043
|
+
state.executableCalls)
|
|
1286
2044
|
state.editableRun = state.latestRun
|
|
1287
2045
|
if (successfulTimelineRun(run)) {
|
|
1288
2046
|
state.lastSuccessfulRunIndex = Math.max(state.lastSuccessfulRunIndex ?? -1, run.index)
|
|
@@ -1302,7 +2060,13 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
|
|
|
1302
2060
|
}
|
|
1303
2061
|
publishRun()
|
|
1304
2062
|
}
|
|
1305
|
-
|
|
2063
|
+
// Digest the serialized fact (undefined fields dropped) so admission can
|
|
2064
|
+
// re-digest exactly the bytes it received.
|
|
2065
|
+
const plainObservation = JSON.parse(JSON.stringify(observation))
|
|
2066
|
+
plainObservation.digest = factDigest(plainObservation)
|
|
2067
|
+
state.eventFacts[state.eventFacts.length - 1] = timelineJson(plainObservation)
|
|
2068
|
+
pruneRetention(state, { settled: settledResultsByEventSeq, executable: state.executableCalls, results: state.results,
|
|
2069
|
+
ordinary: ordinaryResultSeqs })
|
|
1306
2070
|
return Object.fromEntries(Object.entries({ ...state,
|
|
1307
2071
|
executableCalls: state.executableCalls.table, results: state.results.table, editTargets: state.editTargets.table,
|
|
1308
2072
|
pendingByCallId: pendingByCallId.table, seenCallIds: seenCallIds.table,
|
|
@@ -1312,6 +2076,285 @@ function advanceTimelineEvent(previous, inputEvent, eventIndex, replayFact) {
|
|
|
1312
2076
|
}).filter(([_key, value]) => value !== undefined))
|
|
1313
2077
|
}
|
|
1314
2078
|
|
|
2079
|
+
/**
|
|
2080
|
+
* Refold a session while keeping every payload.
|
|
2081
|
+
*
|
|
2082
|
+
* A consumer that needs a cluster the retention window dropped cannot rebuild it
|
|
2083
|
+
* from the checkpoint. The session log still has the events, so the caller refolds
|
|
2084
|
+
* that slice with pruning disabled and reads the cluster from there. The flag is
|
|
2085
|
+
* scoped to one synchronous fold; the checkpointed state never sees it.
|
|
2086
|
+
*/
|
|
2087
|
+
let fullRetentionDepth = 0
|
|
2088
|
+
export function withFullRetention(run) {
|
|
2089
|
+
fullRetentionDepth += 1
|
|
2090
|
+
try {
|
|
2091
|
+
return run()
|
|
2092
|
+
} finally {
|
|
2093
|
+
fullRetentionDepth -= 1
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
/**
|
|
2098
|
+
* Drop the oldest settlements entirely and fold them into one summary.
|
|
2099
|
+
*
|
|
2100
|
+
* The projection is a fold shortcut, not the log. Keeping a digest stub per settled
|
|
2101
|
+
* cell still made a checkpoint proportional to how many cells the session ran (450
|
|
2102
|
+
* stubs cost ~1.7 MB before any payload), so the whole cluster is dropped instead:
|
|
2103
|
+
* the settlement, its executable call, its ordinary observation and its normalized
|
|
2104
|
+
* result. What survives is one chained summary of what was dropped, which is what
|
|
2105
|
+
* makes the size a function of the window rather than of the session.
|
|
2106
|
+
*
|
|
2107
|
+
* Consumers that need a dropped cell must refold it from the session log; when the
|
|
2108
|
+
* log cannot supply it they report `retention-pruned` instead of guessing.
|
|
2109
|
+
*/
|
|
2110
|
+
/**
|
|
2111
|
+
* A call event whose argument text is gone keeps its identity and the digest of
|
|
2112
|
+
* exactly the bytes it used to carry, so a refold can prove the text it recovers
|
|
2113
|
+
* is the text the checkpoint once held.
|
|
2114
|
+
*/
|
|
2115
|
+
function digestArgumentData(data) {
|
|
2116
|
+
if (!isRecord(data) || typeof data.arguments !== 'string') return data
|
|
2117
|
+
const { arguments: text, ...rest } = data
|
|
2118
|
+
return { ...rest, argumentsDigest: digestOf(text), argumentsBytes: byteLength(text) }
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
/**
|
|
2122
|
+
* A republication clones a call event into its own settlement, so the same call can
|
|
2123
|
+
* appear twice: once in `executableCalls` and once in a settlement that names its
|
|
2124
|
+
* sequence. Whichever copy the retention window digests, every copy of that call must
|
|
2125
|
+
* end up in the same shape, or a checkpoint would describe one call two ways. The
|
|
2126
|
+
* pass is idempotent and runs on every prune, so a clone added after the window moved
|
|
2127
|
+
* still lands on the retained shape — the fold and the replay both go through it.
|
|
2128
|
+
*/
|
|
2129
|
+
function digestSettlementCallCopies(tables) {
|
|
2130
|
+
for (const pair of Object.values(tables.settled.table)) {
|
|
2131
|
+
const [, settlement] = pair
|
|
2132
|
+
if (!isRecord(settlement) || !isRecord(settlement.call)) continue
|
|
2133
|
+
if (typeof settlement.call.data?.arguments !== 'string') continue
|
|
2134
|
+
const retained = tables.executable.get(settlement.call.seq)
|
|
2135
|
+
if (typeof retained?.event?.data?.argumentsDigest !== 'string') continue
|
|
2136
|
+
tables.settled.set(pair[0], { ...settlement, call: { ...settlement.call, data: digestArgumentData(settlement.call.data) } })
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
/**
|
|
2141
|
+
* A journal outside the payload window keeps everything recovery reads — status,
|
|
2142
|
+
* operations, user binding evidence, confirms, completion and per-call metadata —
|
|
2143
|
+
* and replaces each call's argument wire with its digest. Byte-for-byte equality
|
|
2144
|
+
* of argument text is what the window gives up; identity is not.
|
|
2145
|
+
*/
|
|
2146
|
+
export function digestJournalArguments(journal) {
|
|
2147
|
+
if (process.env.DIGEST_SKIP_JOURNAL === '1') return journal
|
|
2148
|
+
if (!isRecord(journal) || !Array.isArray(journal.calls)) return journal
|
|
2149
|
+
return { ...journal, calls: journal.calls.map(call => {
|
|
2150
|
+
if (!isRecord(call) || !Object.hasOwn(call, 'args')) return call
|
|
2151
|
+
const { args, ...rest } = call
|
|
2152
|
+
return { ...rest, argsDigest: digestOf(args), argsBytes: byteLength(args) }
|
|
2153
|
+
}) }
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
function pruneRetention(state, tables) {
|
|
2157
|
+
if (fullRetentionDepth > 0) return
|
|
2158
|
+
const entries = Object.entries(tables.settled.table)
|
|
2159
|
+
.map(([, pair]) => ({ settlement: pair[1], resultSeq: pair[0] }))
|
|
2160
|
+
.filter(entry => entry.settlement !== undefined)
|
|
2161
|
+
.sort((left, right) => left.settlement.observedIndex - right.settlement.observedIndex)
|
|
2162
|
+
if (entries.length === 0) return
|
|
2163
|
+
const limits = retentionLimits()
|
|
2164
|
+
// The count window is order-only so a replayed fold prunes identically. A
|
|
2165
|
+
// configured cluster cap of 0 means the count window never folds anything and
|
|
2166
|
+
// the payload budget is the only bound.
|
|
2167
|
+
const keepClusters = limits.clusters === 0 ? entries.length : Math.min(entries.length, limits.clusters)
|
|
2168
|
+
const keepFrom = Math.max(0, entries.length - keepClusters)
|
|
2169
|
+
// The runs a consumer reads synchronously — the latest publication and the cell
|
|
2170
|
+
// an edit would capture — stay inside the window even when newer settlements
|
|
2171
|
+
// pushed them past the count. Dropping them would turn "editable" into "pruned"
|
|
2172
|
+
// for the cell the user is actually looking at.
|
|
2173
|
+
const pinnedCallSeqs = new Set([state.latestRun?.callSeq, state.editableRun?.callSeq]
|
|
2174
|
+
.filter(callSeq => isCanonicalSequence(callSeq)))
|
|
2175
|
+
// Only a cluster beyond the configured cluster cap is folded into the interval
|
|
2176
|
+
// summary. Every other cluster stays, with its payload text replaced by digests,
|
|
2177
|
+
// so recovery evidence never depends on refolding the session log.
|
|
2178
|
+
const folded = new Set(entries.slice(0, keepFrom)
|
|
2179
|
+
.filter(entry => pinnedCallSeqs.has(entry.settlement.call?.seq) === false)
|
|
2180
|
+
.map(entry => entry.resultSeq))
|
|
2181
|
+
const mustPrune = new Set(folded)
|
|
2182
|
+
// A cumulative byte cap, not only a per-entry one: the window has to be a constant
|
|
2183
|
+
// regardless of how large the retained cells are, otherwise 32 cells of 200 KB
|
|
2184
|
+
// still put 6.4 MB in every checkpoint. Walk newest-first and stop retaining once
|
|
2185
|
+
// the payload budget is spent. A single entry larger than the whole budget is
|
|
2186
|
+
// dropped too: the bound wins over keeping the hot cell, and the edit path refolds
|
|
2187
|
+
// that cell from the session log when it is needed.
|
|
2188
|
+
const retainedPayloadBytes = (entry, executable = tables.executable) => {
|
|
2189
|
+
const callSeq = entry.settlement.call?.seq
|
|
2190
|
+
const stored = callSeq === undefined ? undefined : executable.get(callSeq)
|
|
2191
|
+
const executableArguments = stored?.event?.data?.arguments
|
|
2192
|
+
return settlementBytes(entry.settlement)
|
|
2193
|
+
+ (typeof executableArguments === 'string' ? byteLength(executableArguments) : 0)
|
|
2194
|
+
}
|
|
2195
|
+
let retainedTotal = 0
|
|
2196
|
+
const newestFirst = [...entries].reverse()
|
|
2197
|
+
for (const [index, entry] of newestFirst.entries()) {
|
|
2198
|
+
const bytes = retainedPayloadBytes(entry)
|
|
2199
|
+
const pinned = pinnedCallSeqs.has(entry.settlement.call?.seq)
|
|
2200
|
+
// The run a consumer reads synchronously keeps its text even past the count
|
|
2201
|
+
// window: the window bounds how much history stays readable, never the cell the
|
|
2202
|
+
// user is looking at. Only the byte budget can digest a pinned cell.
|
|
2203
|
+
const outsidePayloadWindow = index >= limits.runs && !pinned
|
|
2204
|
+
const overBudget = bytes > limits.bytes
|
|
2205
|
+
if (overBudget || outsidePayloadWindow || (index > 0 && !pinned && retainedTotal + bytes > limits.bytes)) {
|
|
2206
|
+
mustPrune.add(entry.resultSeq)
|
|
2207
|
+
continue
|
|
2208
|
+
}
|
|
2209
|
+
retainedTotal += bytes
|
|
2210
|
+
}
|
|
2211
|
+
if (mustPrune.size === 0) return
|
|
2212
|
+
const droppedCallSeqs = new Set()
|
|
2213
|
+
const droppedResultSeqs = new Set()
|
|
2214
|
+
const dropped = []
|
|
2215
|
+
const digested = new Set([...mustPrune].filter(resultSeq => !folded.has(resultSeq)))
|
|
2216
|
+
const digestedResultSeqs = new Set()
|
|
2217
|
+
const digestedHotCell = state.latestRun !== undefined && entries.some(entry => (
|
|
2218
|
+
digested.has(entry.resultSeq) && entry.settlement.call?.seq === state.latestRun?.callSeq))
|
|
2219
|
+
for (const entry of entries) {
|
|
2220
|
+
if (!digested.has(entry.resultSeq)) continue
|
|
2221
|
+
const callSeq = entry.settlement.call?.seq
|
|
2222
|
+
if (callSeq !== undefined && tables.executable.has(callSeq)) {
|
|
2223
|
+
const stored = tables.executable.get(callSeq)
|
|
2224
|
+
tables.executable.set(callSeq, { ...stored, event: { ...stored.event, data: digestArgumentData(stored.event.data) } })
|
|
2225
|
+
digestSettlementCallCopies(tables)
|
|
2226
|
+
}
|
|
2227
|
+
// The result keeps every field recovery reads — status, operations, user
|
|
2228
|
+
// binding evidence, confirms, completion and call metadata — and loses only
|
|
2229
|
+
// the argument wire of each call.
|
|
2230
|
+
for (const key of [entry.resultSeq, callSeq]) {
|
|
2231
|
+
if (key === undefined || !tables.results.has(key)) continue
|
|
2232
|
+
const result = tables.results.get(key)
|
|
2233
|
+
if (!isRecord(result) || result.journal === undefined) continue
|
|
2234
|
+
tables.results.set(key, { ...result, journal: digestJournalArguments(result.journal) })
|
|
2235
|
+
}
|
|
2236
|
+
// The settlement keeps its normalized and publication entries because recovery
|
|
2237
|
+
// and the run derivations read them; only the journal argument wire and the
|
|
2238
|
+
// derived source are replaced by digests, which is exactly the payload the
|
|
2239
|
+
// window bounds. Identity, observations and result metadata stay.
|
|
2240
|
+
const digestJournalEntry = value => value === undefined ? undefined : {
|
|
2241
|
+
...value,
|
|
2242
|
+
...(value.journal === undefined ? {} : { journal: digestJournalArguments(value.journal) }),
|
|
2243
|
+
|
|
2244
|
+
}
|
|
2245
|
+
tables.settled.set(entry.resultSeq, {
|
|
2246
|
+
...entry.settlement,
|
|
2247
|
+
...(callSeq === undefined
|
|
2248
|
+
? {}
|
|
2249
|
+
: { call: { ...entry.settlement.call, data: digestArgumentData(entry.settlement.call.data) } }),
|
|
2250
|
+
...(entry.settlement.normalized === undefined
|
|
2251
|
+
? {}
|
|
2252
|
+
: { normalized: digestJournalEntry(entry.settlement.normalized) }),
|
|
2253
|
+
...(entry.settlement.publication === undefined
|
|
2254
|
+
? {}
|
|
2255
|
+
: { publication: digestJournalEntry(entry.settlement.publication) }),
|
|
2256
|
+
})
|
|
2257
|
+
digestedResultSeqs.add(Number(entry.resultSeq))
|
|
2258
|
+
state.retainedBytes = Math.max(0, state.retainedBytes - settlementBytes(entry.settlement))
|
|
2259
|
+
}
|
|
2260
|
+
for (const entry of entries) {
|
|
2261
|
+
if (!mustPrune.has(entry.resultSeq)) continue
|
|
2262
|
+
if (!folded.has(entry.resultSeq)) continue
|
|
2263
|
+
const callSeq = entry.settlement.call?.seq
|
|
2264
|
+
dropped.push(summaryEntry(entry.settlement, Number(entry.resultSeq)))
|
|
2265
|
+
tables.settled.delete(entry.resultSeq)
|
|
2266
|
+
if (callSeq !== undefined) {
|
|
2267
|
+
tables.executable.delete(callSeq)
|
|
2268
|
+
if (tables.ordinary?.has(callSeq)) tables.ordinary.delete(callSeq)
|
|
2269
|
+
droppedCallSeqs.add(callSeq)
|
|
2270
|
+
}
|
|
2271
|
+
// `results` is keyed by the RESULT sequence on the ordinary path and by the CALL
|
|
2272
|
+
// sequence on the republication path, so drop both keys when either exists.
|
|
2273
|
+
for (const key of [entry.resultSeq, callSeq]) {
|
|
2274
|
+
if (key === undefined) continue
|
|
2275
|
+
if (tables.results.has(key)) tables.results.delete(key)
|
|
2276
|
+
}
|
|
2277
|
+
droppedResultSeqs.add(entry.resultSeq)
|
|
2278
|
+
state.retainedBytes = Math.max(0, state.retainedBytes - settlementBytes(entry.settlement))
|
|
2279
|
+
}
|
|
2280
|
+
// A digested cluster kept its identity, so the ordered call list keeps its seq;
|
|
2281
|
+
// a folded cluster is gone and takes its seq with it.
|
|
2282
|
+
state.calls = state.calls.filter(callSeq => !droppedCallSeqs.has(callSeq))
|
|
2283
|
+
// The hot run can be lost either way: a folded cluster no longer exists, and a
|
|
2284
|
+
// digested one no longer holds the source a run is built from. Consumers that
|
|
2285
|
+
// need it refold from the session log, and report the notice until they do.
|
|
2286
|
+
if (state.latestRun !== undefined
|
|
2287
|
+
&& (droppedCallSeqs.has(state.latestRun.callSeq) || digestedHotCell)) {
|
|
2288
|
+
state.latestRun = undefined
|
|
2289
|
+
state.editableRun = undefined
|
|
2290
|
+
}
|
|
2291
|
+
// `hotCellDropped` describes the retained set, not the route the fold took to it.
|
|
2292
|
+
// It holds exactly when the newest settlement no longer carries its payload:
|
|
2293
|
+
// either it was folded away, or its argument text was digested. Recomputing it
|
|
2294
|
+
// from the table on every prune keeps a replayed fold — which reaches the same set
|
|
2295
|
+
// by a different route — from disagreeing with the stored one.
|
|
2296
|
+
const newestEntry = entries[entries.length - 1]
|
|
2297
|
+
const newestStored = newestEntry === undefined ? undefined : tables.settled.get(newestEntry.resultSeq)
|
|
2298
|
+
state.hotCellDropped = newestEntry !== undefined
|
|
2299
|
+
&& (newestStored === undefined || newestStored.call?.data?.arguments === undefined)
|
|
2300
|
+
// `retainedBytes` is a property of the retained set, not a running total: a fold
|
|
2301
|
+
// that digests a cluster after it was counted would otherwise carry a different
|
|
2302
|
+
// value than a fold that reaches the same set by another route. Recompute it from
|
|
2303
|
+
// the table so both sides agree, and so the number always describes what is there.
|
|
2304
|
+
state.retainedBytes = entries.reduce((total, entry) => {
|
|
2305
|
+
const stored = tables.settled.get(entry.resultSeq)
|
|
2306
|
+
return stored === undefined ? total : total + settlementBytes(stored)
|
|
2307
|
+
}, 0)
|
|
2308
|
+
if (dropped.length === 0 && digested.size === 0) return
|
|
2309
|
+
if (dropped.length !== 0) {
|
|
2310
|
+
state.compacted = true
|
|
2311
|
+
dropped.sort((left, right) => left.resultSeq - right.resultSeq)
|
|
2312
|
+
state.retentionSummary = extendSummary(state.retentionSummary, dropped)
|
|
2313
|
+
}
|
|
2314
|
+
// The facts of a folded cluster keep their identity and digests but lose the
|
|
2315
|
+
// payload text, for the same reason the cluster itself is gone.
|
|
2316
|
+
// A fact per event is itself linear in the session length, so a folded cluster
|
|
2317
|
+
// takes its tool/call and tool/result facts with it. Admission for a compacted
|
|
2318
|
+
// checkpoint proves the summary instead of replaying those facts.
|
|
2319
|
+
state.eventFacts = state.eventFacts.filter(fact => {
|
|
2320
|
+
if (fact.event?.type === 'tool/call' && droppedCallSeqs.has(fact.event.seq)) return false
|
|
2321
|
+
if (fact.normalized !== undefined && droppedResultSeqs.has(Number(fact.normalized.eventSeq))) return false
|
|
2322
|
+
return true
|
|
2323
|
+
})
|
|
2324
|
+
// A digested cluster keeps its facts, but they carry the digest-only journal too:
|
|
2325
|
+
// the fact list is a second copy of the same result content. Rewriting the payload
|
|
2326
|
+
// invalidates the fact's own digest, so it is recomputed here — admission checks
|
|
2327
|
+
// that digest against the bytes it receives.
|
|
2328
|
+
state.eventFacts = state.eventFacts.map(fact => {
|
|
2329
|
+
if (fact.normalized === undefined || !digestedResultSeqs.has(Number(fact.normalized.eventSeq))) return fact
|
|
2330
|
+
const { digest: _digest, ...rest } = fact
|
|
2331
|
+
const next = { ...rest, normalized: digestJournalArguments(fact.normalized) }
|
|
2332
|
+
next.digest = factDigest(next)
|
|
2333
|
+
return next
|
|
2334
|
+
})
|
|
2335
|
+
// A dispatch fact names the cell an edit would capture, and that target carries
|
|
2336
|
+
// the cell's source. Once the payload is digested the fold can no longer derive
|
|
2337
|
+
// that source, so the fact must record the same "no target" the fold would
|
|
2338
|
+
// produce now; leaving the old text behind would both duplicate the payload and
|
|
2339
|
+
// fail the round-trip check that compares the fact with its re-derivation.
|
|
2340
|
+
const digestedCallSeqs = new Set(entries
|
|
2341
|
+
.filter(entry => digested.has(entry.resultSeq))
|
|
2342
|
+
.map(entry => Number(entry.settlement.call?.seq)))
|
|
2343
|
+
if (digestedCallSeqs.size !== 0) {
|
|
2344
|
+
let rewritten = false
|
|
2345
|
+
state.eventFacts = state.eventFacts.map(fact => {
|
|
2346
|
+
const target = fact.selectedTarget
|
|
2347
|
+
if (!isRecord(target) || !digestedCallSeqs.has(Number(target.callSeq))) return fact
|
|
2348
|
+
const { digest: _digest, ...rest } = fact
|
|
2349
|
+
const next = { ...rest, selectedTarget: null }
|
|
2350
|
+
next.digest = factDigest(next)
|
|
2351
|
+
rewritten = true
|
|
2352
|
+
return timelineJson(next)
|
|
2353
|
+
})
|
|
2354
|
+
if (rewritten) state.eventFacts = state.eventFacts.map(fact => timelineJson(fact))
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
|
|
1315
2358
|
export function sessionTimelineValue(checkpoint) {
|
|
1316
2359
|
return { ...checkpoint,
|
|
1317
2360
|
executableCalls: new Map(Object.values(checkpoint.executableCalls)),
|
|
@@ -1334,10 +2377,17 @@ export function foldSessionTimeline(events) {
|
|
|
1334
2377
|
export function recoverJournal(session, currentCallSeq, options = {}) {
|
|
1335
2378
|
const events = sessionEvents(session)
|
|
1336
2379
|
if (!Array.isArray(events)) {
|
|
1337
|
-
return { nodes: [], head: undefined, checkpoints: new Map(), volatileSuffix: [], available: true }
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
|
-
|
|
2380
|
+
return { nodes: [], head: undefined, checkpoints: new Map(), volatileSuffix: [], provenBindings: [], available: true }
|
|
2381
|
+
}
|
|
2382
|
+
// Recovery rebuilds bindings from the cell sources and journals. A checkpoint
|
|
2383
|
+
// outside the payload window keeps those as digests, so the fold is repeated with
|
|
2384
|
+
// retention disabled on the same slice: the log is the authority, and reading it
|
|
2385
|
+
// costs one extra fold only when a digested cluster is present.
|
|
2386
|
+
const folded = foldSessionTimeline(events)
|
|
2387
|
+
const needsPayload = [...folded.executableCalls.values()]
|
|
2388
|
+
.some(entry => typeof entry?.event?.data?.arguments !== 'string')
|
|
2389
|
+
const timeline = needsPayload ? withFullRetention(() => foldSessionTimeline(events)) : folded
|
|
2390
|
+
const calls = timelineCalls(timeline).filter(call => call.seq !== currentCallSeq)
|
|
1341
2391
|
const { executableCalls, results, found } = timeline
|
|
1342
2392
|
const visibleCalls = options?.visibleCallSeqs
|
|
1343
2393
|
const appliedBoundaries = options?.[RECOVERY_BOUNDARY_EVIDENCE]
|
|
@@ -1407,7 +2457,12 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
|
|
|
1407
2457
|
const records = []
|
|
1408
2458
|
const orderedRecords = []
|
|
1409
2459
|
const contractedCallRanges = []
|
|
1410
|
-
|
|
2460
|
+
// A live environment that was released cannot lend its recorded live-only
|
|
2461
|
+
// values to the environment that replaces it. The flag belongs to the fold, so
|
|
2462
|
+
// every re-fold below carries it.
|
|
2463
|
+
const foldOptions = { liveOnlyLost: options?.liveOnlyLost === true,
|
|
2464
|
+
editRewritesHistory: options?.editRewritesHistory === true }
|
|
2465
|
+
let state = foldRecords(records, invalidCallSeqs, foldOptions)
|
|
1411
2466
|
let boundaryIndex = 0
|
|
1412
2467
|
const carrierCallSeqForBoundary = boundary => boundary.carrierCallSeq
|
|
1413
2468
|
const isContractedCallSeq = (callSeq) => contractedCallRanges.some(range => (
|
|
@@ -1428,6 +2483,7 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
|
|
|
1428
2483
|
if (record.call.seq >= contractionSeq) invalidCallSeqs.add(record.call.seq)
|
|
1429
2484
|
}
|
|
1430
2485
|
state = foldRecords(records, invalidCallSeqs, {
|
|
2486
|
+
...foldOptions,
|
|
1431
2487
|
surfaceContracted: state.surfaceContracted,
|
|
1432
2488
|
})
|
|
1433
2489
|
registerUnavailable({ seq: carrierCallSeq ?? boundary.failedCallSeq, reason }, state)
|
|
@@ -1487,6 +2543,7 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
|
|
|
1487
2543
|
}
|
|
1488
2544
|
}
|
|
1489
2545
|
state = foldRecords(records, invalidCallSeqs, {
|
|
2546
|
+
...foldOptions,
|
|
1490
2547
|
surfaceContracted: resolvesUnavailable ? false : state.surfaceContracted,
|
|
1491
2548
|
})
|
|
1492
2549
|
if (resolvesUnavailable) {
|
|
@@ -1510,13 +2567,19 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
|
|
|
1510
2567
|
if (result.postEditError !== undefined) {
|
|
1511
2568
|
invalidResultReasons.set(call.seq, result.postEditError)
|
|
1512
2569
|
registerUnavailable({ seq: call.seq, reason: result.postEditError })
|
|
1513
|
-
} else if (result.postEdit !== undefined) {
|
|
2570
|
+
} else if (result.postEdit !== undefined && result.postEdit.derived.unexecuted !== true) {
|
|
2571
|
+
// A derived execution that never ran proves no state of its own, so it is
|
|
2572
|
+
// not folded as a record; the root cell's own record already carries the
|
|
2573
|
+
// proof of the accepted edit.
|
|
1514
2574
|
const postEdit = result.postEdit
|
|
1515
2575
|
orderedRecords.push({
|
|
1516
2576
|
call: timeline.eventFacts.find(fact => fact.event.seq === postEdit.executionStartSeq).event,
|
|
1517
2577
|
code: postEdit.derived.code,
|
|
1518
2578
|
result: { eventSeq: result.eventSeq, journal: postEdit.derived.journal,
|
|
1519
2579
|
derived: postEdit.derived, userBindings: postEdit.derived.userBindings },
|
|
2580
|
+
// The revision names the cell it edits. Whether that relation replaces
|
|
2581
|
+
// the target or appends to it is the fold's decision, not the log's.
|
|
2582
|
+
supersedes: postEdit.targetCallSeq,
|
|
1520
2583
|
hidden: visibleCalls instanceof Set && (!visibleCalls.has(call.seq)
|
|
1521
2584
|
|| !visibleCalls.has(postEdit.targetCallSeq)),
|
|
1522
2585
|
})
|
|
@@ -1566,13 +2629,38 @@ export function recoverJournal(session, currentCallSeq, options = {}) {
|
|
|
1566
2629
|
}
|
|
1567
2630
|
applyBoundariesBefore(Number.POSITIVE_INFINITY)
|
|
1568
2631
|
if (unavailableBoundary !== undefined) registerUnavailable(unavailableBoundary, state)
|
|
1569
|
-
|
|
2632
|
+
// The names the session log proves stay off the enumerable history shape so
|
|
2633
|
+
// every existing consumer sees the same object it saw before; recovery-only
|
|
2634
|
+
// readers (the missing-binding notice) reach them through this property.
|
|
2635
|
+
const history = {
|
|
1570
2636
|
nodes: state.nodes,
|
|
1571
2637
|
head: state.head,
|
|
1572
2638
|
checkpoints: state.checkpoints,
|
|
1573
2639
|
volatileSuffix: state.volatileSuffix,
|
|
1574
2640
|
available: found && !state.surfaceContracted && unavailableBoundary === undefined,
|
|
1575
2641
|
}
|
|
2642
|
+
history.provenBindings = [...state.provenBindings]
|
|
2643
|
+
// Recovery-only, like the proved names: the enumerable history shape is a
|
|
2644
|
+
// contract other readers compare against, so the deletion set stays off it.
|
|
2645
|
+
Object.defineProperty(history, 'deletedBindings', {
|
|
2646
|
+
value: [...state.deletedBindings],
|
|
2647
|
+
enumerable: false,
|
|
2648
|
+
configurable: true,
|
|
2649
|
+
})
|
|
2650
|
+
// The supersession relation is recovery evidence of the same kind: which cell
|
|
2651
|
+
// each revision replaced, and which proved names the path no longer supplies
|
|
2652
|
+
// because of it. Both stay off the enumerable shape for the same reason.
|
|
2653
|
+
Object.defineProperty(history, 'supersessions', {
|
|
2654
|
+
value: [...state.supersessions],
|
|
2655
|
+
enumerable: false,
|
|
2656
|
+
configurable: true,
|
|
2657
|
+
})
|
|
2658
|
+
Object.defineProperty(history, 'supersededBindings', {
|
|
2659
|
+
value: [...state.supersededBindings],
|
|
2660
|
+
enumerable: false,
|
|
2661
|
+
configurable: true,
|
|
2662
|
+
})
|
|
2663
|
+
return history
|
|
1576
2664
|
}
|
|
1577
2665
|
|
|
1578
2666
|
/** Prove that recovery consumes every persisted boundary at its declared frontier. */
|