dsh-ptc-plus 0.2.1
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/LICENSE +21 -0
- package/README.md +137 -0
- package/README.zh.md +137 -0
- package/assets/dsh-ptc-plus-banner.webp +0 -0
- package/assets/ptc-plus-repair.png +0 -0
- package/assets/ptc-plus-settings.png +0 -0
- package/client.js +366 -0
- package/cordis.patch.yml +5 -0
- package/docs/README.md +21 -0
- package/docs/adr/0001-delegate-governance-to-dsh.md +3 -0
- package/docs/adr/0002-limit-work-map-scope.md +3 -0
- package/docs/adr/0003-prefer-native-program-surfaces.md +3 -0
- package/docs/adr/0004-declare-program-bindings-at-the-host.md +5 -0
- package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +129 -0
- package/docs/adr/0006-rewrite-module-syntax-with-an-ast.md +45 -0
- package/docs/adr/0007-separate-worker-transport-from-session-semantics.md +39 -0
- package/docs/adr/0008-define-agent-operating-contract.md +25 -0
- package/docs/adr/0009-bound-expensive-acceptance-default.md +39 -0
- package/docs/adr/0010-session-log-derived-recovery-tips.md +26 -0
- package/docs/adr/0011-isolate-the-local-recovery-tip-provider.md +20 -0
- package/docs/adr/0012-preserve-mixed-destructuring-initialization.md +23 -0
- package/docs/adr/0013-isolate-repl-redeclaration-convenience.md +21 -0
- package/docs/adr/0014-persist-cell-rewrite-policy.md +25 -0
- package/docs/adr/0015-preserve-source-positions-through-rewrites.md +25 -0
- package/docs/adr/0016-archive-resolved-review-findings.md +43 -0
- package/docs/adr/0017-track-the-latest-dsh-public-surface.md +23 -0
- package/docs/adr/0018-use-versioned-fixture-for-ab-benchmark.md +25 -0
- package/docs/adr/0019-plugin-settings-and-kill-switch.md +32 -0
- package/docs/adr/0020-optional-cordis-tools-in-ptc-mode.md +36 -0
- package/docs/adr/0021-separate-host-bootstrap-from-session-runtime.md +68 -0
- package/docs/adr/README.md +18 -0
- package/docs/architecture.md +134 -0
- package/docs/capability-projection.md +48 -0
- package/docs/client-ui.md +47 -0
- package/docs/durability-design.md +265 -0
- package/docs/evaluation.md +62 -0
- package/docs/installation.md +82 -0
- package/docs/program-data-plane.md +61 -0
- package/docs/publishing.md +37 -0
- package/docs/runtime-reference.md +119 -0
- package/docs/value-wire.md +61 -0
- package/index.js +532 -0
- package/internal/ast-traversal.js +20 -0
- package/internal/binding-descriptors.js +95 -0
- package/internal/binding-pattern.js +54 -0
- package/internal/builtin-esm-sync.js +9 -0
- package/internal/cell-analysis.js +467 -0
- package/internal/cell-rewriter.js +520 -0
- package/internal/config-spec.js +179 -0
- package/internal/cordis-tools-owner.js +363 -0
- package/internal/diagnostic.js +133 -0
- package/internal/direct-surface-owner.js +326 -0
- package/internal/edit-transport-owner.js +287 -0
- package/internal/failure-reporting.js +139 -0
- package/internal/kernel-worker.js +484 -0
- package/internal/module-policy.js +46 -0
- package/internal/program-bindings.js +187 -0
- package/internal/record-utils.js +53 -0
- package/internal/recovery-tips.js +81 -0
- package/internal/rejected-cell-editor.js +328 -0
- package/internal/repl-convenience.js +201 -0
- package/internal/runtime-bridge-owner.js +417 -0
- package/internal/runtime-config.js +77 -0
- package/internal/runtime-contexts.js +31 -0
- package/internal/sdk-projection.js +88 -0
- package/internal/session-cell-executor.js +612 -0
- package/internal/session-journal.js +889 -0
- package/internal/session-log-view.js +80 -0
- package/internal/session-runtime.js +536 -0
- package/internal/session-state.js +45 -0
- package/internal/source-position-map.js +224 -0
- package/internal/tool-call-canonicalizer.js +339 -0
- package/internal/value-wire.js +421 -0
- package/internal/worker-client.js +218 -0
- package/internal/worker-cwd-virtualization.js +155 -0
- package/package.json +100 -0
- package/src/client.js +165 -0
|
@@ -0,0 +1,889 @@
|
|
|
1
|
+
import { decodeValue, encodeValue, normalizeValueWire } from './value-wire.js'
|
|
2
|
+
import { normalizeDiagnostic } from './diagnostic.js'
|
|
3
|
+
import { assertOwnFields, isRecord } from './record-utils.js'
|
|
4
|
+
|
|
5
|
+
export const JOURNAL_KEY = 'dshPtcPlus'
|
|
6
|
+
export const EDIT_TARGET_KEY = 'dshPtcPlusEdit'
|
|
7
|
+
export const DERIVED_RUN_KEY = 'dshPtcPlusDerivedRun'
|
|
8
|
+
export const REWRITES_KEY = 'dshPtcPlusRewrites'
|
|
9
|
+
export const RECOVERY_BOUNDARY_KEY = 'dshPtcPlusRecoveryBoundaries'
|
|
10
|
+
export const JOURNAL_VERSION = 3
|
|
11
|
+
const LEGACY_JOURNAL_VERSION = 1
|
|
12
|
+
const INTERMEDIATE_JOURNAL_VERSION = 2
|
|
13
|
+
export const RECOVERY_BOUNDARY_EVENT = 'ptc-plus/recovery-boundary'
|
|
14
|
+
|
|
15
|
+
const STATUSES = new Set(['durable', 'volatile', 'discarded', 'noop'])
|
|
16
|
+
const BINDING_MODES = new Set(['loose', 'strict'])
|
|
17
|
+
const JOURNAL_FIELDS = new Set(['version', 'bindingMode', 'rewritePolicy', 'status', 'calls', 'operations', 'confirms', 'diagnostics', 'completion', 'volatileReason'])
|
|
18
|
+
const LEGACY_JOURNAL_FIELDS = new Set([...JOURNAL_FIELDS].filter(field => field !== 'rewritePolicy'))
|
|
19
|
+
const REWRITE_POLICY_FIELDS = new Set(['autoRewriteImports', 'autoStripExports', 'autoSplitRedeclarations'])
|
|
20
|
+
const CALL_SUCCESS_FIELDS = new Set(['global', 'member', 'args', 'ok', 'value', 'settle'])
|
|
21
|
+
const CALL_ERROR_FIELDS = new Set(['global', 'member', 'args', 'ok', 'error', 'settle'])
|
|
22
|
+
const OPERATION_FIELDS = new Set(['action', 'name'])
|
|
23
|
+
const RETURN_FIELDS = new Set(['kind', 'hasValue', 'value'])
|
|
24
|
+
const THROW_FIELDS = new Set(['kind', 'error'])
|
|
25
|
+
const ERROR_FIELDS = new Set(['kind', 'message'])
|
|
26
|
+
const EDIT_TARGET_FIELDS = new Set(['targetCallSeq'])
|
|
27
|
+
const DERIVED_RUN_FIELDS = new Set(['code', 'description'])
|
|
28
|
+
|
|
29
|
+
function cloneJson(value) {
|
|
30
|
+
if (value === undefined) return undefined
|
|
31
|
+
return decodeValue(encodeValue(value))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function validName(name) {
|
|
35
|
+
return typeof name === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeCalls(value) {
|
|
39
|
+
if (!Array.isArray(value)) throw new Error('invalid dsh-ptc-plus journal calls')
|
|
40
|
+
const calls = value.map((call, index) => {
|
|
41
|
+
if (!isRecord(call) || typeof call.global !== 'string' || typeof call.member !== 'string'
|
|
42
|
+
|| !Object.hasOwn(call, 'args') || (call.ok !== true && call.ok !== false)
|
|
43
|
+
|| !Number.isSafeInteger(call.settle) || call.settle < 0) {
|
|
44
|
+
throw new Error(`invalid dsh-ptc-plus journal call at index ${index}`)
|
|
45
|
+
}
|
|
46
|
+
assertOwnFields(call, call.ok ? CALL_SUCCESS_FIELDS : CALL_ERROR_FIELDS, `journal call at index ${index}`)
|
|
47
|
+
if (call.ok === true && !Object.hasOwn(call, 'value')) {
|
|
48
|
+
throw new Error(`journal call at index ${index} is missing its value`)
|
|
49
|
+
}
|
|
50
|
+
if (call.ok === false && typeof call.error !== 'string') {
|
|
51
|
+
throw new Error(`journal call at index ${index} is missing its error`)
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
global: call.global,
|
|
55
|
+
member: call.member,
|
|
56
|
+
args: normalizeValueWire(call.args),
|
|
57
|
+
ok: call.ok,
|
|
58
|
+
settle: call.settle,
|
|
59
|
+
...(call.ok ? { value: normalizeValueWire(call.value) } : { error: call.error }),
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
const order = calls.map(call => call.settle).sort((left, right) => left - right)
|
|
63
|
+
if (order.some((settle, index) => settle !== index)) {
|
|
64
|
+
throw new Error('dsh-ptc-plus journal call settlement order is not contiguous')
|
|
65
|
+
}
|
|
66
|
+
return calls
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeOperations(value) {
|
|
70
|
+
if (!Array.isArray(value)) throw new Error('invalid dsh-ptc-plus journal operations')
|
|
71
|
+
return value.map((operation, index) => {
|
|
72
|
+
if (!isRecord(operation) || !['save', 'restore', 'delete'].includes(operation.action)
|
|
73
|
+
|| ((operation.action !== 'restore' || operation.name !== undefined) && !validName(operation.name))) {
|
|
74
|
+
throw new Error(`invalid dsh-ptc-plus journal operation at index ${index}`)
|
|
75
|
+
}
|
|
76
|
+
assertOwnFields(operation, OPERATION_FIELDS, `journal operation at index ${index}`)
|
|
77
|
+
return { action: operation.action, ...(operation.name === undefined ? {} : { name: operation.name }) }
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeCompletion(value, required) {
|
|
82
|
+
if (value === undefined && !required) return undefined
|
|
83
|
+
if (!isRecord(value) || !['return', 'throw'].includes(value.kind)) {
|
|
84
|
+
throw new Error('invalid dsh-ptc-plus journal completion')
|
|
85
|
+
}
|
|
86
|
+
if (value.kind === 'return') {
|
|
87
|
+
assertOwnFields(value, RETURN_FIELDS, 'journal return completion')
|
|
88
|
+
if (typeof value.hasValue !== 'boolean'
|
|
89
|
+
|| (value.hasValue ? !Object.hasOwn(value, 'value') : Object.hasOwn(value, 'value'))) {
|
|
90
|
+
throw new Error('invalid dsh-ptc-plus journal return value')
|
|
91
|
+
}
|
|
92
|
+
return Object.freeze({
|
|
93
|
+
kind: 'return',
|
|
94
|
+
hasValue: value.hasValue,
|
|
95
|
+
...(value.hasValue ? { value: normalizeValueWire(value.value) } : {}),
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
if (!isRecord(value.error) || typeof value.error.kind !== 'string' || typeof value.error.message !== 'string') {
|
|
99
|
+
throw new Error('invalid dsh-ptc-plus journal throw completion')
|
|
100
|
+
}
|
|
101
|
+
assertOwnFields(value, THROW_FIELDS, 'journal throw completion')
|
|
102
|
+
assertOwnFields(value.error, ERROR_FIELDS, 'journal completion error')
|
|
103
|
+
return Object.freeze({
|
|
104
|
+
kind: 'throw',
|
|
105
|
+
error: Object.freeze({ kind: value.error.kind, message: value.error.message }),
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const LEGACY_REWRITE_POLICY = Object.freeze({
|
|
110
|
+
autoRewriteImports: false,
|
|
111
|
+
autoStripExports: false,
|
|
112
|
+
autoSplitRedeclarations: false,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
function normalizeLegacyConfirms(value, resolveLegacyConfirm) {
|
|
116
|
+
if (value === undefined) return []
|
|
117
|
+
if (!Array.isArray(value) || value.some(callId => typeof callId !== 'string' || callId.length === 0)) {
|
|
118
|
+
throw new Error('invalid dsh-ptc-plus confirmed no-op calls')
|
|
119
|
+
}
|
|
120
|
+
if (new Set(value).size !== value.length) throw new Error('duplicate dsh-ptc-plus confirmed no-op call')
|
|
121
|
+
if (value.length === 0) return []
|
|
122
|
+
if (typeof resolveLegacyConfirm !== 'function') {
|
|
123
|
+
throw new Error('legacy dsh-ptc-plus confirmed no-op calls require session call identity')
|
|
124
|
+
}
|
|
125
|
+
const confirms = value.map(callId => resolveLegacyConfirm(callId))
|
|
126
|
+
if (confirms.some(callSeq => !Number.isSafeInteger(callSeq) || callSeq < 0)) {
|
|
127
|
+
throw new Error('legacy dsh-ptc-plus confirmed no-op call is not uniquely persisted')
|
|
128
|
+
}
|
|
129
|
+
if (new Set(confirms).size !== confirms.length) {
|
|
130
|
+
throw new Error('duplicate dsh-ptc-plus confirmed no-op call')
|
|
131
|
+
}
|
|
132
|
+
return confirms
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function normalizeConfirms(value) {
|
|
136
|
+
if (value === undefined) return []
|
|
137
|
+
if (!Array.isArray(value) || value.some(callSeq => !Number.isSafeInteger(callSeq) || callSeq < 0)) {
|
|
138
|
+
throw new Error('invalid dsh-ptc-plus confirmed no-op calls')
|
|
139
|
+
}
|
|
140
|
+
const confirms = [...new Set(value)]
|
|
141
|
+
if (confirms.length !== value.length) throw new Error('duplicate dsh-ptc-plus confirmed no-op call')
|
|
142
|
+
return confirms
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function normalizeDiagnostics(value) {
|
|
146
|
+
if (!Array.isArray(value)) throw new Error('invalid dsh-ptc-plus journal diagnostics')
|
|
147
|
+
return value.map((diagnostic, index) => {
|
|
148
|
+
try {
|
|
149
|
+
return normalizeDiagnostic(diagnostic)
|
|
150
|
+
} catch (error) {
|
|
151
|
+
throw new Error(`invalid dsh-ptc-plus journal diagnostic at index ${index}: ${error.message}`)
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function normalizeRewritePolicy(value) {
|
|
157
|
+
if (!isRecord(value)) throw new Error('invalid dsh-ptc-plus journal rewrite policy')
|
|
158
|
+
assertOwnFields(value, REWRITE_POLICY_FIELDS, 'journal rewrite policy')
|
|
159
|
+
for (const key of REWRITE_POLICY_FIELDS) {
|
|
160
|
+
if (typeof value[key] !== 'boolean') throw new Error(`invalid dsh-ptc-plus journal rewrite policy ${key}`)
|
|
161
|
+
}
|
|
162
|
+
return Object.freeze({
|
|
163
|
+
autoRewriteImports: value.autoRewriteImports,
|
|
164
|
+
autoStripExports: value.autoStripExports,
|
|
165
|
+
autoSplitRedeclarations: value.autoSplitRedeclarations,
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function migrateJournal(value, resolveLegacyConfirm) {
|
|
170
|
+
if (value.version !== LEGACY_JOURNAL_VERSION) return value
|
|
171
|
+
assertOwnFields(value, LEGACY_JOURNAL_FIELDS, 'dsh-ptc-plus journal')
|
|
172
|
+
return {
|
|
173
|
+
...value,
|
|
174
|
+
version: JOURNAL_VERSION,
|
|
175
|
+
rewritePolicy: LEGACY_REWRITE_POLICY,
|
|
176
|
+
confirms: normalizeLegacyConfirms(value.confirms, resolveLegacyConfirm),
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Validate and detach one journal emitted by the runtime. */
|
|
181
|
+
export function normalizeJournal(value, options = {}) {
|
|
182
|
+
if (!isRecord(value)) throw new Error('invalid dsh-ptc-plus journal')
|
|
183
|
+
if (![LEGACY_JOURNAL_VERSION, INTERMEDIATE_JOURNAL_VERSION, JOURNAL_VERSION].includes(value.version)
|
|
184
|
+
|| !STATUSES.has(value.status)) {
|
|
185
|
+
throw new Error('invalid dsh-ptc-plus journal')
|
|
186
|
+
}
|
|
187
|
+
const migrated = migrateJournal(value, options.resolveLegacyConfirm)
|
|
188
|
+
assertOwnFields(migrated, JOURNAL_FIELDS, 'dsh-ptc-plus journal')
|
|
189
|
+
if (!BINDING_MODES.has(migrated.bindingMode)) throw new Error('invalid dsh-ptc-plus journal binding mode')
|
|
190
|
+
const rewritePolicy = normalizeRewritePolicy(migrated.rewritePolicy)
|
|
191
|
+
const calls = normalizeCalls(migrated.calls)
|
|
192
|
+
const operations = normalizeOperations(migrated.operations)
|
|
193
|
+
const confirms = normalizeConfirms(migrated.confirms)
|
|
194
|
+
const diagnostics = normalizeDiagnostics(migrated.diagnostics)
|
|
195
|
+
const completion = normalizeCompletion(
|
|
196
|
+
migrated.completion,
|
|
197
|
+
migrated.status === 'durable' || migrated.status === 'volatile',
|
|
198
|
+
)
|
|
199
|
+
if ((migrated.status === 'discarded' || migrated.status === 'noop')
|
|
200
|
+
&& (calls.length !== 0 || operations.length !== 0)) {
|
|
201
|
+
throw new Error(`${migrated.status} dsh-ptc-plus journal must not contain calls or operations`)
|
|
202
|
+
}
|
|
203
|
+
if (migrated.volatileReason !== undefined && typeof migrated.volatileReason !== 'string') {
|
|
204
|
+
throw new Error('invalid dsh-ptc-plus volatile reason')
|
|
205
|
+
}
|
|
206
|
+
if (migrated.volatileReason !== undefined && migrated.status !== 'volatile' && migrated.status !== 'discarded') {
|
|
207
|
+
throw new Error('dsh-ptc-plus volatile reason requires volatile or discarded status')
|
|
208
|
+
}
|
|
209
|
+
return Object.freeze({
|
|
210
|
+
version: JOURNAL_VERSION,
|
|
211
|
+
bindingMode: migrated.bindingMode,
|
|
212
|
+
rewritePolicy,
|
|
213
|
+
status: migrated.status,
|
|
214
|
+
calls: Object.freeze(calls),
|
|
215
|
+
operations: Object.freeze(operations),
|
|
216
|
+
confirms: Object.freeze(confirms),
|
|
217
|
+
diagnostics: Object.freeze(diagnostics),
|
|
218
|
+
...(completion === undefined ? {} : { completion }),
|
|
219
|
+
...(migrated.volatileReason === undefined ? {} : { volatileReason: migrated.volatileReason }),
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Validate the required persisted relation that makes one edit result executable history. */
|
|
224
|
+
export function normalizeDerivedEditResult(meta, expectedTargetCallSeq) {
|
|
225
|
+
if (!Number.isSafeInteger(expectedTargetCallSeq) || expectedTargetCallSeq < 0) {
|
|
226
|
+
throw new Error('derived edit does not identify an eligible target call')
|
|
227
|
+
}
|
|
228
|
+
if (!isRecord(meta)) throw new Error('invalid dsh-ptc-plus derived edit metadata')
|
|
229
|
+
const target = meta[EDIT_TARGET_KEY]
|
|
230
|
+
const derived = meta[DERIVED_RUN_KEY]
|
|
231
|
+
if (!isRecord(target)) throw new Error('invalid dsh-ptc-plus edit target metadata')
|
|
232
|
+
assertOwnFields(target, EDIT_TARGET_FIELDS, 'edit target metadata')
|
|
233
|
+
if (target.targetCallSeq !== expectedTargetCallSeq) {
|
|
234
|
+
throw new Error('derived edit target does not match the eligible target call')
|
|
235
|
+
}
|
|
236
|
+
if (!isRecord(derived) || typeof derived.code !== 'string' || typeof derived.description !== 'string') {
|
|
237
|
+
throw new Error('invalid dsh-ptc-plus derived run metadata')
|
|
238
|
+
}
|
|
239
|
+
assertOwnFields(derived, DERIVED_RUN_FIELDS, 'derived run metadata')
|
|
240
|
+
const journal = normalizeJournal(meta[JOURNAL_KEY])
|
|
241
|
+
if (journal.status === 'noop') throw new Error('derived edit journal must not be noop')
|
|
242
|
+
const recoveryBoundaries = meta[RECOVERY_BOUNDARY_KEY] === undefined
|
|
243
|
+
? undefined
|
|
244
|
+
: normalizeRecoveryBoundaries(meta[RECOVERY_BOUNDARY_KEY])
|
|
245
|
+
return Object.freeze({
|
|
246
|
+
targetCallSeq: target.targetCallSeq,
|
|
247
|
+
code: derived.code,
|
|
248
|
+
description: derived.description,
|
|
249
|
+
journal,
|
|
250
|
+
...(recoveryBoundaries === undefined ? {} : { recoveryBoundaries }),
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Compare only the required persisted relation for one derived edit. */
|
|
255
|
+
export function derivedEditResultsEqual(leftMeta, rightMeta, expectedTargetCallSeq) {
|
|
256
|
+
try {
|
|
257
|
+
const left = encodeValue(normalizeDerivedEditResult(leftMeta, expectedTargetCallSeq))
|
|
258
|
+
const right = encodeValue(normalizeDerivedEditResult(rightMeta, expectedTargetCallSeq))
|
|
259
|
+
return JSON.stringify(left) === JSON.stringify(right)
|
|
260
|
+
} catch {
|
|
261
|
+
return false
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Compare journal semantics without recursive traversal of nested JSON. */
|
|
266
|
+
export function journalsEqual(left, right) {
|
|
267
|
+
try {
|
|
268
|
+
const leftWire = encodeValue(normalizeJournal(left))
|
|
269
|
+
const rightWire = encodeValue(normalizeJournal(right))
|
|
270
|
+
return JSON.stringify(leftWire) === JSON.stringify(rightWire)
|
|
271
|
+
} catch {
|
|
272
|
+
return false
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Compare the optional recovery frontier attached to one settlement. */
|
|
277
|
+
export function recoveryBoundariesEqual(left, right) {
|
|
278
|
+
if (left === undefined || right === undefined) return left === right
|
|
279
|
+
try {
|
|
280
|
+
return JSON.stringify(normalizeRecoveryBoundaries(left))
|
|
281
|
+
=== JSON.stringify(normalizeRecoveryBoundaries(right))
|
|
282
|
+
} catch {
|
|
283
|
+
return false
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function normalizeRecoveryBoundaryValue(value, eventSeq = undefined) {
|
|
288
|
+
if (!isRecord(value)
|
|
289
|
+
|| !Number.isSafeInteger(value.failedCallSeq) || value.failedCallSeq < 0
|
|
290
|
+
|| (value.frontierCallSeq !== null
|
|
291
|
+
&& (!Number.isSafeInteger(value.frontierCallSeq) || value.frontierCallSeq < 0))) {
|
|
292
|
+
throw new Error('invalid dsh-ptc-plus recovery boundary')
|
|
293
|
+
}
|
|
294
|
+
assertOwnFields(value, new Set(['failedCallSeq', 'frontierCallSeq']), 'recovery boundary')
|
|
295
|
+
if (eventSeq !== undefined && (!Number.isSafeInteger(eventSeq) || eventSeq < 0)) {
|
|
296
|
+
throw new Error('invalid dsh-ptc-plus recovery boundary event sequence')
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
failedCallSeq: value.failedCallSeq,
|
|
300
|
+
frontierCallSeq: value.frontierCallSeq,
|
|
301
|
+
...(eventSeq === undefined ? {} : { eventSeq }),
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Validate recovery boundaries stored on a settled tool result. */
|
|
306
|
+
export function normalizeRecoveryBoundaries(value, eventSeq = undefined) {
|
|
307
|
+
if (!Array.isArray(value)) throw new Error('invalid dsh-ptc-plus recovery boundaries')
|
|
308
|
+
return Object.freeze(value.map(boundary => Object.freeze(
|
|
309
|
+
normalizeRecoveryBoundaryValue(boundary, eventSeq),
|
|
310
|
+
)))
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Merge replay contractions into private tool-result metadata. */
|
|
314
|
+
export function withRecoveryBoundaries(meta, boundaries) {
|
|
315
|
+
const base = isRecord(meta) ? { ...meta } : meta === undefined ? {} : { value: cloneJson(meta) }
|
|
316
|
+
base[RECOVERY_BOUNDARY_KEY] = normalizeRecoveryBoundaries(boundaries)
|
|
317
|
+
return base
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Convert the retired custom boundary event in a raw log before DSH restores it.
|
|
322
|
+
* The returned log is a detached, renumbered copy; the input is never mutated.
|
|
323
|
+
*/
|
|
324
|
+
export function migrateRecoveryBoundaryEvents(events) {
|
|
325
|
+
if (!Array.isArray(events)) throw new TypeError('recovery-boundary migration expects an event array')
|
|
326
|
+
const migrated = []
|
|
327
|
+
const pending = []
|
|
328
|
+
for (const event of events) {
|
|
329
|
+
if (event?.type === RECOVERY_BOUNDARY_EVENT) {
|
|
330
|
+
if (!Number.isSafeInteger(event.seq) || event.seq < 0) {
|
|
331
|
+
throw new Error('invalid dsh-ptc-plus recovery boundary event sequence')
|
|
332
|
+
}
|
|
333
|
+
pending.push(normalizeRecoveryBoundaryValue(event.data))
|
|
334
|
+
continue
|
|
335
|
+
}
|
|
336
|
+
const detached = cloneJson(event)
|
|
337
|
+
if (pending.length > 0 && detached?.type === 'tool/result') {
|
|
338
|
+
const data = isRecord(detached.data) ? { ...detached.data } : {}
|
|
339
|
+
const meta = isRecord(data.meta) ? { ...data.meta } : {}
|
|
340
|
+
const existing = meta[RECOVERY_BOUNDARY_KEY] === undefined
|
|
341
|
+
? []
|
|
342
|
+
: normalizeRecoveryBoundaries(meta[RECOVERY_BOUNDARY_KEY])
|
|
343
|
+
meta[RECOVERY_BOUNDARY_KEY] = [...existing, ...pending]
|
|
344
|
+
data.meta = meta
|
|
345
|
+
detached.data = data
|
|
346
|
+
pending.length = 0
|
|
347
|
+
}
|
|
348
|
+
migrated.push(detached)
|
|
349
|
+
}
|
|
350
|
+
if (pending.length > 0) {
|
|
351
|
+
throw new Error('recovery boundary has no later tool/result settlement for migration')
|
|
352
|
+
}
|
|
353
|
+
const sequenceMap = new Map()
|
|
354
|
+
for (const [index, event] of migrated.entries()) {
|
|
355
|
+
if (Number.isSafeInteger(event?.seq) && event.seq >= 0) sequenceMap.set(event.seq, index)
|
|
356
|
+
}
|
|
357
|
+
return Object.freeze(migrated.map((event, index) => Object.freeze({
|
|
358
|
+
...event,
|
|
359
|
+
seq: index,
|
|
360
|
+
...(Array.isArray(event.sourceEventSeqs)
|
|
361
|
+
? { sourceEventSeqs: event.sourceEventSeqs.map(seq => sequenceMap.get(seq) ?? seq) }
|
|
362
|
+
: {}),
|
|
363
|
+
})))
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Resolve the persisted event identity for one named tool call being dispatched. */
|
|
367
|
+
export function liveToolCallSeq(session, callId, toolName) {
|
|
368
|
+
const events = session?.events
|
|
369
|
+
if (!Array.isArray(events) || typeof callId !== 'string' || callId.length === 0
|
|
370
|
+
|| typeof toolName !== 'string' || toolName.length === 0) return undefined
|
|
371
|
+
|
|
372
|
+
const pairedCallSeqs = new Set()
|
|
373
|
+
for (const event of events) {
|
|
374
|
+
if (event?.type !== 'tool/result' || !Array.isArray(event.sourceEventSeqs)) continue
|
|
375
|
+
for (const sourceSeq of event.sourceEventSeqs) {
|
|
376
|
+
if (Number.isSafeInteger(sourceSeq) && sourceSeq >= 0) pairedCallSeqs.add(sourceSeq)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const candidates = []
|
|
381
|
+
for (const event of events) {
|
|
382
|
+
if (event?.type !== 'tool/call' || event.data?.name !== toolName
|
|
383
|
+
|| event.data.callId !== callId || pairedCallSeqs.has(event.seq)) continue
|
|
384
|
+
if (!Number.isSafeInteger(event.seq) || event.seq < 0) {
|
|
385
|
+
throw new Error(`current ${toolName} call has an invalid session event sequence`)
|
|
386
|
+
}
|
|
387
|
+
candidates.push(event.seq)
|
|
388
|
+
}
|
|
389
|
+
if (candidates.length > 1) {
|
|
390
|
+
throw new Error(`session log contains multiple unpaired ${toolName} calls for callId ${JSON.stringify(callId)}`)
|
|
391
|
+
}
|
|
392
|
+
return candidates[0]
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Start a mutable journal for one live cell. */
|
|
396
|
+
export function createJournal(confirms = [], bindingMode, rewritePolicy) {
|
|
397
|
+
if (!BINDING_MODES.has(bindingMode)) throw new TypeError('invalid dsh-ptc-plus journal binding mode')
|
|
398
|
+
return {
|
|
399
|
+
version: JOURNAL_VERSION,
|
|
400
|
+
bindingMode,
|
|
401
|
+
rewritePolicy: normalizeRewritePolicy(rewritePolicy),
|
|
402
|
+
calls: [],
|
|
403
|
+
operations: [],
|
|
404
|
+
confirms: [...confirms],
|
|
405
|
+
diagnostics: [],
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function sourceForRunCall(call) {
|
|
410
|
+
try {
|
|
411
|
+
const args = JSON.parse(call.data.arguments)
|
|
412
|
+
return isRecord(args) && typeof args.code === 'string' ? args.code : undefined
|
|
413
|
+
} catch {
|
|
414
|
+
return undefined
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Return an owned state transition; reject volatile saves and unknown named restores. */
|
|
419
|
+
export function reduceStateOperations({ nodes, head, checkpoints }, operations, nodeIndex) {
|
|
420
|
+
const nextCheckpoints = new Map(checkpoints)
|
|
421
|
+
let nextHead = head
|
|
422
|
+
let restored = false
|
|
423
|
+
for (const operation of operations) {
|
|
424
|
+
if (operation.action === 'save') {
|
|
425
|
+
if (nodeIndex === undefined) throw new Error('volatile journal cannot save a durable REPL state')
|
|
426
|
+
nextCheckpoints.set(operation.name, nodeIndex)
|
|
427
|
+
continue
|
|
428
|
+
}
|
|
429
|
+
if (operation.action === 'delete') {
|
|
430
|
+
nextCheckpoints.delete(operation.name)
|
|
431
|
+
continue
|
|
432
|
+
}
|
|
433
|
+
const target = operation.name === undefined
|
|
434
|
+
? nodeIndex === undefined ? nextHead : nodes[nodeIndex]?.parent
|
|
435
|
+
: nextCheckpoints.get(operation.name)
|
|
436
|
+
if (operation.name !== undefined && target === undefined) {
|
|
437
|
+
throw new Error(`session log restores unknown REPL state "${operation.name}"`)
|
|
438
|
+
}
|
|
439
|
+
nextHead = target
|
|
440
|
+
restored = true
|
|
441
|
+
}
|
|
442
|
+
return { head: nextHead, checkpoints: nextCheckpoints, restored }
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function applyOperations(state, operations, nodeIndex) {
|
|
446
|
+
const transition = reduceStateOperations(state, operations, nodeIndex)
|
|
447
|
+
state.head = transition.head
|
|
448
|
+
state.checkpoints = transition.checkpoints
|
|
449
|
+
if (transition.restored) {
|
|
450
|
+
state.trusted = true
|
|
451
|
+
state.volatileSuffix.length = 0
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function applyRecord(state, record, invalidCallSeqs) {
|
|
456
|
+
const { call, code, result } = record
|
|
457
|
+
if (invalidCallSeqs.has(call.seq)) return
|
|
458
|
+
if (code === undefined || result?.journal === undefined) {
|
|
459
|
+
state.trusted = false
|
|
460
|
+
state.volatileSuffix.push({ seq: call.seq, code, reason: result?.error ?? 'missing dsh-ptc-plus journal result' })
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
const journal = result.journal
|
|
464
|
+
if (journal.status === 'noop') return
|
|
465
|
+
if (journal.status === 'discarded') {
|
|
466
|
+
if (journal.volatileReason !== undefined) {
|
|
467
|
+
state.trusted = false
|
|
468
|
+
state.volatileSuffix.push({ seq: call.seq, code, reason: journal.volatileReason })
|
|
469
|
+
}
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
if (journal.status === 'volatile') {
|
|
473
|
+
state.trusted = false
|
|
474
|
+
state.volatileSuffix.push({ seq: call.seq, code, reason: journal.volatileReason ?? 'volatile cell' })
|
|
475
|
+
applyOperations(state, journal.operations, undefined)
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
if (!state.trusted) {
|
|
479
|
+
state.trusted = true
|
|
480
|
+
state.volatileSuffix.length = 0
|
|
481
|
+
}
|
|
482
|
+
const node = Object.freeze({
|
|
483
|
+
code,
|
|
484
|
+
journal,
|
|
485
|
+
callSeq: call.seq,
|
|
486
|
+
parent: state.head,
|
|
487
|
+
})
|
|
488
|
+
const index = state.nodes.push(node) - 1
|
|
489
|
+
state.head = index
|
|
490
|
+
applyOperations(state, journal.operations, index)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function forceRecoveryHead(state, boundary) {
|
|
494
|
+
state.head = boundary.frontierCallSeq === null
|
|
495
|
+
? undefined
|
|
496
|
+
: state.nodes.findIndex(node => node.callSeq === boundary.frontierCallSeq)
|
|
497
|
+
if (state.head === -1) throw new Error('recovery boundary frontier is not reconstructable')
|
|
498
|
+
state.trusted = true
|
|
499
|
+
state.volatileSuffix = []
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function recordEventSeq(record) {
|
|
503
|
+
return record.result?.eventSeq ?? record.call.seq
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function foldRecords(records, invalidCallSeqs, boundaries = []) {
|
|
507
|
+
const state = {
|
|
508
|
+
nodes: [],
|
|
509
|
+
head: undefined,
|
|
510
|
+
checkpoints: new Map(),
|
|
511
|
+
volatileSuffix: [],
|
|
512
|
+
trusted: true,
|
|
513
|
+
}
|
|
514
|
+
let boundaryIndex = 0
|
|
515
|
+
for (const record of records) {
|
|
516
|
+
while (boundaryIndex < boundaries.length && boundaries[boundaryIndex].eventSeq < recordEventSeq(record)) {
|
|
517
|
+
forceRecoveryHead(state, boundaries[boundaryIndex++])
|
|
518
|
+
}
|
|
519
|
+
applyRecord(state, record, invalidCallSeqs)
|
|
520
|
+
}
|
|
521
|
+
while (boundaryIndex < boundaries.length) forceRecoveryHead(state, boundaries[boundaryIndex++])
|
|
522
|
+
return state
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function dependsOn(nodes, index, ancestor) {
|
|
526
|
+
for (let cursor = index; cursor !== undefined; cursor = nodes[cursor]?.parent) {
|
|
527
|
+
if (cursor === ancestor) return true
|
|
528
|
+
}
|
|
529
|
+
return false
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function timelineRun(call, result, eventIndex, journal) {
|
|
533
|
+
let args
|
|
534
|
+
try {
|
|
535
|
+
const parsed = JSON.parse(call.data.arguments)
|
|
536
|
+
if (isRecord(parsed)) args = Object.freeze(parsed)
|
|
537
|
+
} catch {
|
|
538
|
+
// Invalid persisted arguments cannot support derived source facts.
|
|
539
|
+
}
|
|
540
|
+
return Object.freeze({
|
|
541
|
+
index: eventIndex,
|
|
542
|
+
callSeq: Number.isSafeInteger(call.seq) ? call.seq : undefined,
|
|
543
|
+
args,
|
|
544
|
+
source: typeof args?.code === 'string' ? args.code : undefined,
|
|
545
|
+
journal,
|
|
546
|
+
rewrites: validatedRewrites(result.data?.meta),
|
|
547
|
+
})
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function timelineDerivedRun(call, result, eventIndex, derived) {
|
|
551
|
+
return Object.freeze({
|
|
552
|
+
index: eventIndex,
|
|
553
|
+
callSeq: Number.isSafeInteger(call.seq) ? call.seq : undefined,
|
|
554
|
+
args: Object.freeze({ description: derived.description }),
|
|
555
|
+
source: derived.code,
|
|
556
|
+
journal: derived.journal,
|
|
557
|
+
rewrites: validatedRewrites(result.data?.meta),
|
|
558
|
+
})
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function successfulTimelineRun(run) {
|
|
562
|
+
return run.journal?.completion?.kind === 'return' && run.journal.status !== 'noop'
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Fold persisted tool events once into the call/result and edit-target timeline
|
|
567
|
+
* shared by prompt projection and cold journal recovery.
|
|
568
|
+
*/
|
|
569
|
+
export function foldSessionTimeline(events) {
|
|
570
|
+
const empty = {
|
|
571
|
+
openTurn: false,
|
|
572
|
+
executableCalls: new Map(),
|
|
573
|
+
calls: [],
|
|
574
|
+
results: new Map(),
|
|
575
|
+
boundaries: [],
|
|
576
|
+
found: false,
|
|
577
|
+
unavailableResultSeq: undefined,
|
|
578
|
+
lastSuccessfulRunIndex: undefined,
|
|
579
|
+
latestRun: undefined,
|
|
580
|
+
editableRun: undefined,
|
|
581
|
+
editTargets: new Map(),
|
|
582
|
+
}
|
|
583
|
+
if (!Array.isArray(events)) return empty
|
|
584
|
+
|
|
585
|
+
const state = { ...empty }
|
|
586
|
+
const pendingByCallId = new Map()
|
|
587
|
+
const seenCallIds = new Set()
|
|
588
|
+
const claimedEditTargets = new Set()
|
|
589
|
+
const editClaims = new Map()
|
|
590
|
+
const journalResultSeqs = new Set()
|
|
591
|
+
let scope = 0
|
|
592
|
+
|
|
593
|
+
const resetTurn = (openTurn) => {
|
|
594
|
+
state.openTurn = openTurn
|
|
595
|
+
scope += 1
|
|
596
|
+
pendingByCallId.clear()
|
|
597
|
+
seenCallIds.clear()
|
|
598
|
+
claimedEditTargets.clear()
|
|
599
|
+
state.latestRun = undefined
|
|
600
|
+
state.editableRun = undefined
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
for (let eventIndex = 0; eventIndex < events.length; eventIndex += 1) {
|
|
604
|
+
const event = events[eventIndex]
|
|
605
|
+
if (event?.type === 'turn/start') {
|
|
606
|
+
resetTurn(true)
|
|
607
|
+
continue
|
|
608
|
+
}
|
|
609
|
+
if (event?.type === 'turn/end') {
|
|
610
|
+
resetTurn(false)
|
|
611
|
+
continue
|
|
612
|
+
}
|
|
613
|
+
if (event?.type === RECOVERY_BOUNDARY_EVENT) {
|
|
614
|
+
throw new Error('legacy recovery boundary requires migration before DSH session restore')
|
|
615
|
+
continue
|
|
616
|
+
}
|
|
617
|
+
if (event?.type === 'tool/call' && typeof event.data?.callId === 'string') {
|
|
618
|
+
const executable = event.data.name === 'run_code' || event.data.name === 'edit_run_code'
|
|
619
|
+
let entry = { event, eventIndex, scope }
|
|
620
|
+
if (executable) {
|
|
621
|
+
if (Number.isSafeInteger(event.seq) && event.seq >= 0 && state.executableCalls.has(event.seq)) {
|
|
622
|
+
throw new Error('session log contains a duplicate run_code call sequence')
|
|
623
|
+
}
|
|
624
|
+
if (event.data.name === 'edit_run_code') {
|
|
625
|
+
const targetCallSeq = state.editableRun?.callSeq
|
|
626
|
+
const target = targetCallSeq !== undefined && state.editableRun.source !== undefined
|
|
627
|
+
&& !claimedEditTargets.has(targetCallSeq)
|
|
628
|
+
? Object.freeze({ source: state.editableRun.source, callSeq: targetCallSeq })
|
|
629
|
+
: undefined
|
|
630
|
+
entry = { ...entry, editTarget: target }
|
|
631
|
+
state.editTargets.set(event.seq, target)
|
|
632
|
+
if (target !== undefined) {
|
|
633
|
+
claimedEditTargets.add(target.callSeq)
|
|
634
|
+
editClaims.set(event.seq, target.callSeq)
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
state.calls.push(event)
|
|
638
|
+
if (Number.isSafeInteger(event.seq) && event.seq >= 0) {
|
|
639
|
+
state.executableCalls.set(event.seq, entry)
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (seenCallIds.has(event.data.callId)) pendingByCallId.set(event.data.callId, null)
|
|
643
|
+
else {
|
|
644
|
+
seenCallIds.add(event.data.callId)
|
|
645
|
+
pendingByCallId.set(event.data.callId, entry)
|
|
646
|
+
}
|
|
647
|
+
continue
|
|
648
|
+
}
|
|
649
|
+
if (event?.type !== 'tool/result') continue
|
|
650
|
+
|
|
651
|
+
const sourceSeq = event.sourceEventSeqs?.[0]
|
|
652
|
+
const callId = event.data?.message?.source?.callId
|
|
653
|
+
let entry = Number.isSafeInteger(sourceSeq) ? state.executableCalls.get(sourceSeq) : undefined
|
|
654
|
+
if (entry === undefined && !Number.isSafeInteger(sourceSeq) && typeof callId === 'string') {
|
|
655
|
+
entry = pendingByCallId.get(callId)
|
|
656
|
+
}
|
|
657
|
+
if (typeof callId === 'string') pendingByCallId.delete(callId)
|
|
658
|
+
if (entry === null) {
|
|
659
|
+
state.latestRun = undefined
|
|
660
|
+
state.editableRun = undefined
|
|
661
|
+
continue
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const call = entry?.event
|
|
665
|
+
const callSeq = call?.seq
|
|
666
|
+
const meta = event.data?.meta
|
|
667
|
+
let normalized
|
|
668
|
+
if (isRecord(meta) && Object.hasOwn(meta, JOURNAL_KEY)) {
|
|
669
|
+
if (Number.isSafeInteger(sourceSeq) && journalResultSeqs.has(sourceSeq)) {
|
|
670
|
+
throw new Error(`session log contains duplicate PTC journal results for call seq ${sourceSeq}`)
|
|
671
|
+
}
|
|
672
|
+
if (Number.isSafeInteger(sourceSeq)) journalResultSeqs.add(sourceSeq)
|
|
673
|
+
if (entry === undefined) {
|
|
674
|
+
if (Number.isSafeInteger(sourceSeq)) state.unavailableResultSeq ??= sourceSeq
|
|
675
|
+
continue
|
|
676
|
+
}
|
|
677
|
+
const resultSeq = Number.isSafeInteger(sourceSeq) ? sourceSeq : callSeq
|
|
678
|
+
const raw = {
|
|
679
|
+
meta,
|
|
680
|
+
eventSeq: Number.isSafeInteger(event.seq) && event.seq >= 0 ? event.seq : resultSeq,
|
|
681
|
+
eventIndex,
|
|
682
|
+
}
|
|
683
|
+
if (Object.hasOwn(meta, RECOVERY_BOUNDARY_KEY)) {
|
|
684
|
+
state.boundaries.push(...normalizeRecoveryBoundaries(
|
|
685
|
+
meta[RECOVERY_BOUNDARY_KEY], raw.eventSeq,
|
|
686
|
+
))
|
|
687
|
+
}
|
|
688
|
+
try {
|
|
689
|
+
if (call.data.name === 'edit_run_code') {
|
|
690
|
+
const derived = normalizeDerivedEditResult(meta, entry.editTarget?.callSeq)
|
|
691
|
+
normalized = { ...raw, journal: derived.journal, derived }
|
|
692
|
+
} else if (call.data.name === 'run_code') {
|
|
693
|
+
const rawJournal = meta[JOURNAL_KEY]
|
|
694
|
+
const resolveLegacyConfirm = rawJournal?.version === LEGACY_JOURNAL_VERSION
|
|
695
|
+
? legacyCallId => {
|
|
696
|
+
const candidates = [...state.executableCalls.values()]
|
|
697
|
+
.filter(candidate => candidate.eventIndex < eventIndex
|
|
698
|
+
&& candidate.event.data?.name === 'run_code'
|
|
699
|
+
&& candidate.event.data.callId === legacyCallId
|
|
700
|
+
&& !state.results.has(candidate.event.seq))
|
|
701
|
+
return candidates.length === 1 ? candidates[0].event.seq : undefined
|
|
702
|
+
}
|
|
703
|
+
: undefined
|
|
704
|
+
normalized = { ...raw, journal: normalizeJournal(rawJournal, { resolveLegacyConfirm }) }
|
|
705
|
+
}
|
|
706
|
+
} catch (error) {
|
|
707
|
+
normalized = { ...raw, error: error.message }
|
|
708
|
+
}
|
|
709
|
+
if (Number.isSafeInteger(resultSeq)) state.results.set(resultSeq, normalized)
|
|
710
|
+
state.found = true
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const claimedTarget = editClaims.get(callSeq)
|
|
714
|
+
if (claimedTarget !== undefined) {
|
|
715
|
+
editClaims.delete(callSeq)
|
|
716
|
+
if (normalized?.derived === undefined) claimedEditTargets.delete(claimedTarget)
|
|
717
|
+
}
|
|
718
|
+
if (entry === undefined || entry.scope !== scope) continue
|
|
719
|
+
if (call.data.name === 'edit_run_code') {
|
|
720
|
+
if (normalized?.derived !== undefined) {
|
|
721
|
+
const run = timelineDerivedRun(call, event, eventIndex, normalized.derived)
|
|
722
|
+
state.latestRun = run
|
|
723
|
+
state.editableRun = run
|
|
724
|
+
if (successfulTimelineRun(run)) state.lastSuccessfulRunIndex = eventIndex
|
|
725
|
+
}
|
|
726
|
+
continue
|
|
727
|
+
}
|
|
728
|
+
if (call.data.name !== 'run_code') {
|
|
729
|
+
// Unrelated native settlements do not change the current editable cell.
|
|
730
|
+
// The edit target is captured at dispatch time and remains valid until a
|
|
731
|
+
// new executable cell or turn boundary supersedes it.
|
|
732
|
+
continue
|
|
733
|
+
}
|
|
734
|
+
const run = timelineRun(call, event, eventIndex, normalized?.journal)
|
|
735
|
+
state.latestRun = run
|
|
736
|
+
state.editableRun = run
|
|
737
|
+
if (successfulTimelineRun(run)) state.lastSuccessfulRunIndex = eventIndex
|
|
738
|
+
}
|
|
739
|
+
return state
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** Fold the session log into the last exactly replayable frontier. */
|
|
743
|
+
export function recoverJournal(session, currentCallSeq, options = {}) {
|
|
744
|
+
const events = session?.events
|
|
745
|
+
if (!Array.isArray(events)) {
|
|
746
|
+
return { nodes: [], head: undefined, checkpoints: new Map(), volatileSuffix: [], available: true }
|
|
747
|
+
}
|
|
748
|
+
const timeline = foldSessionTimeline(events)
|
|
749
|
+
const calls = timeline.calls.filter(call => call.seq !== currentCallSeq)
|
|
750
|
+
const { executableCalls, results, found } = timeline
|
|
751
|
+
const extraBoundaries = options?.extraBoundaries ?? []
|
|
752
|
+
const boundaries = [
|
|
753
|
+
...timeline.boundaries,
|
|
754
|
+
...normalizeRecoveryBoundaries(extraBoundaries, undefined).map(boundary => ({
|
|
755
|
+
...boundary,
|
|
756
|
+
eventSeq: Number.POSITIVE_INFINITY,
|
|
757
|
+
})),
|
|
758
|
+
].sort((left, right) => left.eventSeq - right.eventSeq)
|
|
759
|
+
if (timeline.unavailableResultSeq !== undefined) {
|
|
760
|
+
throw new Error(`PTC journal result references unavailable run_code call seq ${timeline.unavailableResultSeq}`)
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const confirmedNoops = new Set()
|
|
764
|
+
for (const { journal, eventIndex } of results.values()) {
|
|
765
|
+
for (const callSeq of journal?.confirms ?? []) {
|
|
766
|
+
const confirmed = executableCalls.get(callSeq)
|
|
767
|
+
if (confirmed === undefined || confirmed.eventIndex >= eventIndex || results.has(callSeq)) {
|
|
768
|
+
throw new Error(`confirmed no-op does not identify an earlier unjournaled run_code call seq ${callSeq}`)
|
|
769
|
+
}
|
|
770
|
+
confirmedNoops.add(callSeq)
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
const records = []
|
|
774
|
+
const orderedRecords = []
|
|
775
|
+
const invalidCallSeqs = new Set()
|
|
776
|
+
const appliedBoundaries = []
|
|
777
|
+
let state = foldRecords(records, invalidCallSeqs)
|
|
778
|
+
let boundaryIndex = 0
|
|
779
|
+
const applyBoundariesBefore = (seq) => {
|
|
780
|
+
while (boundaryIndex < boundaries.length && boundaries[boundaryIndex].eventSeq <= seq) {
|
|
781
|
+
const boundary = boundaries[boundaryIndex++]
|
|
782
|
+
const failedIndex = state.nodes.findIndex(node => node.callSeq === boundary.failedCallSeq)
|
|
783
|
+
if (failedIndex < 0) throw new Error('recovery boundary references an unavailable failed cell')
|
|
784
|
+
const expectedFrontier = state.nodes[failedIndex].parent
|
|
785
|
+
const frontierIndex = boundary.frontierCallSeq === null
|
|
786
|
+
? undefined
|
|
787
|
+
: state.nodes.findIndex(node => node.callSeq === boundary.frontierCallSeq)
|
|
788
|
+
if (frontierIndex !== expectedFrontier) {
|
|
789
|
+
throw new Error('recovery boundary does not identify the failed cell parent')
|
|
790
|
+
}
|
|
791
|
+
for (let index = 0; index < state.nodes.length; index += 1) {
|
|
792
|
+
if (dependsOn(state.nodes, index, failedIndex)) invalidCallSeqs.add(state.nodes[index].callSeq)
|
|
793
|
+
}
|
|
794
|
+
appliedBoundaries.push(boundary)
|
|
795
|
+
state = foldRecords(records, invalidCallSeqs, appliedBoundaries)
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
for (const call of calls) {
|
|
799
|
+
const result = results.get(call.seq)
|
|
800
|
+
const code = call.data?.name === 'edit_run_code' ? result?.derived?.code : sourceForRunCall(call)
|
|
801
|
+
const record = { call, code, result }
|
|
802
|
+
if (call.data?.name === 'edit_run_code' && record.code === undefined && result === undefined) continue
|
|
803
|
+
if (result?.journal === undefined && confirmedNoops.has(call.seq)) continue
|
|
804
|
+
orderedRecords.push(record)
|
|
805
|
+
}
|
|
806
|
+
orderedRecords.sort((left, right) => (
|
|
807
|
+
recordEventSeq(left) - recordEventSeq(right) || left.call.seq - right.call.seq
|
|
808
|
+
))
|
|
809
|
+
for (const record of orderedRecords) {
|
|
810
|
+
applyBoundariesBefore(recordEventSeq(record))
|
|
811
|
+
records.push(record)
|
|
812
|
+
applyRecord(state, record, invalidCallSeqs)
|
|
813
|
+
}
|
|
814
|
+
applyBoundariesBefore(Number.POSITIVE_INFINITY)
|
|
815
|
+
return {
|
|
816
|
+
nodes: state.nodes,
|
|
817
|
+
head: state.head,
|
|
818
|
+
checkpoints: state.checkpoints,
|
|
819
|
+
volatileSuffix: state.volatileSuffix,
|
|
820
|
+
available: found,
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** Return source nodes from the empty state to a selected durable head. */
|
|
825
|
+
export function pathToHead(state) {
|
|
826
|
+
const path = []
|
|
827
|
+
for (let cursor = state.head; cursor !== undefined;) {
|
|
828
|
+
const node = state.nodes[cursor]
|
|
829
|
+
if (node === undefined) throw new Error('invalid dsh-ptc-plus journal head')
|
|
830
|
+
path.push(node)
|
|
831
|
+
cursor = node.parent
|
|
832
|
+
}
|
|
833
|
+
path.reverse()
|
|
834
|
+
return path
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** Merge the journal into the tool result's private metadata. */
|
|
838
|
+
export function withJournal(meta, journal) {
|
|
839
|
+
const base = isRecord(meta) ? { ...meta } : meta === undefined ? {} : { value: cloneJson(meta) }
|
|
840
|
+
base[JOURNAL_KEY] = normalizeJournal(journal)
|
|
841
|
+
return base
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const REWRITE_FIELDS = new Set(['kind', 'description', 'source'])
|
|
845
|
+
const REWRITE_KINDS = new Set(['import', 'redeclaration', 'export'])
|
|
846
|
+
|
|
847
|
+
/** Validate and detach one rewrite record emitted by the runtime. */
|
|
848
|
+
export function normalizeRewrites(value) {
|
|
849
|
+
if (!Array.isArray(value)) throw new Error('invalid dsh-ptc-plus rewrites')
|
|
850
|
+
return Object.freeze(value.map((rewrite, index) => {
|
|
851
|
+
if (!isRecord(rewrite) || !REWRITE_KINDS.has(rewrite.kind) || typeof rewrite.description !== 'string'
|
|
852
|
+
|| rewrite.description.length === 0) {
|
|
853
|
+
throw new Error(`invalid dsh-ptc-plus rewrite at index ${index}`)
|
|
854
|
+
}
|
|
855
|
+
assertOwnFields(rewrite, REWRITE_FIELDS, `dsh-ptc-plus rewrite at index ${index}`)
|
|
856
|
+
if (rewrite.source !== undefined && typeof rewrite.source !== 'string') {
|
|
857
|
+
throw new Error(`invalid dsh-ptc-plus rewrite source at index ${index}`)
|
|
858
|
+
}
|
|
859
|
+
return Object.freeze({
|
|
860
|
+
kind: rewrite.kind,
|
|
861
|
+
description: rewrite.description,
|
|
862
|
+
...(rewrite.source === undefined ? {} : { source: rewrite.source }),
|
|
863
|
+
})
|
|
864
|
+
}))
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** Read optional rewrite provenance without letting malformed metadata affect settlement. */
|
|
868
|
+
export function validatedRewrites(meta) {
|
|
869
|
+
if (!isRecord(meta) || !Object.hasOwn(meta, REWRITES_KEY)) return undefined
|
|
870
|
+
try {
|
|
871
|
+
return normalizeRewrites(meta[REWRITES_KEY])
|
|
872
|
+
} catch {
|
|
873
|
+
return undefined
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** Merge rewrite records into the tool result's private metadata. */
|
|
878
|
+
export function withRewrites(meta, rewrites) {
|
|
879
|
+
const base = isRecord(meta) ? { ...meta } : meta === undefined ? {} : { value: cloneJson(meta) }
|
|
880
|
+
base[REWRITES_KEY] = normalizeRewrites(rewrites)
|
|
881
|
+
return base
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
export function assertStateName(name) {
|
|
885
|
+
if (!validName(name)) {
|
|
886
|
+
throw new Error('REPL state name must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}')
|
|
887
|
+
}
|
|
888
|
+
return name
|
|
889
|
+
}
|