dsh-ptc-plus 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +137 -0
  3. package/README.zh.md +137 -0
  4. package/assets/dsh-ptc-plus-banner.webp +0 -0
  5. package/assets/ptc-plus-repair.png +0 -0
  6. package/assets/ptc-plus-settings.png +0 -0
  7. package/client.js +366 -0
  8. package/cordis.patch.yml +5 -0
  9. package/docs/README.md +21 -0
  10. package/docs/adr/0001-delegate-governance-to-dsh.md +3 -0
  11. package/docs/adr/0002-limit-work-map-scope.md +3 -0
  12. package/docs/adr/0003-prefer-native-program-surfaces.md +3 -0
  13. package/docs/adr/0004-declare-program-bindings-at-the-host.md +5 -0
  14. package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +129 -0
  15. package/docs/adr/0006-rewrite-module-syntax-with-an-ast.md +45 -0
  16. package/docs/adr/0007-separate-worker-transport-from-session-semantics.md +39 -0
  17. package/docs/adr/0008-define-agent-operating-contract.md +25 -0
  18. package/docs/adr/0009-bound-expensive-acceptance-default.md +39 -0
  19. package/docs/adr/0010-session-log-derived-recovery-tips.md +26 -0
  20. package/docs/adr/0011-isolate-the-local-recovery-tip-provider.md +20 -0
  21. package/docs/adr/0012-preserve-mixed-destructuring-initialization.md +23 -0
  22. package/docs/adr/0013-isolate-repl-redeclaration-convenience.md +21 -0
  23. package/docs/adr/0014-persist-cell-rewrite-policy.md +25 -0
  24. package/docs/adr/0015-preserve-source-positions-through-rewrites.md +25 -0
  25. package/docs/adr/0016-archive-resolved-review-findings.md +43 -0
  26. package/docs/adr/0017-track-the-latest-dsh-public-surface.md +23 -0
  27. package/docs/adr/0018-use-versioned-fixture-for-ab-benchmark.md +25 -0
  28. package/docs/adr/0019-plugin-settings-and-kill-switch.md +32 -0
  29. package/docs/adr/0020-optional-cordis-tools-in-ptc-mode.md +36 -0
  30. package/docs/adr/0021-separate-host-bootstrap-from-session-runtime.md +68 -0
  31. package/docs/adr/README.md +18 -0
  32. package/docs/architecture.md +134 -0
  33. package/docs/capability-projection.md +48 -0
  34. package/docs/client-ui.md +47 -0
  35. package/docs/durability-design.md +265 -0
  36. package/docs/evaluation.md +62 -0
  37. package/docs/installation.md +82 -0
  38. package/docs/program-data-plane.md +61 -0
  39. package/docs/publishing.md +37 -0
  40. package/docs/runtime-reference.md +119 -0
  41. package/docs/value-wire.md +61 -0
  42. package/index.js +532 -0
  43. package/internal/ast-traversal.js +20 -0
  44. package/internal/binding-descriptors.js +95 -0
  45. package/internal/binding-pattern.js +54 -0
  46. package/internal/builtin-esm-sync.js +9 -0
  47. package/internal/cell-analysis.js +467 -0
  48. package/internal/cell-rewriter.js +520 -0
  49. package/internal/config-spec.js +179 -0
  50. package/internal/cordis-tools-owner.js +363 -0
  51. package/internal/diagnostic.js +133 -0
  52. package/internal/direct-surface-owner.js +326 -0
  53. package/internal/edit-transport-owner.js +287 -0
  54. package/internal/failure-reporting.js +139 -0
  55. package/internal/kernel-worker.js +484 -0
  56. package/internal/module-policy.js +46 -0
  57. package/internal/program-bindings.js +187 -0
  58. package/internal/record-utils.js +53 -0
  59. package/internal/recovery-tips.js +81 -0
  60. package/internal/rejected-cell-editor.js +328 -0
  61. package/internal/repl-convenience.js +201 -0
  62. package/internal/runtime-bridge-owner.js +417 -0
  63. package/internal/runtime-config.js +77 -0
  64. package/internal/runtime-contexts.js +31 -0
  65. package/internal/sdk-projection.js +88 -0
  66. package/internal/session-cell-executor.js +612 -0
  67. package/internal/session-journal.js +889 -0
  68. package/internal/session-log-view.js +80 -0
  69. package/internal/session-runtime.js +536 -0
  70. package/internal/session-state.js +45 -0
  71. package/internal/source-position-map.js +224 -0
  72. package/internal/tool-call-canonicalizer.js +339 -0
  73. package/internal/value-wire.js +421 -0
  74. package/internal/worker-client.js +218 -0
  75. package/internal/worker-cwd-virtualization.js +155 -0
  76. package/package.json +100 -0
  77. package/src/client.js +165 -0
@@ -0,0 +1,421 @@
1
+ import { assertFields, isRecord } from './record-utils.js'
2
+
3
+ export const VALUE_CODEC = 'ptc-value-graph/v1'
4
+
5
+ export const DEFAULT_VALUE_LIMITS = Object.freeze({
6
+ maxNodes: 100_000,
7
+ maxEdges: 1_000_000,
8
+ maxArrayLength: 1_000_000,
9
+ maxBigIntDigits: 100_000,
10
+ maxStringBytes: 64 * 1024 * 1024,
11
+ })
12
+
13
+ const ENVELOPE_FIELDS = new Set(['codec', 'root', 'nodes'])
14
+ const OBJECT_NODE_FIELDS = new Set(['type', 'prototype', 'entries'])
15
+ const ARRAY_NODE_FIELDS = new Set(['type', 'length', 'entries'])
16
+ const UNDEFINED_FIELDS = new Set(['tag'])
17
+ const NUMBER_FIELDS = new Set(['tag', 'value'])
18
+ const BIGINT_FIELDS = new Set(['tag', 'value'])
19
+ const REFERENCE_FIELDS = new Set(['tag', 'index'])
20
+ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
21
+
22
+ function limitsOf(options = {}) {
23
+ const limits = { ...DEFAULT_VALUE_LIMITS, ...options }
24
+ for (const [name, value] of Object.entries(limits)) {
25
+ if (!Number.isSafeInteger(value) || value < 1) throw new TypeError(`invalid PTC value limit ${name}`)
26
+ }
27
+ return limits
28
+ }
29
+
30
+ function hasIntrinsicConstructor(prototype, name) {
31
+ const constructor = Object.getOwnPropertyDescriptor(prototype, 'constructor')?.value
32
+ if (typeof constructor !== 'function') return false
33
+ try {
34
+ return constructor.name === name
35
+ && constructor.prototype === prototype
36
+ && Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`
37
+ } catch {
38
+ return false
39
+ }
40
+ }
41
+
42
+ function isIntrinsicObjectPrototype(value) {
43
+ return Object.getPrototypeOf(value) === null && hasIntrinsicConstructor(value, 'Object')
44
+ }
45
+
46
+ function plainObjectPrototype(value) {
47
+ const prototype = Object.getPrototypeOf(value)
48
+ if (prototype === null) return 'null'
49
+ if (typeof prototype === 'object' && isIntrinsicObjectPrototype(prototype)) return 'object'
50
+ return undefined
51
+ }
52
+
53
+ function hasPlainArrayPrototype(value) {
54
+ const prototype = Object.getPrototypeOf(value)
55
+ if (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, 'Array')) return false
56
+ const objectPrototype = Object.getPrototypeOf(prototype)
57
+ return objectPrototype !== null && typeof objectPrototype === 'object'
58
+ && isIntrinsicObjectPrototype(objectPrototype)
59
+ }
60
+
61
+ function invalid(path, detail) {
62
+ throw new TypeError(`value at ${path} is not PTC Value V1: ${detail}`)
63
+ }
64
+
65
+ function stringBytes(value) {
66
+ return Buffer.byteLength(value, 'utf8')
67
+ }
68
+
69
+ /** Encode one supported JavaScript graph into the project-owned JSON-safe wire. */
70
+ export function encodeValue(value, options = {}) {
71
+ const limits = limitsOf(options)
72
+ const seen = new WeakMap()
73
+ const pending = []
74
+ const nodes = []
75
+ let edgeCount = 0
76
+ let textBytes = 0
77
+
78
+ const accountText = (text, path) => {
79
+ textBytes += stringBytes(text)
80
+ if (textBytes > limits.maxStringBytes) invalid(path, `string budget exceeds ${limits.maxStringBytes} bytes`)
81
+ }
82
+
83
+ const atom = (current, path) => {
84
+ if (current === null || typeof current === 'boolean') return current
85
+ if (typeof current === 'string') {
86
+ accountText(current, path)
87
+ return current
88
+ }
89
+ if (typeof current === 'number') {
90
+ if (Number.isNaN(current)) return { tag: 'number', value: 'nan' }
91
+ if (current === Infinity) return { tag: 'number', value: 'infinity' }
92
+ if (current === -Infinity) return { tag: 'number', value: '-infinity' }
93
+ if (Object.is(current, -0)) return { tag: 'number', value: '-0' }
94
+ return current
95
+ }
96
+ if (current === undefined) return { tag: 'undefined' }
97
+ if (typeof current === 'bigint') {
98
+ const text = String(current)
99
+ const digits = text[0] === '-' ? text.length - 1 : text.length
100
+ if (digits > limits.maxBigIntDigits) invalid(path, `BigInt exceeds ${limits.maxBigIntDigits} digits`)
101
+ accountText(text, path)
102
+ return { tag: 'bigint', value: text }
103
+ }
104
+ if (typeof current !== 'object') invalid(path, typeof current)
105
+ const prior = seen.get(current)
106
+ if (prior !== undefined) return { tag: 'reference', index: prior }
107
+ if (nodes.length >= limits.maxNodes) invalid(path, `node budget exceeds ${limits.maxNodes}`)
108
+ const index = nodes.length
109
+ seen.set(current, index)
110
+ nodes.push(undefined)
111
+ pending.push({ value: current, index, path })
112
+ return { tag: 'reference', index }
113
+ }
114
+
115
+ const root = atom(value, '$')
116
+ for (let cursor = 0; cursor < pending.length; cursor += 1) {
117
+ const item = pending[cursor]
118
+ const current = item.value
119
+ if (Array.isArray(current)) {
120
+ if (!hasPlainArrayPrototype(current)) invalid(item.path, 'non-plain array')
121
+ if (current.length > limits.maxArrayLength) invalid(item.path, `array length exceeds ${limits.maxArrayLength}`)
122
+ const entries = []
123
+ for (const key of Reflect.ownKeys(current)) {
124
+ if (key === 'length') continue
125
+ if (typeof key !== 'string' || !/^(0|[1-9][0-9]*)$/.test(key)) {
126
+ invalid(item.path, 'array has a symbol or non-index property')
127
+ }
128
+ const index = Number(key)
129
+ if (!Number.isSafeInteger(index) || index >= current.length) invalid(`${item.path}[${key}]`, 'invalid array index')
130
+ const descriptor = Object.getOwnPropertyDescriptor(current, key)
131
+ if (descriptor === undefined || !Object.hasOwn(descriptor, 'value') || descriptor.enumerable !== true) {
132
+ invalid(`${item.path}[${key}]`, 'array index must be an enumerable data property')
133
+ }
134
+ edgeCount += 1
135
+ if (edgeCount > limits.maxEdges) invalid(item.path, `edge budget exceeds ${limits.maxEdges}`)
136
+ entries.push([index, atom(descriptor.value, `${item.path}[${key}]`)])
137
+ }
138
+ nodes[item.index] = { type: 'array', length: current.length, entries }
139
+ continue
140
+ }
141
+
142
+ const prototype = plainObjectPrototype(current)
143
+ if (prototype === undefined) invalid(item.path, 'non-plain object')
144
+ const entries = []
145
+ for (const key of Reflect.ownKeys(current)) {
146
+ if (typeof key !== 'string') invalid(item.path, 'object has a symbol key')
147
+ const descriptor = Object.getOwnPropertyDescriptor(current, key)
148
+ if (descriptor === undefined || !Object.hasOwn(descriptor, 'value') || descriptor.enumerable !== true) {
149
+ invalid(`${item.path}.${key}`, 'object property must be an enumerable data property')
150
+ }
151
+ accountText(key, `${item.path}.${key}`)
152
+ edgeCount += 1
153
+ if (edgeCount > limits.maxEdges) invalid(item.path, `edge budget exceeds ${limits.maxEdges}`)
154
+ entries.push([key, atom(descriptor.value, `${item.path}.${key}`)])
155
+ }
156
+ nodes[item.index] = { type: 'object', prototype, entries }
157
+ }
158
+ return { codec: VALUE_CODEC, root, nodes }
159
+ }
160
+
161
+ function decodeAtom(atom, nodes, limits, budget, path) {
162
+ if (atom === null || typeof atom === 'boolean') return atom
163
+ if (typeof atom === 'string') {
164
+ budget.textBytes += stringBytes(atom)
165
+ if (budget.textBytes > limits.maxStringBytes) throw new TypeError('PTC value string budget exceeded')
166
+ return atom
167
+ }
168
+ if (typeof atom === 'number') {
169
+ if (!Number.isFinite(atom) || Object.is(atom, -0)) throw new TypeError(`invalid PTC value number at ${path}`)
170
+ return atom
171
+ }
172
+ if (!isRecord(atom) || typeof atom.tag !== 'string') throw new TypeError(`invalid PTC value atom at ${path}`)
173
+ if (atom.tag === 'undefined') {
174
+ assertFields(atom, UNDEFINED_FIELDS, 'PTC undefined atom')
175
+ return undefined
176
+ }
177
+ if (atom.tag === 'number') {
178
+ assertFields(atom, NUMBER_FIELDS, 'PTC number atom')
179
+ if (atom.value === 'nan') return NaN
180
+ if (atom.value === 'infinity') return Infinity
181
+ if (atom.value === '-infinity') return -Infinity
182
+ if (atom.value === '-0') return -0
183
+ throw new TypeError('invalid PTC special number')
184
+ }
185
+ if (atom.tag === 'bigint') {
186
+ assertFields(atom, BIGINT_FIELDS, 'PTC BigInt atom')
187
+ if (typeof atom.value !== 'string' || !/^(0|-[1-9][0-9]*|[1-9][0-9]*)$/.test(atom.value)) {
188
+ throw new TypeError('invalid PTC BigInt')
189
+ }
190
+ const digits = atom.value[0] === '-' ? atom.value.length - 1 : atom.value.length
191
+ if (digits > limits.maxBigIntDigits) throw new TypeError('PTC BigInt digit budget exceeded')
192
+ budget.textBytes += stringBytes(atom.value)
193
+ if (budget.textBytes > limits.maxStringBytes) throw new TypeError('PTC value string budget exceeded')
194
+ return BigInt(atom.value)
195
+ }
196
+ if (atom.tag === 'reference') {
197
+ assertFields(atom, REFERENCE_FIELDS, 'PTC reference atom')
198
+ if (!Number.isSafeInteger(atom.index) || atom.index < 0 || atom.index >= nodes.length) {
199
+ throw new TypeError('dangling PTC value reference')
200
+ }
201
+ budget.reachable.add(atom.index)
202
+ return nodes[atom.index]
203
+ }
204
+ throw new TypeError(`unknown PTC value atom tag ${JSON.stringify(atom.tag)}`)
205
+ }
206
+
207
+ /** Validate, hydrate, and own one canonical PTC value envelope without recursive stack growth. */
208
+ function decodeCanonicalValue(wire, options = {}) {
209
+ const limits = limitsOf(options)
210
+ assertFields(wire, ENVELOPE_FIELDS, 'PTC value envelope')
211
+ if (wire.codec !== VALUE_CODEC || !Array.isArray(wire.nodes)) throw new TypeError('invalid PTC value codec')
212
+ if (wire.nodes.length > limits.maxNodes) throw new TypeError('PTC value node budget exceeded')
213
+ const targets = new Array(wire.nodes.length)
214
+ for (let index = 0; index < wire.nodes.length; index += 1) {
215
+ const node = wire.nodes[index]
216
+ if (!isRecord(node)) throw new TypeError(`invalid PTC value node ${index}`)
217
+ if (node.type === 'array') {
218
+ assertFields(node, ARRAY_NODE_FIELDS, `PTC array node ${index}`)
219
+ if (!Number.isSafeInteger(node.length) || node.length < 0 || node.length > limits.maxArrayLength) {
220
+ throw new TypeError(`invalid PTC array length at node ${index}`)
221
+ }
222
+ if (!Array.isArray(node.entries)) throw new TypeError(`invalid PTC array entries at node ${index}`)
223
+ targets[index] = new Array(node.length)
224
+ } else if (node.type === 'object') {
225
+ assertFields(node, OBJECT_NODE_FIELDS, `PTC object node ${index}`)
226
+ if (!['object', 'null'].includes(node.prototype) || !Array.isArray(node.entries)) {
227
+ throw new TypeError(`invalid PTC object node ${index}`)
228
+ }
229
+ targets[index] = node.prototype === 'null' ? Object.create(null) : {}
230
+ } else {
231
+ throw new TypeError(`unknown PTC value node type at ${index}`)
232
+ }
233
+ }
234
+
235
+ const budget = { edges: 0, textBytes: 0, reachable: new Set() }
236
+ const root = decodeAtom(wire.root, targets, limits, budget, '$')
237
+ for (let nodeIndex = 0; nodeIndex < wire.nodes.length; nodeIndex += 1) {
238
+ const node = wire.nodes[nodeIndex]
239
+ const target = targets[nodeIndex]
240
+ if (node.type === 'array') {
241
+ let previous = -1
242
+ for (const entry of node.entries) {
243
+ if (!Array.isArray(entry) || entry.length !== 2 || !Number.isSafeInteger(entry[0])
244
+ || entry[0] <= previous || entry[0] < 0 || entry[0] >= node.length) {
245
+ throw new TypeError(`invalid PTC array entry at node ${nodeIndex}`)
246
+ }
247
+ previous = entry[0]
248
+ budget.edges += 1
249
+ if (budget.edges > limits.maxEdges) throw new TypeError('PTC value edge budget exceeded')
250
+ Object.defineProperty(target, entry[0], {
251
+ value: decodeAtom(entry[1], targets, limits, budget, `$nodes[${nodeIndex}][${entry[0]}]`),
252
+ enumerable: true, configurable: true, writable: true,
253
+ })
254
+ }
255
+ continue
256
+ }
257
+ const keys = new Set()
258
+ for (const entry of node.entries) {
259
+ if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string' || keys.has(entry[0])) {
260
+ throw new TypeError(`invalid PTC object entry at node ${nodeIndex}`)
261
+ }
262
+ keys.add(entry[0])
263
+ budget.textBytes += stringBytes(entry[0])
264
+ if (budget.textBytes > limits.maxStringBytes) throw new TypeError('PTC value string budget exceeded')
265
+ budget.edges += 1
266
+ if (budget.edges > limits.maxEdges) throw new TypeError('PTC value edge budget exceeded')
267
+ Object.defineProperty(target, entry[0], {
268
+ value: decodeAtom(entry[1], targets, limits, budget, `$nodes[${nodeIndex}].${entry[0]}`),
269
+ enumerable: true, configurable: true, writable: true,
270
+ })
271
+ }
272
+ }
273
+ if (budget.reachable.size !== wire.nodes.length) throw new TypeError('PTC value envelope contains unreachable nodes')
274
+ const canonical = encodeValue(root, limits)
275
+ const serialization = JSON.stringify(canonical)
276
+ if (serialization !== JSON.stringify(wire)) throw new TypeError('non-canonical PTC value envelope')
277
+ return { value: root, wire: canonical, serialization }
278
+ }
279
+
280
+ /** Validate and hydrate one canonical PTC value envelope without recursive stack growth. */
281
+ export function decodeValue(wire, options = {}) {
282
+ return decodeCanonicalValue(wire, options).value
283
+ }
284
+
285
+ export function normalizeValueWire(wire, options = {}) {
286
+ return decodeCanonicalValue(wire, options).wire
287
+ }
288
+
289
+ export function valueWiresEqual(left, right, options = {}) {
290
+ try {
291
+ return decodeCanonicalValue(left, options).serialization
292
+ === decodeCanonicalValue(right, options).serialization
293
+ } catch {
294
+ return false
295
+ }
296
+ }
297
+
298
+ /** True only when the outer structured-result projection preserves the complete value semantics. */
299
+ export function isPlainJsonTree(value) {
300
+ const pending = [value]
301
+ const seen = new WeakSet()
302
+ while (pending.length > 0) {
303
+ const current = pending.pop()
304
+ if (current === null || typeof current === 'string' || typeof current === 'boolean') continue
305
+ if (typeof current === 'number' && Number.isFinite(current) && !Object.is(current, -0)) continue
306
+ if (typeof current !== 'object') return false
307
+ if (seen.has(current)) return false
308
+ seen.add(current)
309
+ if (Array.isArray(current)) {
310
+ for (let index = 0; index < current.length; index += 1) {
311
+ if (!Object.hasOwn(current, index)) return false
312
+ pending.push(current[index])
313
+ }
314
+ } else {
315
+ for (const key of Object.keys(current)) pending.push(current[key])
316
+ }
317
+ }
318
+ return true
319
+ }
320
+
321
+ function renderKey(key) {
322
+ return IDENTIFIER.test(key) ? key : JSON.stringify(key)
323
+ }
324
+
325
+ function atomText(atom) {
326
+ if (atom === null || typeof atom === 'boolean' || typeof atom === 'number') return String(atom)
327
+ if (typeof atom === 'string') return JSON.stringify(atom)
328
+ if (atom.tag === 'undefined') return 'undefined'
329
+ if (atom.tag === 'number') {
330
+ return atom.value === 'nan' ? 'NaN'
331
+ : atom.value === 'infinity' ? 'Infinity'
332
+ : atom.value === '-infinity' ? '-Infinity' : '-0'
333
+ }
334
+ if (atom.tag === 'bigint') return `${atom.value}n`
335
+ return undefined
336
+ }
337
+
338
+ /** Deterministic bounded TS-like presentation. It is never a decode format. */
339
+ export function renderValueWire(wire, options = {}) {
340
+ const limits = limitsOf(options)
341
+ const normalized = normalizeValueWire(wire, limits)
342
+ const references = new Array(normalized.nodes.length).fill(0)
343
+ const count = (atom) => {
344
+ if (isRecord(atom) && atom.tag === 'reference') references[atom.index] += 1
345
+ }
346
+ count(normalized.root)
347
+ for (const node of normalized.nodes) for (const entry of node.entries) count(entry[1])
348
+ const labels = new Map()
349
+ let nextLabel = 0
350
+ for (let index = 0; index < references.length; index += 1) {
351
+ if (references[index] > 1) labels.set(index, ++nextLabel)
352
+ }
353
+
354
+ const chunks = []
355
+ let bytes = 0
356
+ const append = (text) => {
357
+ bytes += stringBytes(text)
358
+ if (bytes > limits.maxStringBytes) throw new TypeError(`rendered PTC value exceeds ${limits.maxStringBytes} bytes`)
359
+ chunks.push(text)
360
+ }
361
+ const emitted = new Set()
362
+ const active = new Set()
363
+ const tasks = [{ kind: 'atom', atom: normalized.root }]
364
+ while (tasks.length > 0) {
365
+ const task = tasks.pop()
366
+ if (task.kind === 'text') {
367
+ append(task.text)
368
+ continue
369
+ }
370
+ if (task.kind === 'leave') {
371
+ active.delete(task.index)
372
+ continue
373
+ }
374
+ const primitive = atomText(task.atom)
375
+ if (primitive !== undefined) {
376
+ append(primitive)
377
+ continue
378
+ }
379
+ const index = task.atom.index
380
+ const label = labels.get(index)
381
+ if (emitted.has(index)) {
382
+ append(active.has(index) ? `[Circular *${label}]` : `[Reference *${label}]`)
383
+ continue
384
+ }
385
+ emitted.add(index)
386
+ active.add(index)
387
+ if (label !== undefined) append(`<ref *${label}> `)
388
+ const node = normalized.nodes[index]
389
+ tasks.push({ kind: 'leave', index })
390
+ if (node.type === 'array') {
391
+ append('[')
392
+ tasks.push({ kind: 'text', text: ']' })
393
+ const entries = new Map(node.entries)
394
+ if (node.length > 0 && !entries.has(node.length - 1)) tasks.push({ kind: 'text', text: ',' })
395
+ for (let item = node.length - 1; item >= 0; item -= 1) {
396
+ if (item < node.length - 1) tasks.push({ kind: 'text', text: ', ' })
397
+ const value = entries.get(item)
398
+ if (value !== undefined) tasks.push({ kind: 'atom', atom: value })
399
+ }
400
+ continue
401
+ }
402
+ append('{')
403
+ tasks.push({ kind: 'text', text: '}' })
404
+ for (let entryIndex = node.entries.length - 1; entryIndex >= 0; entryIndex -= 1) {
405
+ const [key, value] = node.entries[entryIndex]
406
+ if (entryIndex < node.entries.length - 1) tasks.push({ kind: 'text', text: ', ' })
407
+ tasks.push({ kind: 'atom', atom: value })
408
+ tasks.push({ kind: 'text', text: `${renderKey(key)}: ` })
409
+ }
410
+ }
411
+ return chunks.join('')
412
+ }
413
+
414
+ /**
415
+ * Return a lossless outer value: plain JSON values stay structured; richer values become rendered
416
+ * text. The untagged ranges overlap because a plain JSON root may itself be a string.
417
+ */
418
+ export function projectValueWire(wire, options = {}) {
419
+ const value = decodeValue(wire, options)
420
+ return isPlainJsonTree(value) ? value : renderValueWire(wire, options)
421
+ }
@@ -0,0 +1,218 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { isAbsolute, join } from 'node:path'
4
+ import { Worker } from 'node:worker_threads'
5
+ import { messageOf } from './failure-reporting.js'
6
+
7
+ const WINDOWS_ENVIRONMENT_NAMES = new Map([
8
+ ['appdata', 'APPDATA'],
9
+ ['comspec', 'ComSpec'],
10
+ ['home', 'HOME'],
11
+ ['homedrive', 'HOMEDRIVE'],
12
+ ['homepath', 'HOMEPATH'],
13
+ ['localappdata', 'LOCALAPPDATA'],
14
+ ['path', 'PATH'],
15
+ ['pathext', 'PATHEXT'],
16
+ ['programdata', 'ProgramData'],
17
+ ['programfiles', 'ProgramFiles'],
18
+ ['programfiles(x86)', 'ProgramFiles(x86)'],
19
+ ['systemdrive', 'SystemDrive'],
20
+ ['systemroot', 'SystemRoot'],
21
+ ['temp', 'TEMP'],
22
+ ['tmp', 'TMP'],
23
+ ['userprofile', 'USERPROFILE'],
24
+ ['windir', 'windir'],
25
+ ])
26
+ const HOST_ONLY_ENVIRONMENT_NAMES = new Set(['node_test_context', 'node_v8_coverage'])
27
+
28
+ /** Project the host environment once without losing Windows case variants. */
29
+ export function normalizeWorkerEnvironment(source, platform = process.platform) {
30
+ if (platform !== 'win32') {
31
+ return Object.fromEntries(Object.entries(source).filter(([name, value]) => (
32
+ value !== undefined && !HOST_ONLY_ENVIRONMENT_NAMES.has(name.toLowerCase())
33
+ )))
34
+ }
35
+ const normalized = new Map()
36
+ for (const [name, value] of Object.entries(source)) {
37
+ if (value === undefined) continue
38
+ const key = name.toLowerCase()
39
+ if (HOST_ONLY_ENVIRONMENT_NAMES.has(key)) continue
40
+ const canonicalName = WINDOWS_ENVIRONMENT_NAMES.get(key) ?? name
41
+ const current = normalized.get(key)
42
+ if (current === undefined || name === canonicalName) {
43
+ normalized.set(key, [canonicalName, value])
44
+ }
45
+ }
46
+ return Object.fromEntries(normalized.values())
47
+ }
48
+
49
+ /** Owns one session kernel's worker process, private port, and scratch directory. */
50
+ export class WorkerClient {
51
+ constructor({ workerUrl, cwd, onMessage, onFailure }) {
52
+ this.workerUrl = workerUrl
53
+ this.cwd = cwd
54
+ this.onMessage = onMessage
55
+ this.onFailure = onFailure
56
+ this.worker = undefined
57
+ this.workerLimit = undefined
58
+ this.workerReady = undefined
59
+ this.port = undefined
60
+ this.scratchReady = undefined
61
+ this.stderrTails = new WeakMap()
62
+ this.terminations = new Set()
63
+ this.disposed = false
64
+ }
65
+
66
+ /** Last non-empty stderr line of a failed worker, capped for diagnostics. */
67
+ stderrDetail(worker) {
68
+ const tail = this.stderrTails.get(worker)?.join('').trim()
69
+ if (tail === undefined || tail.length === 0) return undefined
70
+ const lastLine = tail.split(/\r?\n/).map(line => line.trim()).filter(line => line.length > 0).slice(-1)[0]
71
+ return lastLine === undefined ? undefined : lastLine.slice(0, 300)
72
+ }
73
+
74
+ async ensure(maxOldGenerationSizeMb) {
75
+ if (this.workerLimit !== undefined && this.workerLimit !== maxOldGenerationSizeMb) {
76
+ throw new Error('ptc-plus: session worker memory limit differs from the submitted cell configuration')
77
+ }
78
+ if (this.worker !== undefined) return this.workerReady
79
+ if (this.scratchReady === undefined) {
80
+ const scratchRoot = tmpdir()
81
+ if (!isAbsolute(scratchRoot)) {
82
+ throw new Error(`ptc-plus: host temporary directory must be absolute, got ${JSON.stringify(scratchRoot)}`)
83
+ }
84
+ this.scratchReady = mkdtemp(join(scratchRoot, 'dsh-ptc-plus-'))
85
+ }
86
+ this.workerLimit = maxOldGenerationSizeMb
87
+ let scratchDirectory
88
+ try {
89
+ scratchDirectory = await this.scratchReady
90
+ } catch (error) {
91
+ if (this.worker === undefined) this.workerLimit = undefined
92
+ throw error
93
+ }
94
+ /* c8 ignore next */
95
+ if (this.disposed) {
96
+ this.workerLimit = undefined
97
+ throw new Error('session kernel disposed')
98
+ }
99
+ /* c8 ignore next */
100
+ if (this.worker !== undefined) return this.workerReady
101
+ const environment = normalizeWorkerEnvironment(process.env)
102
+ let worker
103
+ try {
104
+ worker = new Worker(this.workerUrl, {
105
+ env: {
106
+ ...environment,
107
+ TEMP: scratchDirectory,
108
+ TMP: scratchDirectory,
109
+ TMPDIR: scratchDirectory,
110
+ },
111
+ execArgv: [],
112
+ workerData: this.cwd === undefined ? {} : { cwd: this.cwd },
113
+ resourceLimits: { maxOldGenerationSizeMb: this.workerLimit },
114
+ stdout: true,
115
+ stderr: true,
116
+ })
117
+ } catch (error) {
118
+ this.workerLimit = undefined
119
+ throw error
120
+ }
121
+ worker.stdout.resume()
122
+ worker.stderr.resume()
123
+ const stderrTail = []
124
+ worker.stderr.on?.('data', (chunk) => {
125
+ const text = typeof chunk === 'string' ? chunk : String(chunk ?? '')
126
+ stderrTail.push(text)
127
+ const bounded = Buffer.from(stderrTail.join('')).subarray(-2048).toString('utf8')
128
+ stderrTail.splice(0, stderrTail.length, bounded)
129
+ })
130
+ this.stderrTails.set(worker, stderrTail)
131
+ worker.on('error', error => this.fail(worker, `worker error: ${messageOf(error)}`))
132
+ worker.on('exit', code => this.fail(worker, `worker exited with code ${code}`))
133
+ this.worker = worker
134
+ this.workerReady = new Promise((resolve, reject) => {
135
+ const onError = error => reject(error)
136
+ const onExit = (code) => {
137
+ const detail = this.stderrDetail(worker)
138
+ reject(new Error(detail === undefined
139
+ ? `worker exited with code ${code} before opening its private channel`
140
+ : `worker exited with code ${code} before opening its private channel; last stderr: ${detail}`))
141
+ }
142
+ worker.once('error', onError)
143
+ worker.once('exit', onExit)
144
+ worker.once('message', (message) => {
145
+ worker.removeListener('error', onError)
146
+ worker.removeListener('exit', onExit)
147
+ if (message?.type === 'startup-error' && typeof message.error === 'string') {
148
+ reject(new Error(message.error))
149
+ void this.reset(worker)
150
+ return
151
+ }
152
+ if (message?.type !== 'ready' || typeof message.port?.postMessage !== 'function') {
153
+ reject(new Error('kernel worker returned an invalid private channel'))
154
+ void this.reset(worker)
155
+ return
156
+ }
157
+ this.port = message.port
158
+ this.port.on('message', (value) => {
159
+ if (worker === this.worker) this.onMessage(value)
160
+ })
161
+ resolve(worker)
162
+ })
163
+ })
164
+ return this.workerReady
165
+ }
166
+
167
+ post(message) {
168
+ this.port.postMessage(message)
169
+ }
170
+
171
+ /** Best-effort reply for a request whose lease may outlive a worker reset. */
172
+ postIfAlive(message) {
173
+ this.port?.postMessage(message)
174
+ }
175
+
176
+ fail(worker, message) {
177
+ if (worker !== this.worker) return
178
+ this.worker = undefined
179
+ this.workerLimit = undefined
180
+ this.workerReady = undefined
181
+ this.port?.close()
182
+ this.port = undefined
183
+ const detail = this.stderrDetail(worker)
184
+ this.onFailure(detail === undefined ? message : `${message}; last stderr: ${detail}`)
185
+ }
186
+
187
+ async reset(worker) {
188
+ if (this.worker === worker) {
189
+ this.worker = undefined
190
+ this.workerLimit = undefined
191
+ this.workerReady = undefined
192
+ this.port?.close()
193
+ this.port = undefined
194
+ }
195
+ const termination = worker.terminate()
196
+ this.terminations.add(termination)
197
+ try {
198
+ await termination
199
+ } finally {
200
+ this.terminations.delete(termination)
201
+ }
202
+ }
203
+
204
+ async dispose() {
205
+ this.disposed = true
206
+ const worker = this.worker
207
+ if (worker !== undefined) await this.reset(worker)
208
+ await Promise.all([...this.terminations])
209
+ if (this.scratchReady !== undefined) {
210
+ try {
211
+ const scratchDirectory = await this.scratchReady
212
+ await rm(scratchDirectory, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 })
213
+ } catch {
214
+ // Scratch cleanup is best-effort after all worker handles are closed.
215
+ }
216
+ }
217
+ }
218
+ }