@namzu/sdk 50.1.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.
- package/CHANGELOG.md +18 -0
- package/dist/execution/code-runtime/types.d.ts +35 -0
- package/dist/execution/code-runtime/types.d.ts.map +1 -1
- package/dist/execution/code-runtime/types.js.map +1 -1
- package/dist/execution/code-runtime/worker-program.d.ts +1 -1
- package/dist/execution/code-runtime/worker-program.d.ts.map +1 -1
- package/dist/execution/code-runtime/worker-program.js +72 -13
- package/dist/execution/code-runtime/worker-program.js.map +1 -1
- package/dist/execution/code-runtime/worker.d.ts.map +1 -1
- package/dist/execution/code-runtime/worker.js +35 -1
- package/dist/execution/code-runtime/worker.js.map +1 -1
- package/dist/execution/workflow/errors.d.ts +5 -0
- package/dist/execution/workflow/errors.d.ts.map +1 -0
- package/dist/execution/workflow/errors.js +8 -0
- package/dist/execution/workflow/errors.js.map +1 -0
- package/dist/execution/workflow/index.d.ts +9 -0
- package/dist/execution/workflow/index.d.ts.map +1 -0
- package/dist/execution/workflow/index.js +6 -0
- package/dist/execution/workflow/index.js.map +1 -0
- package/dist/execution/workflow/journal.d.ts +18 -0
- package/dist/execution/workflow/journal.d.ts.map +1 -0
- package/dist/execution/workflow/journal.js +43 -0
- package/dist/execution/workflow/journal.js.map +1 -0
- package/dist/execution/workflow/meta.d.ts +14 -0
- package/dist/execution/workflow/meta.d.ts.map +1 -0
- package/dist/execution/workflow/meta.js +314 -0
- package/dist/execution/workflow/meta.js.map +1 -0
- package/dist/execution/workflow/phases.d.ts +32 -0
- package/dist/execution/workflow/phases.d.ts.map +1 -0
- package/dist/execution/workflow/phases.js +57 -0
- package/dist/execution/workflow/phases.js.map +1 -0
- package/dist/execution/workflow/prelude.d.ts +15 -0
- package/dist/execution/workflow/prelude.d.ts.map +1 -0
- package/dist/execution/workflow/prelude.js +128 -0
- package/dist/execution/workflow/prelude.js.map +1 -0
- package/dist/execution/workflow/runtime.d.ts +31 -0
- package/dist/execution/workflow/runtime.d.ts.map +1 -0
- package/dist/execution/workflow/runtime.js +682 -0
- package/dist/execution/workflow/runtime.js.map +1 -0
- package/dist/execution/workflow/schema.d.ts +10 -0
- package/dist/execution/workflow/schema.d.ts.map +1 -0
- package/dist/execution/workflow/schema.js +145 -0
- package/dist/execution/workflow/schema.js.map +1 -0
- package/dist/execution/workflow/types.d.ts +267 -0
- package/dist/execution/workflow/types.d.ts.map +1 -0
- package/dist/execution/workflow/types.js +7 -0
- package/dist/execution/workflow/types.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/plugin/shell-hook.d.ts.map +1 -1
- package/dist/plugin/shell-hook.js +2 -1
- package/dist/plugin/shell-hook.js.map +1 -1
- package/dist/process/kill-tree.d.ts +7 -0
- package/dist/process/kill-tree.d.ts.map +1 -1
- package/dist/process/kill-tree.js +41 -0
- package/dist/process/kill-tree.js.map +1 -1
- package/dist/provider/token-budget.d.ts.map +1 -1
- package/dist/provider/token-budget.js +31 -2
- package/dist/provider/token-budget.js.map +1 -1
- package/dist/public-runtime.d.ts +1 -0
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +3 -0
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-tools.d.ts +1 -1
- package/dist/public-tools.d.ts.map +1 -1
- package/dist/public-tools.js +1 -1
- package/dist/public-tools.js.map +1 -1
- package/dist/public-types.d.ts +3 -0
- package/dist/public-types.d.ts.map +1 -1
- package/dist/runtime/jobs/job-id.d.ts +21 -0
- package/dist/runtime/jobs/job-id.d.ts.map +1 -0
- package/dist/runtime/jobs/job-id.js +66 -0
- package/dist/runtime/jobs/job-id.js.map +1 -0
- package/dist/runtime/jobs/registry.d.ts +0 -1
- package/dist/runtime/jobs/registry.d.ts.map +1 -1
- package/dist/runtime/jobs/registry.js +2 -3
- package/dist/runtime/jobs/registry.js.map +1 -1
- package/dist/runtime/query/prompt.d.ts.map +1 -1
- package/dist/runtime/query/prompt.js +5 -1
- package/dist/runtime/query/prompt.js.map +1 -1
- package/dist/tools/builtins/bash.d.ts +8 -1
- package/dist/tools/builtins/bash.d.ts.map +1 -1
- package/dist/tools/builtins/bash.js +20 -3
- package/dist/tools/builtins/bash.js.map +1 -1
- package/dist/tools/builtins/job.d.ts.map +1 -1
- package/dist/tools/builtins/job.js +4 -0
- package/dist/tools/builtins/job.js.map +1 -1
- package/dist/tools/builtins/wait-for-job.d.ts.map +1 -1
- package/dist/tools/builtins/wait-for-job.js +4 -0
- package/dist/tools/builtins/wait-for-job.js.map +1 -1
- package/dist/tools/command-shell.d.ts +55 -0
- package/dist/tools/command-shell.d.ts.map +1 -1
- package/dist/tools/command-shell.js +174 -13
- package/dist/tools/command-shell.js.map +1 -1
- package/package.json +1 -1
- package/src/agents/AGENTS.md +134 -46
- package/src/execution/code-runtime/types.ts +37 -1
- package/src/execution/code-runtime/worker-program.ts +72 -13
- package/src/execution/code-runtime/worker.ts +40 -1
- package/src/execution/workflow/errors.ts +7 -0
- package/src/execution/workflow/index.ts +8 -0
- package/src/execution/workflow/journal.ts +46 -0
- package/src/execution/workflow/meta.ts +316 -0
- package/src/execution/workflow/phases.ts +91 -0
- package/src/execution/workflow/prelude.ts +135 -0
- package/src/execution/workflow/runtime.ts +786 -0
- package/src/execution/workflow/schema.ts +137 -0
- package/src/execution/workflow/types.ts +300 -0
- package/src/index.ts +4 -0
- package/src/plugin/shell-hook.ts +2 -1
- package/src/process/kill-tree.ts +43 -0
- package/src/provider/token-budget.ts +31 -1
- package/src/public-runtime.ts +11 -0
- package/src/public-tools.ts +6 -0
- package/src/public-types.ts +3 -0
- package/src/runtime/jobs/job-id.ts +66 -0
- package/src/runtime/jobs/registry.ts +2 -3
- package/src/runtime/query/prompt.ts +5 -1
- package/src/tools/builtins/bash.ts +33 -3
- package/src/tools/builtins/job.ts +4 -0
- package/src/tools/builtins/wait-for-job.ts +4 -0
- package/src/tools/command-shell.ts +194 -11
|
@@ -83,11 +83,47 @@ export interface RunCodeOptions {
|
|
|
83
83
|
*/
|
|
84
84
|
readonly maxOutputBytes: number
|
|
85
85
|
readonly signal?: AbortSignal
|
|
86
|
+
/**
|
|
87
|
+
* Replace the single wall-clock interrupt with a CPU budget.
|
|
88
|
+
*
|
|
89
|
+
* By default `timeoutMs` bounds synchronous interpreter execution as well
|
|
90
|
+
* as the whole run, so a program waiting on slow host calls is stopped at
|
|
91
|
+
* the same deadline as one spinning in a loop. A host that legitimately
|
|
92
|
+
* awaits long host operations (a workflow awaiting subagents) sets this:
|
|
93
|
+
* the interpreter is then interrupted when ONE synchronous slice (the time
|
|
94
|
+
* between entering the interpreter and handing control back) exceeds
|
|
95
|
+
* `sliceMs`, or all slices together exceed `totalMs`. Time spent awaiting
|
|
96
|
+
* the host is not counted. `timeoutMs` still bounds the run's wall clock.
|
|
97
|
+
*/
|
|
98
|
+
readonly cpuLimits?: { readonly sliceMs: number; readonly totalMs: number }
|
|
99
|
+
/**
|
|
100
|
+
* Called with each line the program prints, in order, as it is printed and
|
|
101
|
+
* within `maxOutputBytes`. A line is data, not a capability: the callback
|
|
102
|
+
* must treat it as untrusted text.
|
|
103
|
+
*/
|
|
104
|
+
readonly onOutput?: (line: string) => void
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface CodeFrame {
|
|
108
|
+
readonly line: number
|
|
109
|
+
readonly column: number
|
|
86
110
|
}
|
|
87
111
|
|
|
88
112
|
export type CodeRunOutcome =
|
|
89
113
|
| { readonly status: 'completed'; readonly result: unknown }
|
|
90
|
-
| {
|
|
114
|
+
| {
|
|
115
|
+
readonly status: 'failed'
|
|
116
|
+
readonly error: string
|
|
117
|
+
/** The thrown value's `name`, when it was an error object. */
|
|
118
|
+
readonly errorName?: string
|
|
119
|
+
/**
|
|
120
|
+
* Where the interpreter says the program failed: innermost first, line and
|
|
121
|
+
* column counted in the submitted `source` (1-based). Absent when the
|
|
122
|
+
* interpreter gave no location (a non-error was thrown, or the failure was
|
|
123
|
+
* not in the program).
|
|
124
|
+
*/
|
|
125
|
+
readonly frames?: readonly CodeFrame[]
|
|
126
|
+
}
|
|
91
127
|
| { readonly status: 'timed-out' }
|
|
92
128
|
| { readonly status: 'cancelled' }
|
|
93
129
|
|
|
@@ -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
|
-
|
|
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 =
|
|
187
|
+
const error = failureError(jobs.error)
|
|
133
188
|
jobs.error.dispose()
|
|
134
|
-
throw
|
|
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 =
|
|
199
|
+
try { error = failureError(state.error) }
|
|
145
200
|
finally { state.error.dispose() }
|
|
146
|
-
fail(
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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: {
|
|
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,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
|
+
}
|