@nicknisi/pi-workflows 0.1.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/LICENSE +21 -0
- package/README.md +86 -0
- package/dist/engine.d.ts +102 -0
- package/dist/engine.js +167 -0
- package/dist/examples.test.d.ts +9 -0
- package/dist/examples.test.js +35 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +532 -0
- package/dist/workflow.test.d.ts +10 -0
- package/dist/workflow.test.js +299 -0
- package/engine.ts +295 -0
- package/index.ts +618 -0
- package/package.json +41 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the workflow engine (pi-free) and saved-workflow discovery.
|
|
3
|
+
*
|
|
4
|
+
* Style follows packages/shared/subagents.test.ts: a fake spawn with no real
|
|
5
|
+
* child sessions, and tmp dirs for filesystem discovery. The vm/parallel/
|
|
6
|
+
* pipeline/args/budget coverage exercises engine.ts directly; discovery
|
|
7
|
+
* coverage imports listWorkflows/findWorkflowFile from index.ts and points
|
|
8
|
+
* PI_CODING_AGENT_DIR at a tmp dir so getAgentDir() resolves there.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from 'node:fs';
|
|
11
|
+
import * as os from 'node:os';
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
14
|
+
import { runScript } from './engine.js';
|
|
15
|
+
const tmpdirs = [];
|
|
16
|
+
function tmpRoot() {
|
|
17
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-workflows-test-'));
|
|
18
|
+
tmpdirs.push(dir);
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
for (const dir of tmpdirs.splice(0))
|
|
23
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
// ── Fake spawn ─────────────────────────────────────────────────────────────
|
|
26
|
+
// Echoes the prompt as text; carries a fixed token cost so budget accounting
|
|
27
|
+
// is observable. An `impl` override lets individual cases craft outcomes.
|
|
28
|
+
function fakeSpawn(impl) {
|
|
29
|
+
return async (opts) => impl
|
|
30
|
+
? impl(opts)
|
|
31
|
+
: {
|
|
32
|
+
ok: true,
|
|
33
|
+
text: `echo: ${opts.prompt}`,
|
|
34
|
+
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function fakeFail(kind, error) {
|
|
38
|
+
return async () => ({
|
|
39
|
+
ok: false,
|
|
40
|
+
kind,
|
|
41
|
+
error,
|
|
42
|
+
text: '',
|
|
43
|
+
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
describe('engine: meta export + vm compile', () => {
|
|
47
|
+
it('strips `export const meta =` and surfaces meta.name/description', async () => {
|
|
48
|
+
const r = await runScript({
|
|
49
|
+
script: "export const meta = { name: 'demo', description: 'a demo' };\nreturn meta.name;",
|
|
50
|
+
spawn: fakeSpawn(),
|
|
51
|
+
cwd: '/tmp',
|
|
52
|
+
});
|
|
53
|
+
expect(r.meta?.name).toBe('demo');
|
|
54
|
+
expect(r.meta?.description).toBe('a demo');
|
|
55
|
+
expect(r.value).toBe('demo');
|
|
56
|
+
});
|
|
57
|
+
it('compiles a top-level return (wrapped in an async function)', async () => {
|
|
58
|
+
const r = await runScript({
|
|
59
|
+
script: 'const x = 21; return x * 2;',
|
|
60
|
+
spawn: fakeSpawn(),
|
|
61
|
+
cwd: '/tmp',
|
|
62
|
+
});
|
|
63
|
+
expect(r.value).toBe(42);
|
|
64
|
+
expect(r.meta).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
it('returns undefined when the script forgets to return', async () => {
|
|
67
|
+
const r = await runScript({
|
|
68
|
+
script: 'const x = 1;',
|
|
69
|
+
spawn: fakeSpawn(),
|
|
70
|
+
cwd: '/tmp',
|
|
71
|
+
});
|
|
72
|
+
expect(r.value).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
it('multi-line meta object is captured whole', async () => {
|
|
75
|
+
const r = await runScript({
|
|
76
|
+
script: [
|
|
77
|
+
'export const meta = {',
|
|
78
|
+
" name: 'lanes',",
|
|
79
|
+
" description: 'parallel lanes',",
|
|
80
|
+
' phases: [{ title: "Execute" }],',
|
|
81
|
+
'};',
|
|
82
|
+
'return meta.phases.length;',
|
|
83
|
+
].join('\n'),
|
|
84
|
+
spawn: fakeSpawn(),
|
|
85
|
+
cwd: '/tmp',
|
|
86
|
+
});
|
|
87
|
+
expect(r.meta?.name).toBe('lanes');
|
|
88
|
+
expect(r.meta?.phases).toEqual([{ title: 'Execute' }]);
|
|
89
|
+
expect(r.value).toBe(1);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
describe('engine: parallel + pipeline', () => {
|
|
93
|
+
it('parallel awaits Promise.all over zero-arg thunks', async () => {
|
|
94
|
+
const r = await runScript({
|
|
95
|
+
script: 'const r = await parallel([() => agent("a"), () => agent("b")]); return r;',
|
|
96
|
+
spawn: fakeSpawn(),
|
|
97
|
+
cwd: '/tmp',
|
|
98
|
+
});
|
|
99
|
+
expect(r.value).toEqual(['echo: a', 'echo: b']);
|
|
100
|
+
});
|
|
101
|
+
it('pipeline folds items through stages in parallel waves', async () => {
|
|
102
|
+
const r = await runScript({
|
|
103
|
+
script: 'const out = await pipeline([1, 2], async (n) => n * 2, async (n) => n + 1); return out;',
|
|
104
|
+
spawn: fakeSpawn(),
|
|
105
|
+
cwd: '/tmp',
|
|
106
|
+
});
|
|
107
|
+
expect(r.value).toEqual([3, 5]);
|
|
108
|
+
});
|
|
109
|
+
it('pipeline with zero items returns an empty array (no stranded stage)', async () => {
|
|
110
|
+
const r = await runScript({
|
|
111
|
+
script: 'const out = await pipeline([], async (n) => n); return out;',
|
|
112
|
+
spawn: fakeSpawn(),
|
|
113
|
+
cwd: '/tmp',
|
|
114
|
+
});
|
|
115
|
+
expect(r.value).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
describe('engine: args + budget + log/phase', () => {
|
|
119
|
+
it('passes args through as a global', async () => {
|
|
120
|
+
const r = await runScript({
|
|
121
|
+
script: 'return args.projectName;',
|
|
122
|
+
args: { projectName: 'foo', phases: [] },
|
|
123
|
+
spawn: fakeSpawn(),
|
|
124
|
+
cwd: '/tmp',
|
|
125
|
+
});
|
|
126
|
+
expect(r.value).toBe('foo');
|
|
127
|
+
});
|
|
128
|
+
it('budget.spent accumulates over agent() calls', async () => {
|
|
129
|
+
const r = await runScript({
|
|
130
|
+
script: 'await agent("x"); await agent("y"); return budget.spent;',
|
|
131
|
+
spawn: fakeSpawn(),
|
|
132
|
+
cwd: '/tmp',
|
|
133
|
+
});
|
|
134
|
+
expect(r.value).toBe(30);
|
|
135
|
+
expect(r.usage.totalTokens).toBe(30);
|
|
136
|
+
expect(r.usage.inputTokens).toBe(20);
|
|
137
|
+
});
|
|
138
|
+
it('budget.total defaults to Infinity and remaining tracks spent', async () => {
|
|
139
|
+
const r = await runScript({
|
|
140
|
+
script: 'await agent("x"); return { total: budget.total, remaining: budget.remaining };',
|
|
141
|
+
spawn: fakeSpawn(),
|
|
142
|
+
cwd: '/tmp',
|
|
143
|
+
});
|
|
144
|
+
expect(r.value).toEqual({ total: Infinity, remaining: Infinity });
|
|
145
|
+
});
|
|
146
|
+
it('budget.total honors a configured cap', async () => {
|
|
147
|
+
const r = await runScript({
|
|
148
|
+
script: 'return { total: budget.total, remaining: budget.remaining };',
|
|
149
|
+
spawn: fakeSpawn(),
|
|
150
|
+
cwd: '/tmp',
|
|
151
|
+
budgetTotal: 1000,
|
|
152
|
+
});
|
|
153
|
+
expect(r.value).toEqual({ total: 1000, remaining: 1000 });
|
|
154
|
+
});
|
|
155
|
+
it('log and phase capture into the result logs', async () => {
|
|
156
|
+
const r = await runScript({
|
|
157
|
+
script: 'phase("Wave 1"); log("hello", { k: 1 }); return "done";',
|
|
158
|
+
spawn: fakeSpawn(),
|
|
159
|
+
cwd: '/tmp',
|
|
160
|
+
});
|
|
161
|
+
expect(r.logs).toContain('── Wave 1');
|
|
162
|
+
expect(r.logs.some((l) => l.startsWith('hello'))).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
describe('engine: agent opts + failures', () => {
|
|
166
|
+
it('agentType is accepted but ignored (logged), no registry', async () => {
|
|
167
|
+
const r = await runScript({
|
|
168
|
+
script: "await agent('x', { agentType: 'scout' }); return 'ok';",
|
|
169
|
+
spawn: fakeSpawn(),
|
|
170
|
+
cwd: '/tmp',
|
|
171
|
+
});
|
|
172
|
+
expect(r.value).toBe('ok');
|
|
173
|
+
expect(r.logs.some((l) => l.includes("agentType 'scout'"))).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
it('label maps to the spawn agent label', async () => {
|
|
176
|
+
let seen;
|
|
177
|
+
const spawn = async (opts) => {
|
|
178
|
+
seen = opts.agent;
|
|
179
|
+
return { ok: true, text: 'ok', usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 } };
|
|
180
|
+
};
|
|
181
|
+
await runScript({
|
|
182
|
+
script: "await agent('x', { label: 'reviewer' }); return 'ok';",
|
|
183
|
+
spawn,
|
|
184
|
+
cwd: '/tmp',
|
|
185
|
+
});
|
|
186
|
+
expect(seen).toBe('reviewer');
|
|
187
|
+
});
|
|
188
|
+
it('defaults children to read-only tools when tools omitted', async () => {
|
|
189
|
+
let seen;
|
|
190
|
+
const spawn = async (opts) => {
|
|
191
|
+
seen = opts.tools;
|
|
192
|
+
return { ok: true, text: 'ok', usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 } };
|
|
193
|
+
};
|
|
194
|
+
await runScript({
|
|
195
|
+
script: "await agent('x'); return 'ok';",
|
|
196
|
+
spawn,
|
|
197
|
+
cwd: '/tmp',
|
|
198
|
+
});
|
|
199
|
+
expect(seen).toEqual(['read', 'grep', 'find', 'ls']);
|
|
200
|
+
});
|
|
201
|
+
it('agent failure throws `${kind}: ${error}`', async () => {
|
|
202
|
+
const r = await runScript({
|
|
203
|
+
script: 'try { await agent("x"); } catch (e) { return e.message; }',
|
|
204
|
+
spawn: fakeFail('crashed', 'boom'),
|
|
205
|
+
cwd: '/tmp',
|
|
206
|
+
});
|
|
207
|
+
expect(r.value).toBe('crashed: boom');
|
|
208
|
+
});
|
|
209
|
+
it('agent returns res.data when a schema/data payload is present', async () => {
|
|
210
|
+
const spawn = async () => ({
|
|
211
|
+
ok: true,
|
|
212
|
+
text: '{"verdict":"PASS"}',
|
|
213
|
+
data: { verdict: 'PASS' },
|
|
214
|
+
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
|
215
|
+
});
|
|
216
|
+
const r = await runScript({
|
|
217
|
+
script: 'const v = await agent("x"); return v.verdict;',
|
|
218
|
+
spawn,
|
|
219
|
+
cwd: '/tmp',
|
|
220
|
+
});
|
|
221
|
+
expect(r.value).toBe('PASS');
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
// ── Saved-workflow discovery ───────────────────────────────────────────────
|
|
225
|
+
describe('saved-workflow discovery', () => {
|
|
226
|
+
let prevAgentDir;
|
|
227
|
+
let agentDir;
|
|
228
|
+
let projectDir;
|
|
229
|
+
beforeEach(() => {
|
|
230
|
+
prevAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
231
|
+
agentDir = tmpRoot();
|
|
232
|
+
projectDir = tmpRoot();
|
|
233
|
+
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
234
|
+
});
|
|
235
|
+
afterEach(() => {
|
|
236
|
+
if (prevAgentDir === undefined)
|
|
237
|
+
delete process.env.PI_CODING_AGENT_DIR;
|
|
238
|
+
else
|
|
239
|
+
process.env.PI_CODING_AGENT_DIR = prevAgentDir;
|
|
240
|
+
});
|
|
241
|
+
// Imported lazily so PI_CODING_AGENT_DIR is set before getAgentDir() is
|
|
242
|
+
// called. index.ts reads getAgentDir() at call time inside workflowDirs,
|
|
243
|
+
// not at module load, so this resolves to the tmp agentDir.
|
|
244
|
+
async function loadDiscovery() {
|
|
245
|
+
const mod = await import('./index.js');
|
|
246
|
+
return { listWorkflows: mod.listWorkflows, findWorkflowFile: mod.findWorkflowFile };
|
|
247
|
+
}
|
|
248
|
+
it('lists global workflows from ~/.pi/agent/workflows', async () => {
|
|
249
|
+
const { listWorkflows } = await loadDiscovery();
|
|
250
|
+
const dir = path.join(agentDir, 'workflows');
|
|
251
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
252
|
+
fs.writeFileSync(path.join(dir, 'research.js'), "export const meta = { name: 'research', description: 'parallel research' };\nreturn 'ok';");
|
|
253
|
+
fs.writeFileSync(path.join(dir, 'empty.js'), '// no meta\nreturn 1;');
|
|
254
|
+
const items = listWorkflows(projectDir, false);
|
|
255
|
+
expect(items.map((i) => i.name).sort()).toEqual(['empty', 'research']);
|
|
256
|
+
const research = items.find((i) => i.name === 'research');
|
|
257
|
+
expect(research.scope).toBe('global');
|
|
258
|
+
expect(research.description).toBe('parallel research');
|
|
259
|
+
});
|
|
260
|
+
it('finds a workflow file by name (global)', async () => {
|
|
261
|
+
const { findWorkflowFile } = await loadDiscovery();
|
|
262
|
+
const dir = path.join(agentDir, 'workflows');
|
|
263
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
264
|
+
fs.writeFileSync(path.join(dir, 'demo.js'), 'return 1;');
|
|
265
|
+
expect(findWorkflowFile('demo', projectDir, false)).toBe(path.join(dir, 'demo.js'));
|
|
266
|
+
});
|
|
267
|
+
it('project workflows are visible only when trusted', async () => {
|
|
268
|
+
const { listWorkflows, findWorkflowFile } = await loadDiscovery();
|
|
269
|
+
const dir = path.join(projectDir, '.pi', 'workflows');
|
|
270
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
271
|
+
fs.writeFileSync(path.join(dir, 'secret.js'), 'return 1;');
|
|
272
|
+
expect(listWorkflows(projectDir, false).find((i) => i.name === 'secret')).toBeUndefined();
|
|
273
|
+
expect(findWorkflowFile('secret', projectDir, false)).toBeUndefined();
|
|
274
|
+
expect(listWorkflows(projectDir, true).find((i) => i.name === 'secret')?.scope).toBe('project');
|
|
275
|
+
expect(findWorkflowFile('secret', projectDir, true)).toBe(path.join(dir, 'secret.js'));
|
|
276
|
+
});
|
|
277
|
+
it('global shadows a same-named project workflow', async () => {
|
|
278
|
+
const { listWorkflows } = await loadDiscovery();
|
|
279
|
+
const gDir = path.join(agentDir, 'workflows');
|
|
280
|
+
const pDir = path.join(projectDir, '.pi', 'workflows');
|
|
281
|
+
fs.mkdirSync(gDir, { recursive: true });
|
|
282
|
+
fs.mkdirSync(pDir, { recursive: true });
|
|
283
|
+
fs.writeFileSync(path.join(gDir, 'dup.js'), 'return 1;');
|
|
284
|
+
fs.writeFileSync(path.join(pDir, 'dup.js'), 'return 2;');
|
|
285
|
+
const items = listWorkflows(projectDir, true);
|
|
286
|
+
expect(items.filter((i) => i.name === 'dup')).toHaveLength(1);
|
|
287
|
+
expect(items.find((i) => i.name === 'dup')?.scope).toBe('global');
|
|
288
|
+
});
|
|
289
|
+
it('rejects path-like names (no escaping the workflows dirs)', async () => {
|
|
290
|
+
const { findWorkflowFile } = await loadDiscovery();
|
|
291
|
+
expect(findWorkflowFile('..', projectDir, true)).toBeUndefined();
|
|
292
|
+
expect(findWorkflowFile('../etc/passwd', projectDir, true)).toBeUndefined();
|
|
293
|
+
expect(findWorkflowFile('a/b', projectDir, true)).toBeUndefined();
|
|
294
|
+
});
|
|
295
|
+
it('returns empty list when no dirs exist', async () => {
|
|
296
|
+
const { listWorkflows } = await loadDiscovery();
|
|
297
|
+
expect(listWorkflows(projectDir, true)).toEqual([]);
|
|
298
|
+
});
|
|
299
|
+
});
|
package/engine.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workflow script engine — pi-free and testable.
|
|
3
|
+
*
|
|
4
|
+
* A workflow script is a JavaScript statement body with injected globals
|
|
5
|
+
* (args, agent, parallel, pipeline, phase, log, budget, cwd) and a leading
|
|
6
|
+
* `export const meta = { name, description }` declaration. It returns a value
|
|
7
|
+
* by evaluating a trailing expression or a top-level `return` (the body is
|
|
8
|
+
* wrapped in an async function so a bare `return` compiles).
|
|
9
|
+
*
|
|
10
|
+
* This module deliberately imports nothing from pi or @nicknisi/pi-shared so
|
|
11
|
+
* the test suite can exercise it without an install step: the spawn function
|
|
12
|
+
* is injected. index.ts wires it to the shared subagent runtime.
|
|
13
|
+
*
|
|
14
|
+
* The compile model mirrors ~/Developer/ideation/workflows/engine-host.mjs:
|
|
15
|
+
* `export const meta =` is rewritten to an outer-binding assignment so the vm
|
|
16
|
+
* compiles (a stranded `export` fails loudly) and the tool can surface
|
|
17
|
+
* meta.name/description.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import vm from 'node:vm';
|
|
21
|
+
|
|
22
|
+
// ── Public types ───────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export interface EngineSpawnUsage {
|
|
25
|
+
inputTokens: number;
|
|
26
|
+
outputTokens: number;
|
|
27
|
+
totalTokens: number;
|
|
28
|
+
cost?: number | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface EngineSpawnOk {
|
|
32
|
+
ok: true;
|
|
33
|
+
text: string;
|
|
34
|
+
data?: unknown;
|
|
35
|
+
usage: EngineSpawnUsage;
|
|
36
|
+
/** Run id of the child spawn, when the spawn fn attaches it. */
|
|
37
|
+
runId?: string;
|
|
38
|
+
/** Path to the worktree `.patch` file, for `worktree: true` runs that changed files. */
|
|
39
|
+
patchPath?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface EngineSpawnFail {
|
|
43
|
+
ok: false;
|
|
44
|
+
kind: string;
|
|
45
|
+
error: string;
|
|
46
|
+
text: string;
|
|
47
|
+
usage: EngineSpawnUsage;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type EngineSpawnResult = EngineSpawnOk | EngineSpawnFail;
|
|
51
|
+
|
|
52
|
+
export interface EngineSpawnOptions {
|
|
53
|
+
prompt: string;
|
|
54
|
+
agent?: string;
|
|
55
|
+
model?: string;
|
|
56
|
+
tools?: string[];
|
|
57
|
+
systemPrompt?: string;
|
|
58
|
+
thinkingLevel?: string;
|
|
59
|
+
timeoutMs?: number;
|
|
60
|
+
maxTurns?: number;
|
|
61
|
+
outputSchema?: unknown;
|
|
62
|
+
/** Run the child in an isolated git worktree; its change set is captured to a `.patch`. */
|
|
63
|
+
worktree?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type EngineSpawnFn = (opts: EngineSpawnOptions) => Promise<EngineSpawnResult>;
|
|
67
|
+
|
|
68
|
+
export interface EngineBudget {
|
|
69
|
+
total: number;
|
|
70
|
+
spent: number;
|
|
71
|
+
remaining: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ScriptMeta {
|
|
75
|
+
name?: string;
|
|
76
|
+
description?: string;
|
|
77
|
+
[k: string]: unknown;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface RunScriptOptions {
|
|
81
|
+
script: string;
|
|
82
|
+
args?: unknown;
|
|
83
|
+
spawn: EngineSpawnFn;
|
|
84
|
+
cwd: string;
|
|
85
|
+
budgetTotal?: number;
|
|
86
|
+
onLog?: (line: string) => void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface RunScriptResult {
|
|
90
|
+
value: unknown;
|
|
91
|
+
meta: ScriptMeta | undefined;
|
|
92
|
+
logs: string[];
|
|
93
|
+
usage: EngineSpawnUsage;
|
|
94
|
+
durationMs: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Internals ──────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
const STRIP_META = /export\s+const\s+meta\s*=/;
|
|
100
|
+
const DEFAULT_TOOLS = ['read', 'grep', 'find', 'ls'];
|
|
101
|
+
const MAX_LOG_ENTRIES = 200;
|
|
102
|
+
const MAX_LOG_CHARS = 2000;
|
|
103
|
+
|
|
104
|
+
function truncate(line: string, max: number): string {
|
|
105
|
+
return line.length <= max ? line : `${line.slice(0, max)}…[truncated at ${max} chars]`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function safeStringify(v: unknown): string {
|
|
109
|
+
if (typeof v === 'string') return v;
|
|
110
|
+
try {
|
|
111
|
+
return JSON.stringify(v);
|
|
112
|
+
} catch {
|
|
113
|
+
return String(v);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function zeroUsage(): EngineSpawnUsage {
|
|
118
|
+
return { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function addUsage(a: EngineSpawnUsage, b: EngineSpawnUsage): void {
|
|
122
|
+
a.inputTokens += b.inputTokens;
|
|
123
|
+
a.outputTokens += b.outputTokens;
|
|
124
|
+
a.totalTokens += b.totalTokens;
|
|
125
|
+
if (b.cost !== undefined) a.cost = (a.cost ?? 0) + b.cost;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Engine ─────────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
export async function runScript(opts: RunScriptOptions): Promise<RunScriptResult> {
|
|
131
|
+
const { script, spawn, cwd } = opts;
|
|
132
|
+
const args = opts.args;
|
|
133
|
+
const budgetTotal = opts.budgetTotal ?? Infinity;
|
|
134
|
+
const logs: string[] = [];
|
|
135
|
+
const usage = zeroUsage();
|
|
136
|
+
const startedAt = Date.now();
|
|
137
|
+
|
|
138
|
+
const log = (...parts: unknown[]): void => {
|
|
139
|
+
if (logs.length >= MAX_LOG_ENTRIES) return;
|
|
140
|
+
const line = parts.map(safeStringify).join(' ');
|
|
141
|
+
const t = truncate(line, MAX_LOG_CHARS);
|
|
142
|
+
logs.push(t);
|
|
143
|
+
opts.onLog?.(t);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const phase = (name: string): void => {
|
|
147
|
+
const line = `── ${name}`;
|
|
148
|
+
logs.push(line);
|
|
149
|
+
opts.onLog?.(line);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const budget: EngineBudget = {
|
|
153
|
+
get total() {
|
|
154
|
+
return budgetTotal;
|
|
155
|
+
},
|
|
156
|
+
get spent() {
|
|
157
|
+
return usage.totalTokens;
|
|
158
|
+
},
|
|
159
|
+
get remaining() {
|
|
160
|
+
return budgetTotal - usage.totalTokens;
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// agent(prompt, opts) backed by the injected spawn. A failure kind becomes
|
|
165
|
+
// a throw so a script's safeAgent wrapper converts it into a typed stage
|
|
166
|
+
// failure (matching ~/Developer/ideation/workflows/engine-host.mjs).
|
|
167
|
+
const agent = async (prompt: string, agentOpts: Record<string, unknown> = {}): Promise<unknown> => {
|
|
168
|
+
if (typeof agentOpts.agentType === 'string') {
|
|
169
|
+
log(`(agentType '${agentOpts.agentType}' accepted but ignored — no agent-type registry)`);
|
|
170
|
+
}
|
|
171
|
+
const spawnOpts: EngineSpawnOptions = {
|
|
172
|
+
prompt,
|
|
173
|
+
...(typeof agentOpts.label === 'string' ? { agent: agentOpts.label } : {}),
|
|
174
|
+
...(typeof agentOpts.model === 'string' ? { model: agentOpts.model } : {}),
|
|
175
|
+
...(Array.isArray(agentOpts.tools) ? { tools: agentOpts.tools as string[] } : { tools: DEFAULT_TOOLS }),
|
|
176
|
+
...(typeof agentOpts.systemPrompt === 'string' ? { systemPrompt: agentOpts.systemPrompt } : {}),
|
|
177
|
+
...(typeof agentOpts.effort === 'string' ? { thinkingLevel: agentOpts.effort } : {}),
|
|
178
|
+
...(typeof agentOpts.timeoutMs === 'number' ? { timeoutMs: agentOpts.timeoutMs } : {}),
|
|
179
|
+
...(typeof agentOpts.maxTurns === 'number' ? { maxTurns: agentOpts.maxTurns } : {}),
|
|
180
|
+
...(agentOpts.schema !== undefined ? { outputSchema: agentOpts.schema } : {}),
|
|
181
|
+
...(agentOpts.worktree === true ? { worktree: true } : {}),
|
|
182
|
+
};
|
|
183
|
+
const res = await spawn(spawnOpts);
|
|
184
|
+
addUsage(usage, res.usage);
|
|
185
|
+
if (!res.ok) throw new Error(`${res.kind}: ${res.error}`);
|
|
186
|
+
// A worktree run that changed files produces a `.patch`; surface it
|
|
187
|
+
// alongside the value so the script can hand the path to a judge or
|
|
188
|
+
// return it for the `/patches` apply flow. Opt-in: only when patchPath is
|
|
189
|
+
// present, so non-worktree scripts see the unchanged `data ?? text` return.
|
|
190
|
+
if (res.patchPath) return { value: res.data ?? res.text ?? null, patchPath: res.patchPath, runId: res.runId };
|
|
191
|
+
return res.data ?? res.text ?? null;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const parallel = <T>(thunks: Array<() => Promise<T>>): Promise<T[]> => Promise.all(thunks.map((t) => t()));
|
|
195
|
+
|
|
196
|
+
// pipeline(items, ...stages): each stage maps over the previous stage's
|
|
197
|
+
// outputs in parallel, producing the next array. A fold over Promise.all.
|
|
198
|
+
const pipeline = async <T, U>(items: T[], ...stages: Array<(item: T, index: number) => Promise<U>>): Promise<U[]> => {
|
|
199
|
+
let values: unknown[] = [...items];
|
|
200
|
+
for (const stage of stages) {
|
|
201
|
+
values = await parallel((values as T[]).map((v, i) => () => stage(v, i)));
|
|
202
|
+
}
|
|
203
|
+
return values as U[];
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// Compile + run. compileScript is extracted so callers (tests, future
|
|
207
|
+
// tooling) can compile + read `meta` without a real spawn.
|
|
208
|
+
const compiled = compileScript(script);
|
|
209
|
+
const value = await compiled.fn(args, agent, parallel, pipeline, phase, log, budget, cwd);
|
|
210
|
+
return { value, meta: compiled.meta, logs, usage, durationMs: Date.now() - startedAt };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Compile-only entry ────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
/** The compiled async body; invoking it runs the script with injected globals. */
|
|
216
|
+
export type CompiledFn = (
|
|
217
|
+
args: unknown,
|
|
218
|
+
agent: unknown,
|
|
219
|
+
parallel: unknown,
|
|
220
|
+
pipeline: unknown,
|
|
221
|
+
phase: unknown,
|
|
222
|
+
log: unknown,
|
|
223
|
+
budget: unknown,
|
|
224
|
+
cwd: string,
|
|
225
|
+
) => Promise<unknown>;
|
|
226
|
+
|
|
227
|
+
export interface CompiledScript {
|
|
228
|
+
/** The script's `meta` export, read via a stub dry-run (no real spawn). */
|
|
229
|
+
meta: ScriptMeta | undefined;
|
|
230
|
+
/** Invoke to run the script; spawn-backed globals must be supplied. */
|
|
231
|
+
fn: CompiledFn;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Compile a workflow script and read its `meta` export WITHOUT a real spawn.
|
|
236
|
+
*
|
|
237
|
+
* `export const meta =` is rewritten to an outer-binding assignment so the vm
|
|
238
|
+
* compiles (a stranded `export` fails loudly) and `meta` is captured into a
|
|
239
|
+
* holder. The body is wrapped in an async function so a top-level `return`
|
|
240
|
+
* compiles. `meta` is populated by a stub dry-run — `agent` returns `null`,
|
|
241
|
+
* `parallel`/`pipeline` are `Promise.all`-style folds over those stubs — so the
|
|
242
|
+
* script's `meta` declaration (which well-formed scripts place first) is read
|
|
243
|
+
* without spawning any child sessions. A syntax error throws here.
|
|
244
|
+
*/
|
|
245
|
+
export function compileScript(script: string): CompiledScript {
|
|
246
|
+
const metaHolder: { value: ScriptMeta | undefined } = { value: undefined };
|
|
247
|
+
const stripped = script.replace(STRIP_META, 'const meta = metaHolder.value =');
|
|
248
|
+
const wrapped = `(async function(args, agent, parallel, pipeline, phase, log, budget, cwd, metaHolder){\n${stripped}\n})`;
|
|
249
|
+
const raw = new vm.Script(wrapped, { filename: 'workflow.js' }).runInThisContext() as (
|
|
250
|
+
args: unknown,
|
|
251
|
+
agent: unknown,
|
|
252
|
+
parallel: unknown,
|
|
253
|
+
pipeline: unknown,
|
|
254
|
+
phase: unknown,
|
|
255
|
+
log: unknown,
|
|
256
|
+
budget: unknown,
|
|
257
|
+
cwd: string,
|
|
258
|
+
metaHolder: { value: ScriptMeta | undefined },
|
|
259
|
+
) => Promise<unknown>;
|
|
260
|
+
// Bind metaHolder so callers invoke an 8-arg fn; the holder rides the call
|
|
261
|
+
// (runInThisContext cannot see a closure variable, so it must be a parameter).
|
|
262
|
+
const fn: CompiledFn = (args, agent, parallel, pipeline, phase, log, budget, cwd) =>
|
|
263
|
+
raw(args, agent, parallel, pipeline, phase, log, budget, cwd, metaHolder);
|
|
264
|
+
|
|
265
|
+
// Stub dry-run to read `meta`. Well-formed scripts declare `meta` first, so
|
|
266
|
+
// it is assigned synchronously before the first `await`; we await the whole
|
|
267
|
+
// stubbed body anyway so a script that computes meta from `args` works too.
|
|
268
|
+
// Any throw is swallowed — we only care that it compiled + set meta.
|
|
269
|
+
const stubAgent = async (): Promise<null> => null;
|
|
270
|
+
const stubParallel = <T>(thunks: Array<() => Promise<T>>): Promise<T[]> => Promise.all(thunks.map((t) => t()));
|
|
271
|
+
const stubPipeline = async <T, U>(items: T[], ...stages: Array<(item: T) => Promise<U>>): Promise<U[]> => {
|
|
272
|
+
let values: unknown[] = [...items];
|
|
273
|
+
for (const stage of stages) values = await stubParallel((values as T[]).map((v) => () => stage(v)));
|
|
274
|
+
return values as U[];
|
|
275
|
+
};
|
|
276
|
+
const stubBudget = { total: Infinity, spent: 0, remaining: Infinity };
|
|
277
|
+
void fn(
|
|
278
|
+
undefined,
|
|
279
|
+
stubAgent,
|
|
280
|
+
stubParallel,
|
|
281
|
+
stubPipeline,
|
|
282
|
+
() => {},
|
|
283
|
+
() => {},
|
|
284
|
+
stubBudget,
|
|
285
|
+
'/tmp',
|
|
286
|
+
).catch(() => {});
|
|
287
|
+
|
|
288
|
+
// `meta` is a getter so a caller that re-runs `fn` with real globals sees
|
|
289
|
+
// the post-run meta (the stub dry-run may have thrown on args-derived meta;
|
|
290
|
+
// the real run sets it). For static meta the stub already populated it.
|
|
291
|
+
return Object.defineProperty({ fn }, 'meta', {
|
|
292
|
+
get: () => metaHolder.value,
|
|
293
|
+
enumerable: true,
|
|
294
|
+
}) as CompiledScript;
|
|
295
|
+
}
|