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.
Files changed (58) hide show
  1. package/README.en.md +7 -3
  2. package/README.md +7 -3
  3. package/client.cjs +1467 -84
  4. package/compiler-core.cjs +132 -132
  5. package/compiler-core.cjs.map +3 -3
  6. package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +2 -0
  7. package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +11 -1
  8. package/docs/adr/0032-own-session-log-facts-in-a-host-only-projection.md +31 -1
  9. package/docs/adr/0045-customize-model-visible-prompt-text.md +38 -0
  10. package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +35 -0
  11. package/docs/adr/0047-store-journal-argument-digests-in-the-projection.md +88 -0
  12. package/docs/architecture.md +2 -2
  13. package/docs/client-ui.md +5 -2
  14. package/docs/durability-design.md +7 -1
  15. package/docs/releases/v0.5.2.md +79 -0
  16. package/docs/runtime-reference.md +36 -2
  17. package/docs/unmeasured-cell-recovery-repair-proposal.md +387 -0
  18. package/docs/verification.md +6 -2
  19. package/index.js +36 -15
  20. package/internal/bounded-literal.js +85 -0
  21. package/internal/capability-environment.js +162 -10
  22. package/internal/capability-plan.js +11 -8
  23. package/internal/cell-error.js +13 -0
  24. package/internal/config-spec.js +113 -2
  25. package/internal/direct-surface-owner.js +14 -10
  26. package/internal/edit-transport-owner.js +79 -14
  27. package/internal/isolated-worker.js +134 -17
  28. package/internal/kernel-worker.js +167 -3
  29. package/internal/legacy-cell-analysis.js +14 -2
  30. package/internal/legacy-repl-convenience.js +16 -3
  31. package/internal/projection-retention.js +238 -0
  32. package/internal/prompt-text.js +858 -0
  33. package/internal/recovery-tips.js +50 -11
  34. package/internal/rejected-cell-editor.js +3 -2
  35. package/internal/runtime-bridge-owner.js +1 -1
  36. package/internal/runtime-config.js +2 -0
  37. package/internal/runtime-contexts.js +14 -5
  38. package/internal/runtime-messages.js +43 -15
  39. package/internal/session-cell-executor.js +132 -11
  40. package/internal/session-journal-recovery.js +1009 -99
  41. package/internal/session-journal-schema.js +7 -5
  42. package/internal/session-journal.js +177 -14
  43. package/internal/session-log-view.js +111 -24
  44. package/internal/session-runtime.js +674 -33
  45. package/internal/session-state.js +27 -0
  46. package/internal/stateful-root-compiler.js +11 -0
  47. package/internal/stateful-root-runtime.js +17 -0
  48. package/internal/user-binding-console.js +7 -0
  49. package/internal/user-bindings-owner.js +3 -0
  50. package/internal/user-bindings.js +2 -2
  51. package/internal/validated-parse-repair.js +6 -1
  52. package/internal/worker-client.js +27 -4
  53. package/package.json +3 -1
  54. package/src/client-activity.js +24 -4
  55. package/src/client-capabilities-view.js +459 -24
  56. package/src/client-copy.js +163 -2
  57. package/src/client-repl-view.js +6 -1
  58. package/src/client-styles.js +64 -2
@@ -42,7 +42,7 @@ import { installProgramAmbientResolver } from './dynamic-environment-runtime.js'
42
42
  const { Object, Map, Set, Proxy, Error,
43
43
  mapGet, mapSet, mapHas, mapDelete, mapClear, mapSize, mapForEach,
44
44
  setHas, setAdd, setDelete, setClear, setForEach,
45
- everyArray, someArray, appendArray, copyArray, join, sliceString, endsWith,
45
+ everyArray, someArray, isArray, min, appendArray, copyArray, join, sliceString, endsWith,
46
46
  bufferConcat, bufferIsBuffer, bufferToString,
47
47
  Reflect: privateReflect, promiseResolve, promiseReject } = moduleRuntimeIntrinsics
48
48
  const PublicPromise = globalThis.Promise
@@ -114,6 +114,11 @@ const output = new PassThrough()
114
114
  output.resume()
115
115
  const evaluationScope = createPrivateAsyncLocalStorage()
116
116
  const sessionCwd = typeof workerData?.cwd === 'string' ? workerData.cwd : undefined
117
+ /** Fallback bound for the startup conformance probe when the host config omits one. */
118
+ const DEFAULT_STARTUP_PROBE_MS = 5000
119
+ const startupProbeMs = Number.isSafeInteger(workerData?.startupProbeMs) && workerData.startupProbeMs > 0
120
+ ? workerData.startupProbeMs
121
+ : DEFAULT_STARTUP_PROBE_MS
117
122
  // The module source base is fixed when a cell starts; the operation directory
118
123
  // follows the Session's working directory while the cell runs, so a confirmed
119
124
  // `tools.working_directory` change rebases the rest of the cell exactly as the
@@ -232,6 +237,9 @@ const CONFORMANCE_CELL = `"use strict";
232
237
  }`
233
238
  let activeRun
234
239
  let activeExecution
240
+ // Compile-time proof that a binding's initializer is a bounded object literal,
241
+ // keyed by binding name for the running cell.
242
+ let declaredBoundedLiterals = new Map()
235
243
  let suppressReplCwdObservation = false
236
244
  let nextCallId = 0
237
245
  let nextStaticAdapterId = 0
@@ -964,7 +972,13 @@ async function activatePerNameUserBindings(snapshot, shadowedNames, cwd, reusePo
964
972
  let evaluated
965
973
  const activation = { id: entry.id, called: false, failed: false }
966
974
  try {
967
- if (entry.durability === 'volatile') markVolatile(`user binding ${jsonStringify(entry.id)}: ${entry.volatileReason ?? 'non-replayable source'}`)
975
+ if (entry.durability === 'volatile') {
976
+ // Name the binding and its first cause instead of the whole reason list:
977
+ // the model reads this to decide what it can no longer redo.
978
+ const reasons = String(entry.volatileReason ?? 'non-replayable source').split(', ')
979
+ const more = reasons.length > 1 ? ` (+${reasons.length - 1} more)` : ''
980
+ markVolatile(`user binding ${jsonStringify(entry.id)} depends on ${reasons[0]}${more}`)
981
+ }
968
982
  evaluated = await userBindingActivationScope.run(activation, () => evaluateUserBinding(entry, cwd, snapshot.transform))
969
983
  const namespace = evaluated.namespace
970
984
  for (let symbolIndex = 0; symbolIndex < entry.symbols.length; symbolIndex += 1) {
@@ -1297,6 +1311,131 @@ function observeBindings(names) {
1297
1311
  }) }
1298
1312
  }
1299
1313
 
1314
+ /**
1315
+ * The logical binding a committed root target names.
1316
+ *
1317
+ * A root target is `root:<sourceStart>:<name>` with an optional position suffix
1318
+ * the compiler appends for a repeated name. Only the logical name exists on the
1319
+ * recovery side, and it is the name the model reads, so the record keeps that
1320
+ * instead of the compiler's internal target.
1321
+ */
1322
+ function logicalBindingName(target) {
1323
+ const match = /^root:\d+:([^:]+)/u.exec(target)
1324
+ return match === null ? target : match[1]
1325
+ }
1326
+
1327
+ /** Cap one recorded binding: 64 KiB of encoded wire. */
1328
+ const RECORDED_BINDING_WIRE_LIMIT = 64 * 1024
1329
+ /** Cap one settlement's recorded bindings: 1 MiB of encoded wire in total. */
1330
+ /** Cap the graph a recording pass may walk before the value becomes unrecorded. */
1331
+ const RECORDED_VALUE_NODES = 2048
1332
+ const RECORDED_VALUE_EDGES = 4096
1333
+
1334
+ const RECORDED_CELL_WIRE_LIMIT = 1024 * 1024
1335
+
1336
+ /**
1337
+ * Encode the values a settlement produced.
1338
+ *
1339
+ * Every settlement records what it declared, durable or volatile: a durable
1340
+ * cell still needs its values when recovery cannot replay it (a rewind moved
1341
+ * the head past it, or the log proves it but the path does not reach it), and a
1342
+ * volatile cell cannot be replayed at all. The record keeps values as wire so
1343
+ * recovery installs them without running the cell again. Two caps keep the
1344
+ * journal bounded: 64 KiB per binding and 1 MiB per settlement. A value over a
1345
+ * cap, or one the codec cannot transfer, is reported by name instead of
1346
+ * silently disappearing from the record.
1347
+ */
1348
+ /**
1349
+ * Whether recording one binding can be attempted without walking an unbounded
1350
+ * value. The codec enumerates the value's own keys before it can apply any
1351
+ * budget, and enumerating a graph with millions of keys exhausts a worker heap
1352
+ * before a cap could reject it. So only shapes that are cheap to size are
1353
+ * recorded: primitives, and arrays whose length is already known. A plain
1354
+ * object, function or class instance is reported by name as unrecorded — its
1355
+ * binding still exists in the journal's operations, but its value is not
1356
+ * captured.
1357
+ */
1358
+ function recordingAffordable(value) {
1359
+ if (value === null) return true
1360
+ const kind = typeof value
1361
+ if (kind === 'number' || kind === 'string' || kind === 'boolean' || kind === 'bigint' || kind === 'undefined') return true
1362
+ if (isArray(value)) return value.length <= RECORDED_VALUE_NODES
1363
+ return false
1364
+ }
1365
+
1366
+ function collectBindingValues(names, limits = {}) {
1367
+ const values = []
1368
+ const unrecorded = []
1369
+ const seen = new Set()
1370
+ let recorded = 0
1371
+ for (const name of names) {
1372
+ const logical = logicalBindingName(name)
1373
+ if (setHas(seen, logical)) continue
1374
+ const local = statefulRoots.localValue(name) ?? statefulRoots.localValue(logical)
1375
+ if (local === undefined) {
1376
+ setAdd(seen, logical)
1377
+ unrecorded.push(logical)
1378
+ continue
1379
+ }
1380
+ setAdd(seen, logical)
1381
+ try {
1382
+ // A recording pass must stay cheap. The codec starts by enumerating the
1383
+ // value's own keys, so a huge declaration (millions of keys) would exhaust
1384
+ // the worker heap before any budget check could reject it. Walk the value
1385
+ // cheaply first — array length, or a `for…in` that stops after the cap
1386
+ // without materializing keys — and record the name as unrecorded instead.
1387
+ if (!recordingAffordable(local.value)) {
1388
+ unrecorded.push(logical)
1389
+ continue
1390
+ }
1391
+ const wire = encodeValue(local.value, {
1392
+ ...limits,
1393
+ maxNodes: min(limits.maxNodes ?? RECORDED_VALUE_NODES, RECORDED_VALUE_NODES),
1394
+ maxEdges: min(limits.maxEdges ?? RECORDED_VALUE_EDGES, RECORDED_VALUE_EDGES),
1395
+ maxArrayLength: min(limits.maxArrayLength ?? RECORDED_VALUE_NODES, RECORDED_VALUE_NODES),
1396
+ maxStringBytes: min(limits.maxStringBytes ?? RECORDED_BINDING_WIRE_LIMIT, RECORDED_BINDING_WIRE_LIMIT),
1397
+ })
1398
+ const size = jsonStringify(wire).length
1399
+ if (size > RECORDED_BINDING_WIRE_LIMIT || recorded + size > RECORDED_CELL_WIRE_LIMIT) {
1400
+ unrecorded.push(logical)
1401
+ continue
1402
+ }
1403
+ recorded += size
1404
+ values.push({ name: logical, wire })
1405
+ } catch {
1406
+ unrecorded.push(logical)
1407
+ }
1408
+ }
1409
+ return { values, unrecorded }
1410
+ }
1411
+
1412
+ /**
1413
+ * Install the values recovery recorded for a volatile cell.
1414
+ *
1415
+ * The worker never runs the restoration cell's source: each binding is decoded
1416
+ * and committed directly, so an ambient read, a timer or a child process
1417
+ * reached by the original cell is not dispatched a second time. A value the
1418
+ * codec cannot decode is reported by name so recovery can say which binding it
1419
+ * could not bring back.
1420
+ */
1421
+ function restoreRecordedValues(message) {
1422
+ const limits = message.valueLimits
1423
+ const restored = []
1424
+ const failed = []
1425
+ for (const entry of message.values ?? []) {
1426
+ const name = entry?.name
1427
+ if (typeof name !== 'string' || name === '') continue
1428
+ try {
1429
+ const value = decodeValue(entry.wire, limits)
1430
+ if (statefulRoots.restoreValue(name, value)) restored.push(name)
1431
+ else failed.push({ name, reason: 'the store cannot publish a session identity for this name' })
1432
+ } catch (error) {
1433
+ failed.push({ name, reason: error instanceof Error ? error.message : String(error) })
1434
+ }
1435
+ }
1436
+ return { restored, failed }
1437
+ }
1438
+
1300
1439
  function refreshLegacyPublication(name) {
1301
1440
  const publications = activeExecution?.nativePublications
1302
1441
  const publication = publications === undefined ? undefined : mapGet(publications, name)
@@ -1334,12 +1473,16 @@ async function sendCompletion(message, execution, userBindings, committedRedecla
1334
1473
  // after this point is background output and carries the attribution
1335
1474
  // diagnostic instead of being attached to a later cell.
1336
1475
  execution.open = false
1476
+ const bindingValues = collectBindingValues(committedRedeclarations, execution.valueLimits)
1337
1477
  channel.postMessage({
1338
1478
  type: 'done',
1339
1479
  id: message.id,
1340
1480
  outputFence: message.outputFence,
1341
1481
  logs: execution.logs,
1342
1482
  ...completionDurability(execution),
1483
+ ...(bindingValues.values.length === 0 && bindingValues.unrecorded.length === 0
1484
+ ? {}
1485
+ : { bindingValues }),
1343
1486
  committedRedeclarations: setValuesArray(committedRedeclarations),
1344
1487
  ...(message.rootBindings === undefined ? {} : { rootBindingFacts: statefulRoots.facts() }),
1345
1488
  ...(perName ? { userBindingNames: normalizeUserBindingNames(mapArray(mapEntriesArray(userBindingSources), entry => ({
@@ -1398,6 +1541,11 @@ async function runCell(message) {
1398
1541
  volatileReason: message.volatileReason,
1399
1542
  }
1400
1543
  activeExecution = execution
1544
+ // Compile-time evidence that a declaration's initializer is a bounded object
1545
+ // literal, keyed by binding name. Settlement uses it to decide whether a value
1546
+ // can be recorded without walking an object the codec cannot size; nothing
1547
+ // reads it yet, so this commit changes no recording behaviour.
1548
+ declaredBoundedLiterals = mapFromArray(message.declarationEvidence ?? [], entry => entry)
1401
1549
  const cellGlobals = []
1402
1550
  const committedRedeclarations = new Set()
1403
1551
 
@@ -1543,6 +1691,13 @@ channel.on('message', (message) => {
1543
1691
  channel.postMessage({ type: 'ready', id: message.id })
1544
1692
  return
1545
1693
  }
1694
+ if (message?.type === 'restore') {
1695
+ // Recovery installs the values a volatile cell recorded. No cell code runs
1696
+ // here: replaying the source would redispatch the ambient effects that made
1697
+ // the cell volatile, which is exactly what the record exists to avoid.
1698
+ channel.postMessage({ type: 'restored', id: message.id, ...restoreRecordedValues(message) })
1699
+ return
1700
+ }
1546
1701
  if (message?.type === 'observe') {
1547
1702
  channel.postMessage({ type: 'observation-started', id: message.id })
1548
1703
  channel.postMessage({ type: 'observation', id: message.id,
@@ -1585,7 +1740,16 @@ try {
1585
1740
  await Promise.race([
1586
1741
  verifyEvaluation(),
1587
1742
  new Promise((_, reject) => {
1588
- startupTimer = setTimeout(() => reject(new Error('REPL settlement probe timed out')), 5000)
1743
+ /* c8 ignore start -- the probe settles in microtasks, so the timer cannot win */
1744
+ // `verifyEvaluation` dispatches its fixed sources through this realm's own
1745
+ // evaluator, and every await it takes settles in a microtask (`throw`,
1746
+ // `await Promise.reject`, a syntax rejection). A `setTimeout` is a macrotask,
1747
+ // so it cannot run before those microtasks drain: the rejection below is
1748
+ // unreachable for any input, and a bound of 1 ms still starts the worker.
1749
+ startupTimer = setTimeout(() => reject(new Error(
1750
+ `REPL settlement probe timed out after ${startupProbeMs}ms`,
1751
+ )), startupProbeMs)
1752
+ /* c8 ignore stop */
1589
1753
  }),
1590
1754
  ])
1591
1755
  replModuleRealm = (await evaluate('({Promise,stringify:value=>`${value}`,importModule:(source,options)=>import(source,options)})')).value
@@ -1,4 +1,5 @@
1
1
  /** Historical legacy-v1 lowering, retained from c51616c8d6c8:cell-analysis.js. */
2
+ import { boundedLiteralEvidence } from './bounded-literal.js'
2
3
  import { ModuleRewriteError, rewriteModuleImportsExports } from './legacy-cell-rewriter.js'
3
4
  import { parseExecutableCell } from './legacy-cell-parser.js'
4
5
  import {
@@ -74,6 +75,7 @@ function topLevelDeclarations(body, variableRedeclarations = false) {
74
75
  if (statement.type === 'VariableDeclaration') {
75
76
  const definitionSpan = declarationSpan(statement)
76
77
  for (const declaration of statement.declarations) {
78
+ const first = declarations.length
77
79
  addPatternDeclarations(
78
80
  declaration.id,
79
81
  declarations,
@@ -81,6 +83,14 @@ function topLevelDeclarations(body, variableRedeclarations = false) {
81
83
  definitionSpan,
82
84
  statement.kind !== 'const' || variableRedeclarations,
83
85
  )
86
+ // A plain `const name = { … }` initializer proves its own size from the
87
+ // source, so recovery may record the value without walking it. A
88
+ // destructuring pattern has no single initializer to prove.
89
+ const evidence = declaration.id.type === 'Identifier' && declaration.init !== null
90
+ ? boundedLiteralEvidence(declaration.init) : undefined
91
+ if (evidence !== undefined) {
92
+ for (const record of declarations.slice(first)) record.boundedLiteral = evidence
93
+ }
84
94
  }
85
95
  } else if ((statement.type === 'FunctionDeclaration' || statement.type === 'ClassDeclaration')
86
96
  && statement.id !== null) {
@@ -408,6 +418,7 @@ function normalizePreparationOptions({
408
418
  importNamespaces = new Set(),
409
419
  writableBindings,
410
420
  moduleSemantics = LIVE_MODULE_SEMANTICS,
421
+ rewriteDescriptions,
411
422
  }) {
412
423
  if (typeof bindingPolicy === 'boolean') {
413
424
  bindingPolicy = {
@@ -435,7 +446,7 @@ function normalizePreparationOptions({
435
446
  throw new TypeError('ptc-plus: module semantics must define supported default-export and import-expression rules')
436
447
  }
437
448
  return { knownBindings, bindingPolicy, reservedBindings, rewritesEnabled,
438
- importBindings, importNamespaces, writableBindings, moduleSemantics }
449
+ importBindings, importNamespaces, writableBindings, moduleSemantics, rewriteDescriptions }
439
450
  }
440
451
 
441
452
  function parseMappedCell(moduleRewrite, program) {
@@ -522,7 +533,7 @@ function classifyPrepared({ code, sourceMap }, program, knownBindings, moduleRew
522
533
  export function prepareLegacyProgram(program, options = {}) {
523
534
  if (typeof program !== 'string') throw new TypeError('ptc-plus: program must be a string')
524
535
  const { knownBindings, bindingPolicy, reservedBindings, rewritesEnabled,
525
- importBindings, importNamespaces, writableBindings, moduleSemantics } = normalizePreparationOptions(options)
536
+ importBindings, importNamespaces, writableBindings, moduleSemantics, rewriteDescriptions } = normalizePreparationOptions(options)
526
537
  const unavailableGeneratedNames = new Set([
527
538
  ...knownBindings, ...reservedBindings, ...importBindings.keys(), ...importNamespaces,
528
539
  ])
@@ -561,6 +572,7 @@ export function prepareLegacyProgram(program, options = {}) {
561
572
  bindingPolicy,
562
573
  autoSplitRedeclarations: rewritesEnabled.autoSplitRedeclarations,
563
574
  commitSignal,
575
+ rewriteDescriptions,
564
576
  })
565
577
  const { executableCode, executableSourceMap, collisions, redeclared, rewrites } = convenience
566
578
  const commitTargets = new Set(moduleRewrite.commitTargets)
@@ -9,6 +9,18 @@ import {
9
9
  import { applySourceEdits, createMappedTextBuilder, identitySourceMap } from './source-position-map.js'
10
10
  import { SKIP_AST_CHILDREN, walkAst } from './ast-traversal.js'
11
11
 
12
+ /**
13
+ * Historical descriptions, used when a caller supplies no resolved document.
14
+ *
15
+ * They are presentation only: no recognizer compares a rewrite description, so a
16
+ * customized description changes what the model reads and nothing else.
17
+ */
18
+ const DEFAULT_DESCRIPTIONS = Object.freeze({
19
+ mixed: MIXED_REDECLARATION_REWRITE,
20
+ function: FUNCTION_REDECLARATION_REWRITE,
21
+ class: CLASS_REDECLARATION_REWRITE,
22
+ })
23
+
12
24
  /**
13
25
  * Optional compatibility policy for a persistent REPL. The cell language does
14
26
  * not own this policy: callers may replace it when persistence uses separate
@@ -129,6 +141,7 @@ export function rewriteReplRedeclarations({
129
141
  bindingPolicy,
130
142
  autoSplitRedeclarations,
131
143
  commitSignal,
144
+ rewriteDescriptions = DEFAULT_DESCRIPTIONS,
132
145
  }) {
133
146
  const redeclared = []
134
147
  const rewrites = []
@@ -184,8 +197,8 @@ export function rewriteReplRedeclarations({
184
197
  rewrites.push({
185
198
  kind: 'redeclaration',
186
199
  description: kind === 'class'
187
- ? CLASS_REDECLARATION_REWRITE
188
- : FUNCTION_REDECLARATION_REWRITE,
200
+ ? rewriteDescriptions.class
201
+ : rewriteDescriptions.function,
189
202
  source: statement.id.name,
190
203
  })
191
204
  }
@@ -262,7 +275,7 @@ export function rewriteReplRedeclarations({
262
275
  redeclared.push(...existing.map(collisionFor))
263
276
  rewrites.push({
264
277
  kind: 'redeclaration',
265
- description: MIXED_REDECLARATION_REWRITE,
278
+ description: rewriteDescriptions.mixed,
266
279
  source: existing.map(binding => binding.name).join(', '),
267
280
  })
268
281
  } else if (existing.length === bindings.length && bindings.length > 0) {
@@ -0,0 +1,238 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ /**
4
+ * Retention window for the host-only session-log projection.
5
+ *
6
+ * The projection is a fold shortcut, not the session log. Keeping the payload of
7
+ * every call would make a checkpoint proportional to how many bytes the session
8
+ * wrote (issue #10: a 77 MB state that the host cloned and pretty-printed until
9
+ * it aborted). The window is therefore bounded by default: the newest clusters
10
+ * keep their argument text so tips and the current edit target stay synchronous,
11
+ * and every older cluster keeps its identity, its observations and its call
12
+ * record while its payload text becomes a digest. Only a cluster beyond
13
+ * `retentionClusters` is folded into the chained summary, and a cluster whose
14
+ * payload alone exceeds the byte budget keeps its identity with a digested
15
+ * payload — the bound wins over the convenience of the hot cell, and the edit
16
+ * path refolds that cell from the session log.
17
+ *
18
+ * `retentionPayloadBytes` bounds the retained payload (the settlement and
19
+ * executable copies). A checkpoint additionally carries the selected run's
20
+ * copies, so its serialized total can approach roughly twice the budget plus the
21
+ * per-cluster metadata; that is the shipped bound, stated in ADR 0032.
22
+ *
23
+ * The environment overrides may only *lower* the window for tests; a value that
24
+ * would disable the bound is ignored so a deployment cannot silently return to
25
+ * an unbounded checkpoint.
26
+ */
27
+ export const RETENTION_BOUNDS = Object.freeze({
28
+ runs: Object.freeze({ min: 8, max: 512 }),
29
+ bytes: Object.freeze({ min: 1024 * 1024, max: 16 * 1024 * 1024 }),
30
+ // 0 is the only unlimited spelling; a cluster cap of 0 means the count window
31
+ // never folds anything and only the payload budget bounds the checkpoint.
32
+ clusters: Object.freeze({ min: 0, max: 50000 }),
33
+ })
34
+
35
+ export const DEFAULT_RETENTION_LIMITS = Object.freeze({
36
+ runs: 8,
37
+ bytes: 8 * 1024 * 1024,
38
+ // The count window defaults to the payload window: cells beyond it fold into the
39
+ // chained summary instead of keeping a per-cluster record, which is what keeps a
40
+ // long session from growing linearly. A larger value keeps more recovery evidence
41
+ // without a log refold at the cost of a bigger checkpoint.
42
+ clusters: 8,
43
+ })
44
+
45
+ /**
46
+ * The environment levers exist for tests and for a deployment that wants a
47
+ * *smaller* window. They fill a field only when configuration leaves it absent
48
+ * and are clamped to the configured or default value, so no environment can
49
+ * widen the bound beyond what the settings say.
50
+ */
51
+ const ENVIRONMENT_KEYS = Object.freeze({
52
+ runs: 'DSH_PTC_RETENTION_RUNS',
53
+ bytes: 'DSH_PTC_RETENTION_BYTES',
54
+ clusters: 'DSH_PTC_RETENTION_CLUSTERS',
55
+ })
56
+
57
+ function normalizeLimit(kind, raw, fallback) {
58
+ if (raw === undefined || raw === null || raw === '') return fallback
59
+ const value = Number(raw)
60
+ const { min, max } = RETENTION_BOUNDS[kind]
61
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < min || value > max) return fallback
62
+ // 0 means unlimited for clusters; every other value is clamped to the fallback
63
+ // so an environment lever can never widen the window past the configuration.
64
+ return kind === 'clusters' && value === 0 ? 0 : Math.min(value, fallback)
65
+ }
66
+
67
+ /**
68
+ * A configured window is honoured as written: it is the user's setting, bounded by
69
+ * `RETENTION_BOUNDS` only. The clamp in `normalizeLimit` exists for the environment
70
+ * lever, which may tighten a configured window but never widen it.
71
+ */
72
+ function normalizeConfigured(kind, raw) {
73
+ if (raw === undefined || raw === null || raw === '') return DEFAULT_RETENTION_LIMITS[kind]
74
+ const value = Number(raw)
75
+ const { min, max } = RETENTION_BOUNDS[kind]
76
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < min || value > max) {
77
+ return DEFAULT_RETENTION_LIMITS[kind]
78
+ }
79
+ return value
80
+ }
81
+
82
+ let activeLimits = resolveRetentionLimits({})
83
+
84
+ /** The limits the current fold prunes against. */
85
+ export function retentionLimits() {
86
+ return activeLimits
87
+ }
88
+
89
+ /**
90
+ * Resolve the effective limits from plugin configuration: configuration wins,
91
+ * the environment fills an absent field, and anything invalid falls back to the
92
+ * default rather than to an unbounded checkpoint.
93
+ */
94
+ export function resolveRetentionLimits(config = {}) {
95
+ const configured = {
96
+ runs: config.retentionPayloadRuns,
97
+ bytes: config.retentionPayloadBytes,
98
+ clusters: config.retentionClusters,
99
+ }
100
+ const resolved = {}
101
+ // The cluster window follows the payload window unless it is configured: a user
102
+ // who raises the retained cells expects the same number of cells to keep their
103
+ // cluster records, not eight of them.
104
+ if (configured.clusters === undefined || configured.clusters === null || configured.clusters === '') {
105
+ configured.clusters = normalizeConfigured('runs', configured.runs)
106
+ }
107
+ for (const kind of ['runs', 'bytes', 'clusters']) {
108
+ const base = normalizeConfigured(kind, configured[kind])
109
+ // The environment may only tighten the configured bound, never widen it.
110
+ resolved[kind] = normalizeLimit(kind, process.env[ENVIRONMENT_KEYS[kind]], base)
111
+ }
112
+ return Object.freeze(resolved)
113
+ }
114
+
115
+ /** Install the limits for the folds that follow; returns the previous ones. */
116
+ export function configureRetention(config = {}) {
117
+ const previous = activeLimits
118
+ activeLimits = resolveRetentionLimits(config)
119
+ return previous
120
+ }
121
+
122
+ /** The count of clusters kept when the configuration folds by count. */
123
+ export function retainedClusterCount(limits = activeLimits) {
124
+ if (limits.clusters === 0) return Number.POSITIVE_INFINITY
125
+ return Math.min(limits.runs, limits.clusters)
126
+ }
127
+
128
+ /** Shout when the retention window cannot hold the payload a consumer needs. */
129
+ export const PRUNED_EDIT = '该单元格超出保留窗口,已不可编辑'
130
+ export const PRUNED_EDIT_EN = 'This cell is outside the retention window and can no longer be edited'
131
+
132
+ /**
133
+ * The checkpoint's summary and the session log disagree about a dropped cluster.
134
+ * Neither side may be used: the edit must be refused rather than applied to a cell
135
+ * the log and the checkpoint describe differently.
136
+ */
137
+ export const PRUNED_SUMMARY_MISMATCH = '保留摘要与被重折的结算不一致'
138
+ export const PRUNED_SUMMARY_MISMATCH_EN = 'Retention summary disagrees with the refolded settlement'
139
+
140
+ /** The newest cell alone exceeded the byte budget and was dropped from the checkpoint. */
141
+ export const HOT_CELL_DROPPED = '最新单元格超出保留预算,已从检查点移除'
142
+ export const HOT_CELL_DROPPED_EN = 'The newest cell exceeded the retention budget and was removed from the checkpoint'
143
+
144
+ export function digestOf(value) {
145
+ return createHash('sha256').update(JSON.stringify(value ?? null, (_key, field) => (
146
+ isPlainRecord(field) ? Object.fromEntries(Object.keys(field).sort().map(key => [key, field[key]])) : field
147
+ ))).digest('hex')
148
+ }
149
+
150
+ function isPlainRecord(value) {
151
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
152
+ }
153
+
154
+ export function byteLength(value) {
155
+ if (typeof value === 'string') return Buffer.byteLength(value, 'utf8')
156
+ if (value === undefined || value === null) return 0
157
+ return Buffer.byteLength(JSON.stringify(value), 'utf8')
158
+ }
159
+
160
+ /**
161
+ * Identity of one settlement for the retention summary.
162
+ *
163
+ * A dropped settlement keeps three facts: its result position, its call position
164
+ * and the digest of the argument text the log still holds. Those three are exactly
165
+ * what a fold of the session log can reproduce, so the chain over them can be
166
+ * recomputed from the log; the normalized digest is recorded for diagnostics but
167
+ * stays out of the chain, because a settlement that was compacted earlier recorded
168
+ * only its payload digest rather than the normalized object itself.
169
+ */
170
+ export function summaryEntry(settlement, resultSeq) {
171
+ return {
172
+ resultSeq,
173
+ callSeq: settlement?.call?.seq,
174
+ argumentsDigest: settlement?.call?.data?.argumentsDigest ?? digestOf(settlement?.call?.data?.arguments ?? null),
175
+ normalizedDigest: settlement?.normalized?.payloadDigest ?? digestOf(settlement?.normalized ?? null),
176
+ }
177
+ }
178
+
179
+ /** The part of an entry the chain commits to: reproducible from the log alone. */
180
+ function chainEntry(entry) {
181
+ return { callSeq: entry.callSeq, resultSeq: entry.resultSeq, argumentsDigest: entry.argumentsDigest }
182
+ }
183
+
184
+ const EMPTY_CHAIN = digestOf(null)
185
+
186
+ /**
187
+ * Extend a chained retention summary with the settlements it just dropped.
188
+ *
189
+ * The summary keeps one compact record per dropped cluster rather than only a
190
+ * rolling digest. Admission receives no session log, so it cannot recompute the
191
+ * chain from the events; what it can do is prove the summary is internally
192
+ * consistent (count, ranges, chain over the stored records). The record itself is
193
+ * what makes the later guarantee possible: when a dropped settlement is refolded
194
+ * from the log, its digests are compared against the stored record and a mismatch
195
+ * is reported instead of being used.
196
+ */
197
+ export function extendSummary(previous, entries) {
198
+ if (entries.length === 0) return previous
199
+ const records = [...(previous?.entries ?? [])]
200
+ let chain = previous?.chainDigest ?? EMPTY_CHAIN
201
+ let count = previous?.count ?? 0
202
+ let fromSeq = previous?.fromSeq
203
+ let toSeq = previous?.toSeq
204
+ for (const entry of entries) {
205
+ const record = chainEntry(entry)
206
+ records.push(record)
207
+ chain = digestOf({ chain, entry: record })
208
+ count += 1
209
+ fromSeq = fromSeq === undefined ? entry.resultSeq : Math.min(fromSeq, entry.resultSeq)
210
+ toSeq = toSeq === undefined ? entry.resultSeq : Math.max(toSeq, entry.resultSeq)
211
+ }
212
+ records.sort((left, right) => left.resultSeq - right.resultSeq)
213
+ return { fromSeq, toSeq, count, chainDigest: chain, entries: records }
214
+ }
215
+
216
+ /** Recompute the chain committed by a summary's own records. */
217
+ export function chainOfEntries(records) {
218
+ let chain = EMPTY_CHAIN
219
+ for (const record of records) chain = digestOf({ chain, entry: { callSeq: record.callSeq, resultSeq: record.resultSeq, argumentsDigest: record.argumentsDigest } })
220
+ return chain
221
+ }
222
+
223
+ /** The record a dropped cluster must match when it is refolded from the log. */
224
+ export function summaryRecordOf(summary, resultSeq) {
225
+ if (!isPlainRecord(summary) || !Array.isArray(summary.entries)) return undefined
226
+ return summary.entries.find(record => record?.resultSeq === resultSeq)
227
+ }
228
+
229
+ /** Payload bytes a settlement still retains, used to decide pruning. */
230
+ export function settlementBytes(settlement) {
231
+ let bytes = 0
232
+ const args = settlement?.call?.data?.arguments
233
+ if (typeof args === 'string') bytes += byteLength(args)
234
+ const normalized = settlement?.normalized
235
+ if (normalized !== undefined) bytes += byteLength(normalized)
236
+ if (settlement?.publication !== undefined) bytes += byteLength(settlement.publication)
237
+ return bytes
238
+ }