threadwire 0.1.5 → 0.1.6

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.
@@ -0,0 +1,377 @@
1
+ // @ts-check
2
+
3
+ import {types as utilTypes} from "node:util"
4
+ import {sanitizeOutput} from "./relay.js"
5
+
6
+ export const CONCLUSION_LIMIT = 16_384
7
+ export const BLOCKER_LIMIT = 1_024
8
+ export const DECISION_REQUEST_LIMIT = 1_024
9
+ export const CONTINUATION_HANDLE_LIMIT = 512
10
+ export const ARTIFACT_HANDLE_LIMIT = 512
11
+ export const REFERENCE_VALUE_LIMIT = 2_048
12
+ export const VALIDATION_SUMMARY_LIMIT = 2_048
13
+ export const MAX_ARTIFACT_HANDLES = 16
14
+ export const MAX_REFERENCES = 16
15
+ export const MAX_ENVELOPE_BYTES = 98_304
16
+ const MAX_VALIDATION_BUDGET = 256
17
+
18
+ const CANDIDATE_FIELDS = new Set([
19
+ "state",
20
+ "exitCode",
21
+ "conclusion",
22
+ "blocker",
23
+ "decisionRequest",
24
+ "continuationHandle",
25
+ "artifactHandles",
26
+ "references",
27
+ "validationSummary"
28
+ ])
29
+ const STATES = new Set(["completed", "failed", "blocked", "needs_decision"])
30
+
31
+ /**
32
+ * @typedef {"completed" | "failed" | "blocked" | "needs_decision"} TerminalState
33
+ * @typedef {{kind: "commit" | "url", value: string}} AdmissionReference
34
+ * @typedef {{
35
+ * state: TerminalState,
36
+ * exitCode: number,
37
+ * conclusion?: string,
38
+ * blocker?: string,
39
+ * decisionRequest?: string,
40
+ * continuationHandle?: string,
41
+ * artifactHandles?: string[],
42
+ * references?: AdmissionReference[],
43
+ * validationSummary?: string
44
+ * }} DelegatedResultCandidate
45
+ */
46
+
47
+ export class DelegatedResultAdmission {
48
+ /** @param {{output: NodeJS.WritableStream, metrics?: import("./context-budget-metrics.js").ContextBudgetMetrics}} options */
49
+ constructor(options) {
50
+ this.output = options.output
51
+ this.metrics = options.metrics
52
+ this.conclusion = ""
53
+ this.conclusionTruncated = false
54
+ /** @type {string | undefined} */
55
+ this.continuationHandle = undefined
56
+ /** @type {string[]} */
57
+ this.artifactHandles = []
58
+ /** @type {AdmissionReference[]} */
59
+ this.references = []
60
+ this.completed = false
61
+ }
62
+
63
+ /** @param {string} text */
64
+ appendConclusion(text) {
65
+ if (typeof text !== "string") throw new Error("Conclusion text must be a string")
66
+ if (this.conclusionTruncated) return
67
+ text = wellFormedString(text)
68
+ const remaining = CONCLUSION_LIMIT - Array.from(this.conclusion).length
69
+ const characters = Array.from(text)
70
+ if (characters.length <= remaining) this.conclusion += text
71
+ else {
72
+ this.conclusion += characters.slice(0, Math.max(0, remaining - 1)).join("") + "…"
73
+ this.conclusionTruncated = true
74
+ }
75
+ }
76
+
77
+ /** @param {unknown} event */
78
+ acceptConclusionEvent(event) {
79
+ if (!isPlainObject(event) || !hasExactEnumerableFields(event, ["type", "text", "streamId"])) {
80
+ throw new Error("Invalid admitted conclusion event")
81
+ }
82
+ if (event.type !== "text-delta" || typeof event.text !== "string" || typeof event.streamId !== "string") {
83
+ throw new Error("Invalid admitted conclusion event")
84
+ }
85
+ this.appendConclusion(event.text)
86
+ }
87
+
88
+ /** @param {string} handle */
89
+ setContinuationHandle(handle) {
90
+ this.continuationHandle = validateContinuationHandle(handle)
91
+ }
92
+
93
+ /** @param {string} handle */
94
+ addArtifactHandle(handle) {
95
+ this.artifactHandles = optionalArtifactHandles([...this.artifactHandles, handle]) ?? []
96
+ }
97
+
98
+ /** @param {string} handle */
99
+ removeArtifactHandle(handle) {
100
+ this.artifactHandles = this.artifactHandles.filter((candidate) => candidate !== handle)
101
+ }
102
+
103
+ /** @param {{state: TerminalState, exitCode: number}} terminal */
104
+ preview(terminal) {
105
+ return this.createEnvelope(terminal)
106
+ }
107
+
108
+ /** @param {AdmissionReference} reference */
109
+ addReference(reference) {
110
+ this.references = /** @type {AdmissionReference[]} */ (optionalReferences([...this.references, reference]) ?? [])
111
+ }
112
+
113
+ /** @param {{state: TerminalState, exitCode: number}} terminal */
114
+ complete(terminal) {
115
+ if (this.completed) throw new Error("Delegated result already completed")
116
+ const envelope = this.createEnvelope(terminal)
117
+ this.completed = true
118
+ this.metrics?.recordAdmission(envelope)
119
+ this.output.write(`${JSON.stringify(envelope)}\n`)
120
+ }
121
+
122
+ /** @param {{state: TerminalState, exitCode: number}} terminal */
123
+ createEnvelope(terminal) {
124
+ const safeConclusion = truncateBoundedString(sanitizeOutput(this.conclusion), CONCLUSION_LIMIT)
125
+ return createDelegatedResultEnvelope({
126
+ ...terminal,
127
+ ...(terminal.state !== "completed" || safeConclusion === undefined ? {} : {conclusion: safeConclusion}),
128
+ ...(this.continuationHandle === undefined ? {} : {continuationHandle: this.continuationHandle}),
129
+ ...(this.artifactHandles.length === 0 ? {} : {artifactHandles: this.artifactHandles}),
130
+ ...(this.references.length === 0 ? {} : {references: this.references})
131
+ })
132
+ }
133
+ }
134
+
135
+ /** @param {unknown} candidate */
136
+ export function createDelegatedResultEnvelope(candidate) {
137
+ const clonedCandidate = cloneTopLevelCandidate(candidate)
138
+ for (const field of Reflect.ownKeys(clonedCandidate)) {
139
+ if (typeof field !== "string" || !CANDIDATE_FIELDS.has(field) || !Object.prototype.propertyIsEnumerable.call(clonedCandidate, field)) {
140
+ throw new Error(`Unknown delegated result candidate field: ${String(field)}`)
141
+ }
142
+ }
143
+ if (typeof clonedCandidate.state !== "string" || !STATES.has(clonedCandidate.state)) {
144
+ throw new Error("Invalid delegated result terminal state")
145
+ }
146
+ if (!Number.isSafeInteger(clonedCandidate.exitCode) || /** @type {number} */ (clonedCandidate.exitCode) < 0) {
147
+ throw new Error("Invalid delegated result exitCode")
148
+ }
149
+ const conclusion = optionalBoundedString(clonedCandidate.conclusion, "conclusion", CONCLUSION_LIMIT)
150
+ const blocker = optionalBoundedString(clonedCandidate.blocker, "blocker", BLOCKER_LIMIT)
151
+ const decisionRequest = optionalBoundedString(clonedCandidate.decisionRequest, "decisionRequest", DECISION_REQUEST_LIMIT)
152
+ const continuationHandle = clonedCandidate.continuationHandle === undefined ? undefined : validateContinuationHandle(clonedCandidate.continuationHandle)
153
+ const validationSummary = optionalBoundedString(clonedCandidate.validationSummary, "validationSummary", VALIDATION_SUMMARY_LIMIT)
154
+ const artifactHandles = optionalArtifactHandles(clonedCandidate.artifactHandles)
155
+ const references = optionalReferences(clonedCandidate.references)
156
+
157
+ const envelope = {
158
+ version: 1,
159
+ type: "delegated_result",
160
+ state: /** @type {TerminalState} */ (clonedCandidate.state),
161
+ exitCode: /** @type {number} */ (clonedCandidate.exitCode),
162
+ ...(conclusion === undefined ? {} : {conclusion}),
163
+ ...(blocker === undefined ? {} : {blocker}),
164
+ ...(decisionRequest === undefined ? {} : {decisionRequest}),
165
+ ...(continuationHandle === undefined ? {} : {continuationHandle}),
166
+ ...(artifactHandles === undefined ? {} : {artifactHandles}),
167
+ ...(references === undefined ? {} : {references}),
168
+ ...(validationSummary === undefined ? {} : {validationSummary})
169
+ }
170
+ if (Buffer.byteLength(JSON.stringify(envelope), "utf8") > MAX_ENVELOPE_BYTES) {
171
+ throw new Error("Delegated result candidate exceeds the envelope byte limit")
172
+ }
173
+ return envelope
174
+ }
175
+
176
+ /** @param {unknown} value @param {string} field @param {number} limit */
177
+ function optionalBoundedString(value, field, limit) {
178
+ return value === undefined ? undefined : boundedString(value, field, limit)
179
+ }
180
+
181
+ /** @param {unknown} value @param {string} field @param {number} limit */
182
+ function boundedString(value, field, limit) {
183
+ if (typeof value !== "string" || value.length === 0 || wellFormedString(value) !== value || Array.from(value).length > limit) {
184
+ throw new Error(`Invalid delegated result ${field}`)
185
+ }
186
+ return value
187
+ }
188
+
189
+ /** @param {string | undefined} value @param {number} limit */
190
+ function truncateBoundedString(value, limit) {
191
+ if (value === undefined) return undefined
192
+ const characters = Array.from(value)
193
+ if (characters.length <= limit) return value
194
+ if (limit <= 1) return "…"
195
+ return `${characters.slice(0, limit - 1).join("")}…`
196
+ }
197
+
198
+ /** @param {unknown} value */
199
+ export function validateContinuationHandle(value) {
200
+ const handle = boundedString(value, "continuationHandle", CONTINUATION_HANDLE_LIMIT)
201
+ if (
202
+ !/^[A-Za-z0-9][A-Za-z0-9._-]*(?::[A-Za-z0-9][A-Za-z0-9._-]*)*$/u.test(handle)
203
+ || /^(?:data|file|ftp|ftps|http|https|javascript|mailto|sftp|ssh|urn|ws|wss):/iu.test(handle)
204
+ ) {
205
+ throw new Error("Invalid delegated result continuationHandle")
206
+ }
207
+ return handle
208
+ }
209
+
210
+ /** @param {unknown} value */
211
+ function optionalArtifactHandles(value) {
212
+ if (value === undefined) return undefined
213
+ return validateClosedArray(value, "artifactHandles", MAX_ARTIFACT_HANDLES).map((item) => artifactHandle(item))
214
+ }
215
+
216
+ /** @param {unknown} value */
217
+ function artifactHandle(value) {
218
+ const handle = boundedString(value, "artifactHandles", ARTIFACT_HANDLE_LIMIT)
219
+ if (!/^\/?[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/u.test(handle)) {
220
+ throw new Error("Invalid delegated result artifactHandles")
221
+ }
222
+ const components = handle.split("/").filter((component) => component.length > 0)
223
+ if (components.some((component) => component === "." || component === "..")) {
224
+ throw new Error("Invalid delegated result artifactHandles")
225
+ }
226
+ return handle
227
+ }
228
+
229
+ /** @param {unknown} value */
230
+ function optionalReferences(value) {
231
+ if (value === undefined) return undefined
232
+ return validateClosedArray(value, "references", MAX_REFERENCES).map((reference) => {
233
+ if (!isPlainObject(reference) || !hasExactEnumerableFields(reference, ["kind", "value"])) {
234
+ throw new Error("Invalid delegated result reference")
235
+ }
236
+ if (reference.kind !== "commit" && reference.kind !== "url") throw new Error("Invalid delegated result reference kind")
237
+ const referenceValue = boundedString(reference.value, "references", REFERENCE_VALUE_LIMIT)
238
+ if (reference.kind === "commit" && !/^[0-9a-f]{7,64}$/iu.test(referenceValue)) {
239
+ throw new Error("Invalid delegated result commit reference")
240
+ }
241
+ if (reference.kind === "url") validateUrlReference(referenceValue)
242
+ return {kind: reference.kind, value: referenceValue}
243
+ })
244
+ }
245
+
246
+ /**
247
+ * @param {unknown} value
248
+ * @param {"artifactHandles" | "references"} field
249
+ * @param {number} limit
250
+ * @returns {unknown[]}
251
+ */
252
+ function validateClosedArray(value, field, limit) {
253
+ if (!Array.isArray(value) || value.length === 0 || value.length > limit) {
254
+ throw new Error(`Invalid delegated result ${field}`)
255
+ }
256
+ const keys = Reflect.ownKeys(value)
257
+ if (keys.length !== value.length + 1) throw new Error(`Invalid delegated result ${field}`)
258
+ for (let index = 0; index < value.length; index += 1) {
259
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index))
260
+ if (!descriptor || !("value" in descriptor) || descriptor.enumerable !== true) {
261
+ throw new Error(`Invalid delegated result ${field}`)
262
+ }
263
+ }
264
+ const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length")
265
+ if (
266
+ !lengthDescriptor
267
+ || !("value" in lengthDescriptor)
268
+ || lengthDescriptor.value !== value.length
269
+ || lengthDescriptor.enumerable !== false
270
+ ) throw new Error(`Invalid delegated result ${field}`)
271
+ if (keys.some((key) => (
272
+ key !== "length"
273
+ && (typeof key !== "string" || !/^(?:0|[1-9]\d*)$/u.test(key) || Number(key) >= value.length)
274
+ ))) throw new Error(`Invalid delegated result ${field}`)
275
+ return value
276
+ }
277
+
278
+ /** @param {string} value */
279
+ function validateUrlReference(value) {
280
+ if (Array.from(value).some((character) => {
281
+ const codePoint = character.codePointAt(0) ?? 0
282
+ return codePoint <= 31 || codePoint === 127 || /\s/u.test(character)
283
+ })) throw new Error("Invalid delegated result URL reference")
284
+ let parsed
285
+ try {
286
+ parsed = new URL(value)
287
+ } catch {
288
+ throw new Error("Invalid delegated result URL reference")
289
+ }
290
+ if (!["http:", "https:"].includes(parsed.protocol) || parsed.username.length > 0 || parsed.password.length > 0) {
291
+ throw new Error("Invalid delegated result URL reference")
292
+ }
293
+ }
294
+
295
+ /** @param {unknown} candidate @returns {DelegatedResultCandidate} */
296
+ function cloneTopLevelCandidate(candidate) {
297
+ if (candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) {
298
+ throw new Error("Delegated result candidate must be a plain object")
299
+ }
300
+ if (utilTypes.isProxy(candidate)) throw new Error("Delegated result candidate must not be a proxy")
301
+ if (!isPlainObject(candidate)) throw new Error("Delegated result candidate must be a plain object")
302
+ return /** @type {DelegatedResultCandidate} */ (cloneWithinBudget(candidate, {remaining: MAX_VALIDATION_BUDGET, seen: new WeakSet()}))
303
+ }
304
+
305
+ /**
306
+ * @param {unknown} value
307
+ * @param {{remaining: number, seen: WeakSet<object>}} state
308
+ * @returns {unknown}
309
+ */
310
+ function cloneWithinBudget(value, state) {
311
+ state.remaining -= 1
312
+ if (state.remaining < 0) throw new Error("Delegated result candidate exceeds the validation budget")
313
+ if (value === null || typeof value !== "object") return value
314
+ if (utilTypes.isProxy(value)) throw new Error("Delegated result candidate must not contain proxy values")
315
+ if (!Array.isArray(value) && !isPlainObject(value)) return value
316
+ if (state.seen.has(value)) throw new Error("Delegated result candidate must not contain cycles")
317
+ state.seen.add(value)
318
+ try {
319
+ return cloneConcreteContainer(value, state)
320
+ } finally {
321
+ state.seen.delete(value)
322
+ }
323
+ }
324
+
325
+ /**
326
+ * @param {Record<PropertyKey, unknown> | unknown[]} value
327
+ * @param {{remaining: number, seen: WeakSet<object>}} state
328
+ */
329
+ function cloneConcreteContainer(value, state) {
330
+ const clone = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value))
331
+ for (const key of Reflect.ownKeys(value)) {
332
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)
333
+ if (!descriptor) continue
334
+ if ("get" in descriptor || "set" in descriptor) {
335
+ throw new Error("Delegated result candidate must not contain accessor descriptors")
336
+ }
337
+ Object.defineProperty(clone, key, {
338
+ ...descriptor,
339
+ value: Array.isArray(value) && key === "length" ? descriptor.value : cloneWithinBudget(descriptor.value, state)
340
+ })
341
+ }
342
+ return clone
343
+ }
344
+
345
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
346
+ function isPlainObject(value) {
347
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false
348
+ const prototype = Object.getPrototypeOf(value)
349
+ return prototype === Object.prototype || prototype === null
350
+ }
351
+
352
+ /** @param {Record<string, unknown>} value @param {string[]} expected */
353
+ function hasExactEnumerableFields(value, expected) {
354
+ const fields = Reflect.ownKeys(value)
355
+ return fields.length === expected.length && fields.every((field) => (
356
+ typeof field === "string"
357
+ && expected.includes(field)
358
+ && Object.prototype.propertyIsEnumerable.call(value, field)
359
+ ))
360
+ }
361
+
362
+ /** @param {string} value */
363
+ function wellFormedString(value) {
364
+ let result = ""
365
+ for (let index = 0; index < value.length; index += 1) {
366
+ const codeUnit = value.charCodeAt(index)
367
+ if (codeUnit >= 0xD800 && codeUnit <= 0xDBFF) {
368
+ const next = value.charCodeAt(index + 1)
369
+ if (next >= 0xDC00 && next <= 0xDFFF) {
370
+ result += value.slice(index, index + 2)
371
+ index += 1
372
+ } else result += "\uFFFD"
373
+ } else if (codeUnit >= 0xDC00 && codeUnit <= 0xDFFF) result += "\uFFFD"
374
+ else result += value.slice(index, index + 1)
375
+ }
376
+ return result
377
+ }