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