dsh-ptc-plus 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +10 -4
- package/README.md +10 -4
- package/client.cjs +1623 -104
- package/compiler-core.cjs +132 -132
- package/compiler-core.cjs.map +3 -3
- package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +2 -0
- package/docs/adr/0017-track-the-latest-dsh-public-surface.md +4 -2
- package/docs/adr/0019-plugin-settings-and-kill-switch.md +2 -2
- package/docs/adr/0023-global-user-bindings.md +12 -0
- package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +11 -1
- package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +1 -1
- package/docs/adr/0030-serve-both-typert-codec-generations-from-one-codec.md +2 -2
- package/docs/adr/0032-own-session-log-facts-in-a-host-only-projection.md +31 -1
- package/docs/adr/0044-coexist-with-the-official-persistent-repl.md +4 -4
- package/docs/adr/0045-customize-model-visible-prompt-text.md +38 -0
- package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +35 -0
- package/docs/adr/0047-store-journal-argument-digests-in-the-projection.md +88 -0
- package/docs/adr/0048-supersede-a-rewritten-cell.md +48 -0
- package/docs/adr/0049-prove-a-settlement-against-its-published-record.md +53 -0
- package/docs/adr/0050-read-host-surfaces-through-their-published-contract.md +48 -0
- package/docs/adr/README.md +7 -1
- package/docs/architecture.md +3 -3
- package/docs/client-ui.md +5 -2
- package/docs/durability-design.md +15 -2
- package/docs/installation.md +9 -9
- package/docs/releases/v0.5.2.md +91 -0
- package/docs/releases/v0.5.3.md +77 -0
- package/docs/runtime-reference.md +43 -3
- package/docs/unmeasured-cell-recovery-repair-proposal.md +387 -0
- package/docs/user-bindings.en.md +15 -0
- package/docs/user-bindings.md +15 -0
- package/docs/v0.5.3-goals.md +117 -0
- package/docs/verification.md +7 -3
- package/index.js +100 -31
- package/internal/binding-adoption.js +136 -0
- package/internal/bounded-literal.js +85 -0
- package/internal/capability-command-receipt.js +18 -4
- package/internal/capability-delivery-proof.js +70 -0
- package/internal/capability-environment.js +200 -11
- package/internal/capability-plan.js +11 -8
- package/internal/cell-error.js +13 -0
- package/internal/config-spec.js +126 -6
- package/internal/cordis-tools-owner.js +2 -1
- package/internal/direct-surface-owner.js +39 -21
- package/internal/edit-transport-owner.js +81 -15
- package/internal/execution-provider-owner.js +6 -0
- package/internal/execution-seam-compat.js +25 -0
- package/internal/host-tool-sdk.js +308 -0
- package/internal/isolated-worker.js +134 -17
- package/internal/kernel-worker.js +167 -3
- package/internal/legacy-cell-analysis.js +14 -2
- package/internal/legacy-repl-convenience.js +16 -3
- package/internal/official-repl-priority.js +31 -10
- package/internal/program-bindings.js +12 -1
- package/internal/projection-retention.js +238 -0
- package/internal/prompt-text.js +858 -0
- package/internal/recovery-tips.js +50 -11
- package/internal/rejected-cell-editor.js +3 -2
- package/internal/repl-memory-projection.js +21 -2
- package/internal/runtime-bridge-owner.js +114 -9
- package/internal/runtime-config.js +2 -0
- package/internal/runtime-contexts.js +14 -5
- package/internal/runtime-messages.js +48 -15
- package/internal/session-cell-executor.js +132 -11
- package/internal/session-journal-recovery.js +1204 -116
- package/internal/session-journal-schema.js +10 -5
- package/internal/session-journal.js +214 -14
- package/internal/session-log-view.js +133 -26
- package/internal/session-runtime.js +746 -38
- package/internal/session-state.js +27 -0
- package/internal/stateful-root-compiler.js +11 -0
- package/internal/stateful-root-runtime.js +17 -0
- package/internal/user-binding-console.js +7 -0
- package/internal/user-bindings-owner.js +112 -6
- package/internal/user-bindings.js +2 -2
- package/internal/validated-parse-repair.js +6 -1
- package/internal/worker-client.js +27 -4
- package/package.json +16 -6
- package/src/client-activity.js +24 -4
- package/src/client-capabilities-view.js +504 -27
- package/src/client-copy.js +187 -2
- package/src/client-indicator-view.js +2 -1
- package/src/client-repl-view.js +35 -11
- package/src/client-styles.js +64 -2
- package/src/client.js +3 -1
|
@@ -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')
|
|
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
|
-
|
|
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
|
-
?
|
|
188
|
-
:
|
|
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:
|
|
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) {
|
|
@@ -1,27 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Official persistent-REPL priority.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* runtime
|
|
10
|
-
*
|
|
11
|
-
*
|
|
4
|
+
* The question is not which source form the official provider evaluates; it is
|
|
5
|
+
* whether that provider keeps a program environment alive per state owner. A
|
|
6
|
+
* host declares that fact as `retainsState`, and the answer decides who owns PTC
|
|
7
|
+
* session execution: while the user selects the official REPL priority and the
|
|
8
|
+
* live provider retains state, the plugin yields execution to the official
|
|
9
|
+
* implementation and installs no session runtime — `run_code`, its descriptors
|
|
10
|
+
* and its guidance stay official. Neither the source form nor a release
|
|
11
|
+
* identifier can answer it: one host line retains state for `script` programs and
|
|
12
|
+
* another runs the same `script` in a fresh process, and an earlier line could
|
|
13
|
+
* only report the REPL form at all. The decision is re-evaluated on every
|
|
14
|
+
* configuration change and host source change, so either side can take over
|
|
15
|
+
* without restarting the host.
|
|
12
16
|
*/
|
|
13
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Whether a live provider retains a program environment per state owner.
|
|
20
|
+
*
|
|
21
|
+
* A provider that reports `retainsState` answers directly, whatever source form
|
|
22
|
+
* it evaluates. A provider that predates that declaration leaks the same fact
|
|
23
|
+
* through its source form: only `repl` was retained, so a `script` or function
|
|
24
|
+
* body from that host is a fresh environment.
|
|
25
|
+
* @param {object} provider - the provider surface registered for the seam.
|
|
26
|
+
* @returns {boolean} true when the provider keeps state across runs per owner.
|
|
27
|
+
*/
|
|
28
|
+
export function providerRetainsState(provider) {
|
|
29
|
+
const reported = provider?.retainsState
|
|
30
|
+
if (typeof reported === 'boolean') return reported
|
|
31
|
+
// Without the declaration the source form is the only published evidence.
|
|
32
|
+
return provider?.programFormat === 'repl'
|
|
33
|
+
}
|
|
34
|
+
|
|
14
35
|
/**
|
|
15
36
|
* Decide whether this configuration yields PTC session execution to the
|
|
16
37
|
* official provider.
|
|
17
38
|
* @param {object} config - resolved plugin configuration.
|
|
18
39
|
* @param {object} provider - the live provider surface registered for the seam.
|
|
19
|
-
* @returns {boolean} true when the official
|
|
40
|
+
* @returns {boolean} true when the official provider owns execution.
|
|
20
41
|
*/
|
|
21
42
|
export function defersToOfficialRepl(config, provider) {
|
|
22
43
|
if (config?.officialReplPriority !== true) return false
|
|
23
44
|
try {
|
|
24
|
-
return provider
|
|
45
|
+
return providerRetainsState(provider)
|
|
25
46
|
} catch {
|
|
26
47
|
// An unreadable capability descriptor cannot prove official ownership;
|
|
27
48
|
// the plugin keeps its execution responsibility instead of guessing.
|
|
@@ -175,6 +175,17 @@ export function capabilityFind(metadata, query) {
|
|
|
175
175
|
.map(candidate => candidate.value)
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Inspection answers "how do I call this", so it hands back the declaration the
|
|
180
|
+
* model programs against. The JSON schema is how the plugin stores a signature,
|
|
181
|
+
* not how anyone reads one; keeping it in the payload made the model parse JSON
|
|
182
|
+
* to learn a call shape the source already states.
|
|
183
|
+
*/
|
|
184
|
+
function inspectableMember(member) {
|
|
185
|
+
const { schema, ...rest } = member
|
|
186
|
+
return rest
|
|
187
|
+
}
|
|
188
|
+
|
|
178
189
|
/** Batch, budgeted inspection; omitted items remain explicit to the caller. */
|
|
179
190
|
export function capabilityInspect(metadata, symbols = undefined, budget = 50) {
|
|
180
191
|
if (!Number.isSafeInteger(budget) || budget < 1) throw new TypeError('ptc-plus: capability inspection budget must be a positive integer')
|
|
@@ -190,7 +201,7 @@ export function capabilityInspect(metadata, symbols = undefined, budget = 50) {
|
|
|
190
201
|
const selected = requested.slice(0, budget).flatMap((symbol) => {
|
|
191
202
|
const key = text(symbol, 'capability symbol')
|
|
192
203
|
const found = bySymbol.get(key)
|
|
193
|
-
return found === undefined ? [] : [found]
|
|
204
|
+
return found === undefined ? [] : [inspectableMember(found)]
|
|
194
205
|
})
|
|
195
206
|
return {
|
|
196
207
|
symbols: selected,
|
|
@@ -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
|
+
}
|