dsh-ptc-plus 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/README.zh.md +137 -0
- package/assets/dsh-ptc-plus-banner.webp +0 -0
- package/assets/ptc-plus-repair.png +0 -0
- package/assets/ptc-plus-settings.png +0 -0
- package/client.js +366 -0
- package/cordis.patch.yml +5 -0
- package/docs/README.md +21 -0
- package/docs/adr/0001-delegate-governance-to-dsh.md +3 -0
- package/docs/adr/0002-limit-work-map-scope.md +3 -0
- package/docs/adr/0003-prefer-native-program-surfaces.md +3 -0
- package/docs/adr/0004-declare-program-bindings-at-the-host.md +5 -0
- package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +129 -0
- package/docs/adr/0006-rewrite-module-syntax-with-an-ast.md +45 -0
- package/docs/adr/0007-separate-worker-transport-from-session-semantics.md +39 -0
- package/docs/adr/0008-define-agent-operating-contract.md +25 -0
- package/docs/adr/0009-bound-expensive-acceptance-default.md +39 -0
- package/docs/adr/0010-session-log-derived-recovery-tips.md +26 -0
- package/docs/adr/0011-isolate-the-local-recovery-tip-provider.md +20 -0
- package/docs/adr/0012-preserve-mixed-destructuring-initialization.md +23 -0
- package/docs/adr/0013-isolate-repl-redeclaration-convenience.md +21 -0
- package/docs/adr/0014-persist-cell-rewrite-policy.md +25 -0
- package/docs/adr/0015-preserve-source-positions-through-rewrites.md +25 -0
- package/docs/adr/0016-archive-resolved-review-findings.md +43 -0
- package/docs/adr/0017-track-the-latest-dsh-public-surface.md +23 -0
- package/docs/adr/0018-use-versioned-fixture-for-ab-benchmark.md +25 -0
- package/docs/adr/0019-plugin-settings-and-kill-switch.md +32 -0
- package/docs/adr/0020-optional-cordis-tools-in-ptc-mode.md +36 -0
- package/docs/adr/0021-separate-host-bootstrap-from-session-runtime.md +68 -0
- package/docs/adr/README.md +18 -0
- package/docs/architecture.md +134 -0
- package/docs/capability-projection.md +48 -0
- package/docs/client-ui.md +47 -0
- package/docs/durability-design.md +265 -0
- package/docs/evaluation.md +62 -0
- package/docs/installation.md +82 -0
- package/docs/program-data-plane.md +61 -0
- package/docs/publishing.md +37 -0
- package/docs/runtime-reference.md +119 -0
- package/docs/value-wire.md +61 -0
- package/index.js +532 -0
- package/internal/ast-traversal.js +20 -0
- package/internal/binding-descriptors.js +95 -0
- package/internal/binding-pattern.js +54 -0
- package/internal/builtin-esm-sync.js +9 -0
- package/internal/cell-analysis.js +467 -0
- package/internal/cell-rewriter.js +520 -0
- package/internal/config-spec.js +179 -0
- package/internal/cordis-tools-owner.js +363 -0
- package/internal/diagnostic.js +133 -0
- package/internal/direct-surface-owner.js +326 -0
- package/internal/edit-transport-owner.js +287 -0
- package/internal/failure-reporting.js +139 -0
- package/internal/kernel-worker.js +484 -0
- package/internal/module-policy.js +46 -0
- package/internal/program-bindings.js +187 -0
- package/internal/record-utils.js +53 -0
- package/internal/recovery-tips.js +81 -0
- package/internal/rejected-cell-editor.js +328 -0
- package/internal/repl-convenience.js +201 -0
- package/internal/runtime-bridge-owner.js +417 -0
- package/internal/runtime-config.js +77 -0
- package/internal/runtime-contexts.js +31 -0
- package/internal/sdk-projection.js +88 -0
- package/internal/session-cell-executor.js +612 -0
- package/internal/session-journal.js +889 -0
- package/internal/session-log-view.js +80 -0
- package/internal/session-runtime.js +536 -0
- package/internal/session-state.js +45 -0
- package/internal/source-position-map.js +224 -0
- package/internal/tool-call-canonicalizer.js +339 -0
- package/internal/value-wire.js +421 -0
- package/internal/worker-client.js +218 -0
- package/internal/worker-cwd-virtualization.js +155 -0
- package/package.json +100 -0
- package/src/client.js +165 -0
|
@@ -0,0 +1,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
|
+
}
|