@namzu/sdk 50.0.0 → 51.0.0

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 (133) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/execution/code-runtime/types.d.ts +35 -0
  3. package/dist/execution/code-runtime/types.d.ts.map +1 -1
  4. package/dist/execution/code-runtime/types.js.map +1 -1
  5. package/dist/execution/code-runtime/worker-program.d.ts +1 -1
  6. package/dist/execution/code-runtime/worker-program.d.ts.map +1 -1
  7. package/dist/execution/code-runtime/worker-program.js +72 -13
  8. package/dist/execution/code-runtime/worker-program.js.map +1 -1
  9. package/dist/execution/code-runtime/worker.d.ts.map +1 -1
  10. package/dist/execution/code-runtime/worker.js +35 -1
  11. package/dist/execution/code-runtime/worker.js.map +1 -1
  12. package/dist/execution/workflow/errors.d.ts +5 -0
  13. package/dist/execution/workflow/errors.d.ts.map +1 -0
  14. package/dist/execution/workflow/errors.js +8 -0
  15. package/dist/execution/workflow/errors.js.map +1 -0
  16. package/dist/execution/workflow/index.d.ts +9 -0
  17. package/dist/execution/workflow/index.d.ts.map +1 -0
  18. package/dist/execution/workflow/index.js +6 -0
  19. package/dist/execution/workflow/index.js.map +1 -0
  20. package/dist/execution/workflow/journal.d.ts +18 -0
  21. package/dist/execution/workflow/journal.d.ts.map +1 -0
  22. package/dist/execution/workflow/journal.js +43 -0
  23. package/dist/execution/workflow/journal.js.map +1 -0
  24. package/dist/execution/workflow/meta.d.ts +14 -0
  25. package/dist/execution/workflow/meta.d.ts.map +1 -0
  26. package/dist/execution/workflow/meta.js +314 -0
  27. package/dist/execution/workflow/meta.js.map +1 -0
  28. package/dist/execution/workflow/phases.d.ts +32 -0
  29. package/dist/execution/workflow/phases.d.ts.map +1 -0
  30. package/dist/execution/workflow/phases.js +57 -0
  31. package/dist/execution/workflow/phases.js.map +1 -0
  32. package/dist/execution/workflow/prelude.d.ts +15 -0
  33. package/dist/execution/workflow/prelude.d.ts.map +1 -0
  34. package/dist/execution/workflow/prelude.js +128 -0
  35. package/dist/execution/workflow/prelude.js.map +1 -0
  36. package/dist/execution/workflow/runtime.d.ts +31 -0
  37. package/dist/execution/workflow/runtime.d.ts.map +1 -0
  38. package/dist/execution/workflow/runtime.js +682 -0
  39. package/dist/execution/workflow/runtime.js.map +1 -0
  40. package/dist/execution/workflow/schema.d.ts +10 -0
  41. package/dist/execution/workflow/schema.d.ts.map +1 -0
  42. package/dist/execution/workflow/schema.js +145 -0
  43. package/dist/execution/workflow/schema.js.map +1 -0
  44. package/dist/execution/workflow/types.d.ts +267 -0
  45. package/dist/execution/workflow/types.d.ts.map +1 -0
  46. package/dist/execution/workflow/types.js +7 -0
  47. package/dist/execution/workflow/types.js.map +1 -0
  48. package/dist/index.d.ts +1 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +1 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/plugin/shell-hook.d.ts.map +1 -1
  53. package/dist/plugin/shell-hook.js +2 -1
  54. package/dist/plugin/shell-hook.js.map +1 -1
  55. package/dist/process/kill-tree.d.ts +7 -0
  56. package/dist/process/kill-tree.d.ts.map +1 -1
  57. package/dist/process/kill-tree.js +41 -0
  58. package/dist/process/kill-tree.js.map +1 -1
  59. package/dist/provider/strict-schema.d.ts +28 -0
  60. package/dist/provider/strict-schema.d.ts.map +1 -1
  61. package/dist/provider/strict-schema.js +86 -0
  62. package/dist/provider/strict-schema.js.map +1 -1
  63. package/dist/provider/token-budget.d.ts.map +1 -1
  64. package/dist/provider/token-budget.js +31 -2
  65. package/dist/provider/token-budget.js.map +1 -1
  66. package/dist/public-runtime.d.ts +2 -1
  67. package/dist/public-runtime.d.ts.map +1 -1
  68. package/dist/public-runtime.js +4 -1
  69. package/dist/public-runtime.js.map +1 -1
  70. package/dist/public-tools.d.ts +1 -1
  71. package/dist/public-tools.d.ts.map +1 -1
  72. package/dist/public-tools.js +1 -1
  73. package/dist/public-tools.js.map +1 -1
  74. package/dist/public-types.d.ts +3 -0
  75. package/dist/public-types.d.ts.map +1 -1
  76. package/dist/runtime/jobs/job-id.d.ts +21 -0
  77. package/dist/runtime/jobs/job-id.d.ts.map +1 -0
  78. package/dist/runtime/jobs/job-id.js +66 -0
  79. package/dist/runtime/jobs/job-id.js.map +1 -0
  80. package/dist/runtime/jobs/registry.d.ts +0 -1
  81. package/dist/runtime/jobs/registry.d.ts.map +1 -1
  82. package/dist/runtime/jobs/registry.js +2 -3
  83. package/dist/runtime/jobs/registry.js.map +1 -1
  84. package/dist/runtime/query/prompt.d.ts.map +1 -1
  85. package/dist/runtime/query/prompt.js +5 -1
  86. package/dist/runtime/query/prompt.js.map +1 -1
  87. package/dist/tools/builtins/bash.d.ts +8 -1
  88. package/dist/tools/builtins/bash.d.ts.map +1 -1
  89. package/dist/tools/builtins/bash.js +20 -3
  90. package/dist/tools/builtins/bash.js.map +1 -1
  91. package/dist/tools/builtins/edit.d.ts.map +1 -1
  92. package/dist/tools/builtins/edit.js +12 -1
  93. package/dist/tools/builtins/edit.js.map +1 -1
  94. package/dist/tools/builtins/job.d.ts.map +1 -1
  95. package/dist/tools/builtins/job.js +4 -0
  96. package/dist/tools/builtins/job.js.map +1 -1
  97. package/dist/tools/builtins/wait-for-job.d.ts.map +1 -1
  98. package/dist/tools/builtins/wait-for-job.js +4 -0
  99. package/dist/tools/builtins/wait-for-job.js.map +1 -1
  100. package/dist/tools/command-shell.d.ts +55 -0
  101. package/dist/tools/command-shell.d.ts.map +1 -1
  102. package/dist/tools/command-shell.js +174 -13
  103. package/dist/tools/command-shell.js.map +1 -1
  104. package/package.json +1 -1
  105. package/src/agents/AGENTS.md +134 -46
  106. package/src/execution/code-runtime/types.ts +37 -1
  107. package/src/execution/code-runtime/worker-program.ts +72 -13
  108. package/src/execution/code-runtime/worker.ts +40 -1
  109. package/src/execution/workflow/errors.ts +7 -0
  110. package/src/execution/workflow/index.ts +8 -0
  111. package/src/execution/workflow/journal.ts +46 -0
  112. package/src/execution/workflow/meta.ts +316 -0
  113. package/src/execution/workflow/phases.ts +91 -0
  114. package/src/execution/workflow/prelude.ts +135 -0
  115. package/src/execution/workflow/runtime.ts +786 -0
  116. package/src/execution/workflow/schema.ts +137 -0
  117. package/src/execution/workflow/types.ts +300 -0
  118. package/src/index.ts +4 -0
  119. package/src/plugin/shell-hook.ts +2 -1
  120. package/src/process/kill-tree.ts +43 -0
  121. package/src/provider/strict-schema.ts +90 -0
  122. package/src/provider/token-budget.ts +31 -1
  123. package/src/public-runtime.ts +12 -0
  124. package/src/public-tools.ts +6 -0
  125. package/src/public-types.ts +3 -0
  126. package/src/runtime/jobs/job-id.ts +66 -0
  127. package/src/runtime/jobs/registry.ts +2 -3
  128. package/src/runtime/query/prompt.ts +5 -1
  129. package/src/tools/builtins/bash.ts +33 -3
  130. package/src/tools/builtins/edit.ts +12 -1
  131. package/src/tools/builtins/job.ts +4 -0
  132. package/src/tools/builtins/wait-for-job.ts +4 -0
  133. package/src/tools/command-shell.ts +194 -11
@@ -18,7 +18,33 @@ async function main() {
18
18
  const runtime = module.newRuntime()
19
19
  runtime.setMemoryLimit(limits.memoryLimitBytes)
20
20
  runtime.setMaxStackSize(512 * 1024)
21
- runtime.setInterruptHandler(() => Date.now() >= workerData.deadline)
21
+ // Two ways to bound a program's synchronous execution. Default: one wall
22
+ // deadline for the whole run. With \`cpu\`: a budget per synchronous slice
23
+ // (each entry into the interpreter) and a total across slices, so a program
24
+ // that spends hours AWAITING the host is not stopped, but a busy loop is.
25
+ const cpu = workerData.cpu
26
+ let sliceDepth = 0
27
+ let sliceStart = 0
28
+ let cpuUsed = 0
29
+ let interrupted = false
30
+ function inSlice(fn) {
31
+ if (!cpu) return fn()
32
+ if (sliceDepth === 0) sliceStart = Date.now()
33
+ sliceDepth++
34
+ try { return fn() }
35
+ finally {
36
+ sliceDepth--
37
+ if (sliceDepth === 0) cpuUsed += Date.now() - sliceStart
38
+ }
39
+ }
40
+ runtime.setInterruptHandler(() => {
41
+ const now = Date.now()
42
+ const stop = cpu
43
+ ? sliceDepth > 0 && (now - sliceStart >= cpu.sliceMs || cpuUsed + (now - sliceStart) >= cpu.totalMs)
44
+ : now >= workerData.deadline
45
+ if (stop) interrupted = true
46
+ return stop
47
+ })
22
48
  const vm = runtime.newContext()
23
49
  const pending = new Map()
24
50
  let nextId = 0
@@ -68,6 +94,8 @@ async function main() {
68
94
  const decode = keep(vm.evalCode('(function () { const parse = JSON.parse; return function(value) { return parse(value); }; })()'))
69
95
  const errorText = keep(vm.evalCode('(function () { const string = String; return function(error) { try { return typeof error.message === "string" ? error.message : string(error); } catch { return "The program failed."; } }; })()'))
70
96
 
97
+ const errorMeta = keep(vm.evalCode('(function () { const string = String; const stringify = JSON.stringify; return function(error) { try { return stringify({ name: typeof error.name === "string" ? error.name : "", stack: typeof error.stack === "string" ? error.stack : "" }); } catch { return "{}"; } }; })()'))
98
+
71
99
  function textValue(handle, maximum) {
72
100
  const length = vm.getProp(handle, 'length')
73
101
  try {
@@ -97,6 +125,24 @@ async function main() {
97
125
  catch { return 'The program failed with an oversized error.' }
98
126
  finally { result.value.dispose() }
99
127
  }
128
+ // The guest error's message plus its name and stack, so the host can say
129
+ // where in the program it failed. Stack text is untrusted and only parsed
130
+ // for line and column numbers.
131
+ function failureError(handle) {
132
+ const error = new Error(failureText(handle))
133
+ try {
134
+ const result = vm.callFunction(errorMeta, vm.undefined, handle)
135
+ if (result.error) result.error.dispose()
136
+ else {
137
+ try {
138
+ const meta = JSON.parse(textValue(result.value, 4096))
139
+ error.guestName = String(meta.name || '').slice(0, 64)
140
+ error.guestStack = String(meta.stack || '').slice(0, 3072)
141
+ } finally { result.value.dispose() }
142
+ }
143
+ } catch {}
144
+ return error
145
+ }
100
146
  function dispose() {
101
147
  for (const deferred of pending.values()) deferred.dispose()
102
148
  pending.clear()
@@ -114,9 +160,9 @@ async function main() {
114
160
  parentPort.postMessage(message)
115
161
  }
116
162
  function fail(error) {
117
- finish(Date.now() >= workerData.deadline
163
+ finish(interrupted || Date.now() >= workerData.deadline
118
164
  ? { kind: 'timed-out' }
119
- : { kind: 'error', error: String(error && error.message || error).slice(0, 4096) })
165
+ : { kind: 'error', error: String(error && error.message || error).slice(0, 4096), name: error && error.guestName, stack: error && error.guestStack })
120
166
  }
121
167
  function schedule() {
122
168
  if (ended || scheduled) return
@@ -125,13 +171,22 @@ async function main() {
125
171
  }
126
172
  function pump() {
127
173
  if (ended || pumping) return
174
+ inSlice(pumpOnce)
175
+ }
176
+ function pumpOnce() {
128
177
  pumping = true
129
178
  try {
179
+ // A program that never lets one slice run long (a loop of awaited
180
+ // microtasks) is still bounded by the total CPU budget.
181
+ if (cpu && cpuUsed >= cpu.totalMs) {
182
+ interrupted = true
183
+ throw new Error('The program used its CPU budget.')
184
+ }
130
185
  const jobs = runtime.executePendingJobs(32)
131
186
  if (jobs.error) {
132
- const error = failureText(jobs.error)
187
+ const error = failureError(jobs.error)
133
188
  jobs.error.dispose()
134
- throw new Error(error)
189
+ throw error
135
190
  }
136
191
  const state = vm.getPromiseState(program)
137
192
  if (state.type === 'fulfilled') {
@@ -141,9 +196,9 @@ async function main() {
141
196
  finish({ kind: 'done', json })
142
197
  } else if (state.type === 'rejected') {
143
198
  let error
144
- try { error = failureText(state.error) }
199
+ try { error = failureError(state.error) }
145
200
  finally { state.error.dispose() }
146
- fail(new Error(error))
201
+ fail(error)
147
202
  } else if (runtime.hasPendingJob()) schedule()
148
203
  } catch (error) { fail(error) }
149
204
  finally { pumping = false }
@@ -207,6 +262,7 @@ async function main() {
207
262
  if (!deferred) return
208
263
  pending.delete(message.id)
209
264
  try {
265
+ inSlice(() => {
210
266
  if (message.ok) {
211
267
  let value = vm.undefined
212
268
  if (message.json !== undefined) {
@@ -223,16 +279,19 @@ async function main() {
223
279
  error.dispose()
224
280
  }
225
281
  deferred.dispose()
282
+ })
226
283
  schedule()
227
284
  } catch (error) { deferred.dispose(); fail(error) }
228
285
  })
229
286
  try {
230
- const result = vm.evalCode('(async () => {\\n' + workerData.source + '\\n})()', 'model-program.js')
231
- if (result.error) {
232
- const error = failureText(result.error)
233
- result.error.dispose()
234
- fail(new Error(error))
235
- } else { program = result.value; pump() }
287
+ inSlice(() => {
288
+ const result = vm.evalCode('(async () => {\\n' + workerData.source + '\\n})()', 'model-program.js')
289
+ if (result.error) {
290
+ const error = failureError(result.error)
291
+ result.error.dispose()
292
+ fail(error)
293
+ } else { program = result.value; pump() }
294
+ })
236
295
  } catch (error) { fail(error) }
237
296
  }
238
297
  main().catch((error) => parentPort.postMessage({ kind: 'error', error: String(error && error.message || error).slice(0, 4096) }))
@@ -3,6 +3,7 @@ import { Worker } from 'node:worker_threads'
3
3
 
4
4
  import { encodeCodeValue } from './json-value.js'
5
5
  import {
6
+ type CodeFrame,
6
7
  type CodeRunResult,
7
8
  type CodeRuntime,
8
9
  HostCallDeniedError,
@@ -10,6 +11,30 @@ import {
10
11
  } from './types.js'
11
12
  import { QUICKJS_WORKER_SOURCE } from './worker-program.js'
12
13
 
14
+ /** The worker wraps the program in `(async () => {` plus a newline: one line. */
15
+ const WRAPPER_LINES = 1
16
+ const FRAME_PATTERN = /model-program\.js:(\d+):(\d+)/g
17
+
18
+ function failureLocation(
19
+ name: unknown,
20
+ stack: unknown,
21
+ sourceLines: number,
22
+ ): { errorName?: string; frames?: readonly CodeFrame[] } {
23
+ const frames: CodeFrame[] = []
24
+ if (typeof stack === 'string') {
25
+ for (const match of stack.matchAll(FRAME_PATTERN)) {
26
+ const line = Number(match[1]) - WRAPPER_LINES
27
+ // The wrapper's own lines (its opening, and the closing after the source)
28
+ // are not in what the caller submitted.
29
+ if (line >= 1 && line <= sourceLines) frames.push({ line, column: Number(match[2]) })
30
+ }
31
+ }
32
+ return {
33
+ ...(typeof name === 'string' && name !== '' ? { errorName: name } : {}),
34
+ ...(frames.length > 0 ? { frames } : {}),
35
+ }
36
+ }
37
+
13
38
  const requireFromSdk = createRequire(import.meta.url)
14
39
 
15
40
  /** Resource policy for one fresh QuickJS worker execution. */
@@ -74,6 +99,10 @@ export class WorkerCodeRuntime implements CodeRuntime {
74
99
  async run(options: RunCodeOptions): Promise<CodeRunResult> {
75
100
  validateInteger('timeoutMs', options.timeoutMs, 1, 2_147_483_647)
76
101
  validateInteger('maxOutputBytes', options.maxOutputBytes, 0, 16 * 1024 * 1024)
102
+ if (options.cpuLimits) {
103
+ validateInteger('cpuLimits.sliceMs', options.cpuLimits.sliceMs, 1, 2_147_483_647)
104
+ validateInteger('cpuLimits.totalMs', options.cpuLimits.totalMs, 1, 2_147_483_647)
105
+ }
77
106
  const allowed = new Set(options.allowedCalls)
78
107
  const calls: { name: string; ok: boolean }[] = []
79
108
  let output = ''
@@ -120,6 +149,11 @@ export class WorkerCodeRuntime implements CodeRuntime {
120
149
  }
121
150
  output = next
122
151
  printCount++
152
+ try {
153
+ options.onOutput?.(line)
154
+ } catch {
155
+ // A listener bug must not decide the program's outcome.
156
+ }
123
157
  }
124
158
  }
125
159
 
@@ -131,6 +165,7 @@ export class WorkerCodeRuntime implements CodeRuntime {
131
165
  variantPath: requireFromSdk.resolve('@jitl/quickjs-wasmfile-release-sync'),
132
166
  limits: this.limits,
133
167
  maxOutputBytes: options.maxOutputBytes,
168
+ cpu: options.cpuLimits,
134
169
  deadline: Date.now() + options.timeoutMs,
135
170
  },
136
171
  // Trusted bootstrap requires Node; guest code runs only inside QuickJS.
@@ -344,7 +379,11 @@ export class WorkerCodeRuntime implements CodeRuntime {
344
379
 
345
380
  if (message.kind === 'error') {
346
381
  finish({
347
- outcome: { status: 'failed', error: String(message.error) },
382
+ outcome: {
383
+ status: 'failed',
384
+ error: String(message.error),
385
+ ...failureLocation(message.name, message.stack, options.source.split('\n').length),
386
+ },
348
387
  output,
349
388
  outputTruncated: truncated,
350
389
  calls,
@@ -0,0 +1,7 @@
1
+ /** A script that cannot run: bad `meta`, too large, bad `args`. Nothing started. */
2
+ export class WorkflowScriptError extends Error {
3
+ constructor(message: string) {
4
+ super(message)
5
+ this.name = 'WorkflowScriptError'
6
+ }
7
+ }
@@ -0,0 +1,8 @@
1
+ export { WorkflowScriptError } from './errors.js'
2
+ export { FileWorkflowJournal } from './journal.js'
3
+ export { parseWorkflowMeta } from './meta.js'
4
+ export { summarizeWorkflowPhases } from './phases.js'
5
+ export type { PhaseAgentView, PhaseRecord } from './phases.js'
6
+ export { resolveWorkflowLimits, WORKFLOW_HOST_CALL, WorkflowRuntime } from './runtime.js'
7
+ export type { WorkflowRuntimeOptions } from './runtime.js'
8
+ export type * from './types.js'
@@ -0,0 +1,46 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { appendFile, mkdir, writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+
5
+ import type { WorkflowJournal, WorkflowJournalRecord } from './types.js'
6
+
7
+ export function sha256(text: string): string {
8
+ return createHash('sha256').update(text).digest('hex')
9
+ }
10
+
11
+ /** The first `max` UTF-16 units of `text`, never ending inside a surrogate pair. */
12
+ export function preview(text: string, max = 500): string {
13
+ if (text.length <= max) return text
14
+ let end = max
15
+ const last = text.charCodeAt(end - 1)
16
+ if (last >= 0xd800 && last <= 0xdbff) end--
17
+ return `${text.slice(0, end)}…`
18
+ }
19
+
20
+ /**
21
+ * A journal in a directory: `script.js` (the exact script text, mode 0600)
22
+ * and `journal.jsonl` (one JSON record per line, append-only). The caller
23
+ * chooses the directory, normally `<session>/runs/<runId>/workflow/`.
24
+ */
25
+ export class FileWorkflowJournal implements WorkflowJournal {
26
+ private ready: Promise<void> | undefined
27
+
28
+ constructor(private readonly dir: string) {}
29
+
30
+ private ensure(): Promise<void> {
31
+ this.ready ??= mkdir(this.dir, { recursive: true, mode: 0o700 }).then(() => undefined)
32
+ return this.ready
33
+ }
34
+
35
+ async writeScript(source: string): Promise<void> {
36
+ await this.ensure()
37
+ await writeFile(join(this.dir, 'script.js'), source, { mode: 0o600 })
38
+ }
39
+
40
+ async append(record: WorkflowJournalRecord): Promise<void> {
41
+ await this.ensure()
42
+ await appendFile(join(this.dir, 'journal.jsonl'), `${JSON.stringify(record)}\n`, {
43
+ mode: 0o600,
44
+ })
45
+ }
46
+ }
@@ -0,0 +1,316 @@
1
+ import { WorkflowScriptError } from './errors.js'
2
+ import type { WorkflowMeta, WorkflowPhaseMeta } from './types.js'
3
+
4
+ const MAX_META_BYTES = 16 * 1024
5
+ const MAX_DEPTH = 8
6
+ const MAX_PHASES = 50
7
+ const KEYWORD = /export\s+const\s+meta\s*=/y
8
+
9
+ type Literal = string | number | boolean | null | Literal[] | { [key: string]: Literal }
10
+
11
+ /**
12
+ * Reads the `export const meta = {...}` literal WITHOUT running the script.
13
+ *
14
+ * The statement must be the first one in the file (comments and blank lines
15
+ * before it are fine) and its value must be a pure literal: strings, numbers,
16
+ * booleans, null, arrays and objects. Variables, calls, spreads, template
17
+ * substitutions and computed keys are refused with a plain message, because
18
+ * the operator reviews this literal BEFORE the script is allowed to run.
19
+ */
20
+ class LiteralParser {
21
+ pos = 0
22
+
23
+ constructor(private readonly source: string) {}
24
+
25
+ fail(what: string): never {
26
+ const before = this.source.slice(0, this.pos)
27
+ const line = before.split('\n').length
28
+ const column = this.pos - before.lastIndexOf('\n')
29
+ throw new WorkflowScriptError(`${what} (line ${line}, column ${column}).`)
30
+ }
31
+
32
+ skipTrivia(): void {
33
+ const s = this.source
34
+ for (;;) {
35
+ const c = s[this.pos]
36
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '') this.pos++
37
+ else if (c === '/' && s[this.pos + 1] === '/') {
38
+ while (this.pos < s.length && s[this.pos] !== '\n') this.pos++
39
+ } else if (c === '/' && s[this.pos + 1] === '*') {
40
+ const end = s.indexOf('*/', this.pos + 2)
41
+ if (end === -1) this.fail('Unterminated comment')
42
+ this.pos = end + 2
43
+ } else return
44
+ }
45
+ }
46
+
47
+ private describe(): string {
48
+ const snippet = this.source.slice(this.pos, this.pos + 24).split('\n')[0]
49
+ return snippet ? `"${snippet}"` : 'the end of the script'
50
+ }
51
+
52
+ private notLiteral(): never {
53
+ return this.fail(
54
+ `\`meta\` must be a pure literal (no variables, calls, spreads or expressions); found ${this.describe()}`,
55
+ )
56
+ }
57
+
58
+ value(depth: number): Literal {
59
+ if (depth > MAX_DEPTH) this.fail('`meta` is nested too deeply')
60
+ this.skipTrivia()
61
+ const c = this.source[this.pos]
62
+ if (c === '{') return this.object(depth)
63
+ if (c === '[') return this.array(depth)
64
+ if (c === '"' || c === "'") return this.string(c)
65
+ if (c === '`') return this.string('`')
66
+ if (c === '-' || c === '.' || (c !== undefined && c >= '0' && c <= '9')) return this.number()
67
+ for (const [word, value] of [
68
+ ['true', true],
69
+ ['false', false],
70
+ ['null', null],
71
+ ] as const) {
72
+ if (
73
+ this.source.startsWith(word, this.pos) &&
74
+ !/[\w$]/.test(this.source[this.pos + word.length] ?? '')
75
+ ) {
76
+ this.pos += word.length
77
+ return value
78
+ }
79
+ }
80
+ return this.notLiteral()
81
+ }
82
+
83
+ private number(): number {
84
+ const match = /-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/y
85
+ match.lastIndex = this.pos
86
+ const found = match.exec(this.source)
87
+ if (!found) return this.notLiteral()
88
+ this.pos += found[0].length
89
+ if (/[\w$]/.test(this.source[this.pos] ?? '')) return this.notLiteral()
90
+ return Number(found[0])
91
+ }
92
+
93
+ private string(quote: string): string {
94
+ const s = this.source
95
+ this.pos++
96
+ let out = ''
97
+ for (;;) {
98
+ const c = s[this.pos]
99
+ if (c === undefined) return this.fail('Unterminated string in `meta`')
100
+ if (c === quote) {
101
+ this.pos++
102
+ return out
103
+ }
104
+ if (c === '\n' && quote !== '`') return this.fail('Unterminated string in `meta`')
105
+ if (quote === '`' && c === '$' && s[this.pos + 1] === '{') {
106
+ return this.fail('`meta` strings cannot use `${...}` substitutions')
107
+ }
108
+ if (c !== '\\') {
109
+ out += c
110
+ this.pos++
111
+ continue
112
+ }
113
+ const e = s[this.pos + 1]
114
+ this.pos += 2
115
+ const simple: Record<string, string> = {
116
+ n: '\n',
117
+ t: '\t',
118
+ r: '\r',
119
+ b: '\b',
120
+ f: '\f',
121
+ v: '\v',
122
+ '0': '\0',
123
+ '\\': '\\',
124
+ "'": "'",
125
+ '"': '"',
126
+ '`': '`',
127
+ $: '$',
128
+ }
129
+ if (e !== undefined && e in simple) out += simple[e]
130
+ else if (e === '\n') continue
131
+ else if (e === 'u' && s[this.pos] === '{') {
132
+ const end = s.indexOf('}', this.pos)
133
+ const hex = s.slice(this.pos + 1, end)
134
+ if (end === -1 || !/^[0-9a-fA-F]{1,6}$/.test(hex) || Number.parseInt(hex, 16) > 0x10ffff) {
135
+ return this.fail('Bad escape in a `meta` string')
136
+ }
137
+ out += String.fromCodePoint(Number.parseInt(hex, 16))
138
+ this.pos = end + 1
139
+ } else if (e === 'u' || e === 'x') {
140
+ const len = e === 'u' ? 4 : 2
141
+ const hex = s.slice(this.pos, this.pos + len)
142
+ if (!new RegExp(`^[0-9a-fA-F]{${len}}$`).test(hex))
143
+ return this.fail('Bad escape in a `meta` string')
144
+ out += String.fromCharCode(Number.parseInt(hex, 16))
145
+ this.pos += len
146
+ } else return this.fail('Bad escape in a `meta` string')
147
+ }
148
+ }
149
+
150
+ private array(depth: number): Literal[] {
151
+ this.pos++
152
+ const out: Literal[] = []
153
+ for (;;) {
154
+ this.skipTrivia()
155
+ if (this.source[this.pos] === ']') {
156
+ this.pos++
157
+ return out
158
+ }
159
+ out.push(this.value(depth + 1))
160
+ this.skipTrivia()
161
+ if (this.source[this.pos] === ',') this.pos++
162
+ else if (this.source[this.pos] !== ']') return this.notLiteral()
163
+ }
164
+ }
165
+
166
+ private object(depth: number): { [key: string]: Literal } {
167
+ this.pos++
168
+ const out: { [key: string]: Literal } = Object.create(null)
169
+ for (;;) {
170
+ this.skipTrivia()
171
+ const c = this.source[this.pos]
172
+ if (c === '}') {
173
+ this.pos++
174
+ return out
175
+ }
176
+ let key: string
177
+ if (c === '"' || c === "'") key = this.string(c)
178
+ else {
179
+ const ident = /[A-Za-z_$][\w$]*/y
180
+ ident.lastIndex = this.pos
181
+ const found = ident.exec(this.source)
182
+ if (!found) return this.notLiteral()
183
+ key = found[0]
184
+ this.pos += key.length
185
+ }
186
+ if (key === '__proto__') this.fail('`meta` cannot define `__proto__`')
187
+ if (key in out) this.fail(`\`meta\` repeats the key "${key}"`)
188
+ this.skipTrivia()
189
+ if (this.source[this.pos] !== ':') return this.notLiteral()
190
+ this.pos++
191
+ out[key] = this.value(depth + 1)
192
+ this.skipTrivia()
193
+ if (this.source[this.pos] === ',') this.pos++
194
+ else if (this.source[this.pos] !== '}') return this.notLiteral()
195
+ }
196
+ }
197
+ }
198
+
199
+ function text(
200
+ value: Literal | undefined,
201
+ field: string,
202
+ max: number,
203
+ required: boolean,
204
+ ): string | undefined {
205
+ if (value === undefined) {
206
+ if (required) throw new WorkflowScriptError(`\`meta.${field}\` is required.`)
207
+ return undefined
208
+ }
209
+ if (typeof value !== 'string' || value.trim() === '') {
210
+ throw new WorkflowScriptError(`\`meta.${field}\` must be a non-empty string.`)
211
+ }
212
+ if (value.length > max) {
213
+ throw new WorkflowScriptError(`\`meta.${field}\` is longer than ${max} characters.`)
214
+ }
215
+ return value
216
+ }
217
+
218
+ function onlyKeys(
219
+ object: { [key: string]: Literal },
220
+ allowed: readonly string[],
221
+ where: string,
222
+ ): void {
223
+ for (const key of Object.keys(object)) {
224
+ if (!allowed.includes(key)) {
225
+ throw new WorkflowScriptError(
226
+ `\`${where}\` has an unknown key "${key}". Allowed: ${allowed.join(', ')}.`,
227
+ )
228
+ }
229
+ }
230
+ }
231
+
232
+ function isRecord(value: Literal | undefined): value is { [key: string]: Literal } {
233
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
234
+ }
235
+
236
+ export function normalizePhaseTitle(value: string): string {
237
+ return value.replace(/\s+/g, ' ').trim().slice(0, 120)
238
+ }
239
+
240
+ function toMeta(literal: Literal): WorkflowMeta {
241
+ if (!isRecord(literal)) throw new WorkflowScriptError('`meta` must be an object literal.')
242
+ onlyKeys(literal, ['name', 'description', 'whenToUse', 'phases'], 'meta')
243
+ const name = text(literal.name, 'name', 80, true) as string
244
+ const description = text(literal.description, 'description', 500, true) as string
245
+ const whenToUse = text(literal.whenToUse, 'whenToUse', 500, false)
246
+ const phases: WorkflowPhaseMeta[] = []
247
+ if (literal.phases !== undefined) {
248
+ if (!Array.isArray(literal.phases)) {
249
+ throw new WorkflowScriptError('`meta.phases` must be an array of { title, detail?, model? }.')
250
+ }
251
+ if (literal.phases.length > MAX_PHASES) {
252
+ throw new WorkflowScriptError(`\`meta.phases\` lists more than ${MAX_PHASES} phases.`)
253
+ }
254
+ const seen = new Set<string>()
255
+ for (const [i, entry] of literal.phases.entries()) {
256
+ if (!isRecord(entry)) {
257
+ throw new WorkflowScriptError(`\`meta.phases[${i}]\` must be an object with a title.`)
258
+ }
259
+ onlyKeys(entry, ['title', 'detail', 'model'], `meta.phases[${i}]`)
260
+ const raw = text(entry.title, `phases[${i}].title`, 120, true) as string
261
+ const title = normalizePhaseTitle(raw)
262
+ if (seen.has(title)) {
263
+ throw new WorkflowScriptError(`\`meta.phases\` repeats the title "${title}".`)
264
+ }
265
+ seen.add(title)
266
+ const detail = text(entry.detail, `phases[${i}].detail`, 300, false)
267
+ const model = text(entry.model, `phases[${i}].model`, 120, false)
268
+ phases.push({
269
+ title,
270
+ ...(detail !== undefined ? { detail } : {}),
271
+ ...(model !== undefined ? { model } : {}),
272
+ })
273
+ }
274
+ }
275
+ return {
276
+ name,
277
+ description,
278
+ ...(whenToUse !== undefined ? { whenToUse } : {}),
279
+ phases,
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Split a script into its `meta` and its body. The body keeps the script's
285
+ * line breaks (the meta statement is blanked, not removed) so an error line
286
+ * still points at the author's text.
287
+ */
288
+ export function splitWorkflowScript(source: string): { meta: WorkflowMeta; body: string } {
289
+ const parser = new LiteralParser(source)
290
+ parser.skipTrivia()
291
+ KEYWORD.lastIndex = parser.pos
292
+ const found = KEYWORD.exec(source)
293
+ if (!found) {
294
+ throw new WorkflowScriptError(
295
+ 'A workflow script must start with `export const meta = { name, description, phases? }`.',
296
+ )
297
+ }
298
+ const start = parser.pos
299
+ parser.pos = start + found[0].length
300
+ const literal = parser.value(0)
301
+ parser.skipTrivia()
302
+ if (source[parser.pos] === ';') parser.pos++
303
+ if (Buffer.byteLength(source.slice(start, parser.pos)) > MAX_META_BYTES) {
304
+ throw new WorkflowScriptError(`\`meta\` is larger than ${MAX_META_BYTES} bytes.`)
305
+ }
306
+ const meta = toMeta(literal)
307
+ return {
308
+ meta,
309
+ body: source.slice(0, parser.pos).replace(/[^\n]/g, ' ') + source.slice(parser.pos),
310
+ }
311
+ }
312
+
313
+ /** Parse `meta` from a script without executing it. Throws {@link WorkflowScriptError}. */
314
+ export function parseWorkflowMeta(source: string): WorkflowMeta {
315
+ return splitWorkflowScript(source).meta
316
+ }