@op1/threads 0.1.8 → 0.2.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/README.md +22 -6
- package/docs/dynamic-workflows-plan.md +46 -0
- package/docs/workflow-capacity-findings.md +47 -0
- package/docs/workflows-verification.md +105 -0
- package/docs/workflows.md +60 -0
- package/index.ts +2 -0
- package/package.json +12 -4
- package/skills/workflow-authoring/SKILL.md +50 -0
- package/skills/workflow-authoring/references/runtime.md +87 -0
- package/src/permissions.ts +2 -2
- package/src/threads.ts +201 -32
- package/src/workflow-engine.ts +1049 -0
- package/src/workflow-rpc.ts +39 -0
- package/src/workflow-runtime-interpreter.ts +368 -0
- package/src/workflow-runtime-protocol.ts +36 -0
- package/src/workflow-runtime-worker.ts +64 -0
- package/src/workflow-runtime.ts +144 -0
- package/src/workflow-saved.ts +88 -0
- package/src/workflow-store.ts +103 -0
- package/src/workflow-types.ts +144 -0
- package/src/workflow-ui.tsx +251 -0
- package/src/workflow-worker.ts +210 -0
- package/src/workflows.ts +180 -0
- package/tui.ts +3 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Rpc } from "@opencode/plugin/rpc";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { Json, WorkflowRun, WorkflowSummary } from "./workflow-types";
|
|
4
|
+
|
|
5
|
+
export const WorkflowControl = z.object({
|
|
6
|
+
runID: z.string().min(1),
|
|
7
|
+
action: z.enum(["pause", "resume", "stop"]),
|
|
8
|
+
checkpointKey: z.string().optional(),
|
|
9
|
+
response: Json.optional(),
|
|
10
|
+
}).strict();
|
|
11
|
+
|
|
12
|
+
export const WorkflowsRpc = Rpc.define({
|
|
13
|
+
id: "workflows",
|
|
14
|
+
methods: {
|
|
15
|
+
snapshot: {
|
|
16
|
+
input: z.object({ ownerID: z.string() }).strict(),
|
|
17
|
+
output: z.object({ runs: z.array(WorkflowSummary) }),
|
|
18
|
+
errors: {},
|
|
19
|
+
},
|
|
20
|
+
inspect: {
|
|
21
|
+
input: z.object({ ownerID: z.string(), runID: z.string() }).strict(),
|
|
22
|
+
output: WorkflowRun,
|
|
23
|
+
errors: {},
|
|
24
|
+
},
|
|
25
|
+
control: {
|
|
26
|
+
input: WorkflowControl.extend({ ownerID: z.string() }).strict(),
|
|
27
|
+
output: WorkflowRun,
|
|
28
|
+
errors: {},
|
|
29
|
+
},
|
|
30
|
+
save: {
|
|
31
|
+
input: z.object({ ownerID: z.string(), runID: z.string(), name: z.string(), scope: z.enum(["project", "user"]) }).strict(),
|
|
32
|
+
output: z.object({ path: z.string() }),
|
|
33
|
+
errors: {},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
events: {
|
|
37
|
+
updated: { schema: z.object({ ownerID: z.string(), runID: z.string() }) },
|
|
38
|
+
},
|
|
39
|
+
});
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { CodeMode, Tool, toolError } from "@opencode/codemode";
|
|
2
|
+
import { parse } from "acorn";
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
|
|
5
|
+
export type WorkflowHost = {
|
|
6
|
+
agent(input: unknown): Promise<unknown>;
|
|
7
|
+
phase(title: string): Promise<void>;
|
|
8
|
+
log(message: string): Promise<void>;
|
|
9
|
+
checkpoint(input: unknown): Promise<unknown>;
|
|
10
|
+
workflow(input: { name: string; args?: unknown }): Promise<unknown>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type WorkflowMeta = {
|
|
14
|
+
name: string;
|
|
15
|
+
description: string;
|
|
16
|
+
phases?: { title: string }[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type Node = { type: string; start: number; end: number; [key: string]: unknown };
|
|
20
|
+
|
|
21
|
+
const injected = new Set([
|
|
22
|
+
"args",
|
|
23
|
+
"agent",
|
|
24
|
+
"phase",
|
|
25
|
+
"log",
|
|
26
|
+
"checkpoint",
|
|
27
|
+
"workflow",
|
|
28
|
+
"parallel",
|
|
29
|
+
"pipeline",
|
|
30
|
+
"retry",
|
|
31
|
+
"gate",
|
|
32
|
+
"loopUntilDry",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const safeGlobals = new Set([
|
|
36
|
+
"undefined", "NaN", "Infinity", "Object", "Array", "Math", "JSON", "Promise",
|
|
37
|
+
"Symbol", "Number", "String", "Boolean", "parseInt", "parseFloat", "isFinite",
|
|
38
|
+
"isNaN", "RegExp", "Map", "Set", "URL", "URLSearchParams", "Headers", "Uint8Array",
|
|
39
|
+
"TextEncoder", "TextDecoder", "encodeURI", "encodeURIComponent", "decodeURI",
|
|
40
|
+
"decodeURIComponent", "atob", "btoa", "Error", "TypeError", "RangeError",
|
|
41
|
+
"SyntaxError", "ReferenceError", "AggregateError",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const deterministicMathProperties = new Set([
|
|
45
|
+
"PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2",
|
|
46
|
+
"max", "min", "hypot", "abs", "acos", "acosh", "asin", "asinh", "atan",
|
|
47
|
+
"atan2", "atanh", "floor", "ceil", "round", "trunc", "sign", "sqrt", "cbrt",
|
|
48
|
+
"pow", "cos", "cosh", "sin", "sinh", "tan", "tanh", "log", "log2", "log10",
|
|
49
|
+
"log1p", "exp", "expm1", "f16round", "fround", "clz32", "imul", "sumPrecise",
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const prototypeEscapeProperties = new Set([
|
|
53
|
+
"constructor", "prototype", "__proto__", "getPrototypeOf", "setPrototypeOf",
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const forbiddenGlobals = new Set([
|
|
57
|
+
"Date", "performance", "crypto", "process", "fetch", "require", "globalThis",
|
|
58
|
+
"window", "self", "eval", "Function", "WebAssembly", "tools", "search", "console",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
function ast(source: string, sourceType: "script" | "module"): Node {
|
|
62
|
+
return parse(source, {
|
|
63
|
+
ecmaVersion: "latest",
|
|
64
|
+
sourceType,
|
|
65
|
+
allowReturnOutsideFunction: true,
|
|
66
|
+
allowAwaitOutsideFunction: true,
|
|
67
|
+
}) as unknown as Node;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function children(node: Node): Node[] {
|
|
71
|
+
const result: Node[] = [];
|
|
72
|
+
for (const [key, value] of Object.entries(node)) {
|
|
73
|
+
if (key === "start" || key === "end" || key === "loc") continue;
|
|
74
|
+
if (value && typeof value === "object" && "type" in value)
|
|
75
|
+
result.push(value as Node);
|
|
76
|
+
else if (Array.isArray(value))
|
|
77
|
+
for (const item of value)
|
|
78
|
+
if (item && typeof item === "object" && "type" in item) result.push(item as Node);
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function literal(node: Node): unknown {
|
|
84
|
+
if (node.type === "Literal") return node.value;
|
|
85
|
+
if (node.type === "UnaryExpression" && (node.operator === "-" || node.operator === "+")) {
|
|
86
|
+
const value = literal(node.argument as Node);
|
|
87
|
+
if (typeof value === "number") return node.operator === "-" ? -value : value;
|
|
88
|
+
}
|
|
89
|
+
if (node.type === "ArrayExpression")
|
|
90
|
+
return (node.elements as (Node | null)[]).map((item) => {
|
|
91
|
+
if (!item) throw new Error("Workflow metadata cannot contain array holes");
|
|
92
|
+
return literal(item);
|
|
93
|
+
});
|
|
94
|
+
if (node.type === "ObjectExpression") {
|
|
95
|
+
const value: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
96
|
+
for (const property of node.properties as Node[]) {
|
|
97
|
+
if (property.type !== "Property" || property.computed || property.kind !== "init" || property.method || property.shorthand)
|
|
98
|
+
throw new Error("Workflow metadata must be a literal object");
|
|
99
|
+
const keyNode = property.key as Node;
|
|
100
|
+
const key = keyNode.type === "Identifier" ? keyNode.name : literal(keyNode);
|
|
101
|
+
if (typeof key !== "string") throw new Error("Workflow metadata keys must be strings");
|
|
102
|
+
if (Object.hasOwn(value, key)) throw new Error(`Duplicate workflow metadata key: ${key}`);
|
|
103
|
+
value[key] = literal(property.value as Node);
|
|
104
|
+
}
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
throw new Error("Workflow metadata must contain only literal values");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function validateMeta(value: unknown): WorkflowMeta {
|
|
111
|
+
if (!isRecord(value) || typeof value.name !== "string" || !value.name.trim() ||
|
|
112
|
+
typeof value.description !== "string" || !value.description.trim())
|
|
113
|
+
throw new Error("Workflow metadata requires non-empty name and description strings");
|
|
114
|
+
const keys = Object.keys(value);
|
|
115
|
+
if (keys.some((key) => !["name", "description", "phases"].includes(key)))
|
|
116
|
+
throw new Error("Workflow metadata contains an unknown field");
|
|
117
|
+
if (value.phases !== undefined && (!Array.isArray(value.phases) || value.phases.some((phase) =>
|
|
118
|
+
!isRecord(phase) || Object.keys(phase).length !== 1 || typeof phase.title !== "string" || !phase.title.trim())))
|
|
119
|
+
throw new Error("Workflow metadata phases must contain only non-empty titles");
|
|
120
|
+
return {
|
|
121
|
+
name: value.name,
|
|
122
|
+
description: value.description,
|
|
123
|
+
...(value.phases === undefined ? {} : { phases: value.phases as { title: string }[] }),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function parseWorkflow(script: string): { meta: WorkflowMeta; body: string } {
|
|
128
|
+
const program = ast(script, "module");
|
|
129
|
+
const statements = program.body as Node[];
|
|
130
|
+
const first = statements[0];
|
|
131
|
+
if (!first || first.type !== "ExportNamedDeclaration")
|
|
132
|
+
throw new Error("The first statement must be `export const meta =` with a literal object");
|
|
133
|
+
const declaration = first.declaration as Node | null;
|
|
134
|
+
const declarations = declaration?.declarations as Node[] | undefined;
|
|
135
|
+
const item = declarations?.[0];
|
|
136
|
+
if (declaration?.type !== "VariableDeclaration" || declaration.kind !== "const" || declarations?.length !== 1 ||
|
|
137
|
+
item?.id && ((item.id as Node).type !== "Identifier" || (item.id as Node).name !== "meta") ||
|
|
138
|
+
(item?.init as Node | undefined)?.type !== "ObjectExpression")
|
|
139
|
+
throw new Error("The first statement must be `export const meta =` with a literal object");
|
|
140
|
+
const meta = validateMeta(literal((item as Node).init as Node));
|
|
141
|
+
return { meta, body: script.slice(first.end).trim() };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
145
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function patternNames(node: Node | null | undefined, names: Set<string>): void {
|
|
149
|
+
if (!node) return;
|
|
150
|
+
if (node.type === "Identifier") names.add(node.name as string);
|
|
151
|
+
else if (node.type === "RestElement") patternNames(node.argument as Node, names);
|
|
152
|
+
else if (node.type === "AssignmentPattern") patternNames(node.left as Node, names);
|
|
153
|
+
else if (node.type === "ArrayPattern") for (const item of node.elements as (Node | null)[]) patternNames(item, names);
|
|
154
|
+
else if (node.type === "ObjectPattern") for (const property of node.properties as Node[])
|
|
155
|
+
patternNames((property.type === "RestElement" ? property.argument : property.value) as Node, names);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isReference(node: Node, parent?: Node): boolean {
|
|
159
|
+
if (!parent) return true;
|
|
160
|
+
if ((parent.type === "VariableDeclarator" && parent.id === node) ||
|
|
161
|
+
((parent.type === "FunctionDeclaration" || parent.type === "FunctionExpression" || parent.type === "ArrowFunctionExpression") &&
|
|
162
|
+
(parent.id === node || (parent.params as Node[]).includes(node))) ||
|
|
163
|
+
((parent.type === "Property" || parent.type === "MethodDefinition") && parent.key === node && !parent.computed &&
|
|
164
|
+
!(parent.type === "Property" && parent.shorthand && parent.value === node)) ||
|
|
165
|
+
(parent.type === "MemberExpression" && parent.property === node && !parent.computed) ||
|
|
166
|
+
(parent.type === "LabeledStatement" || parent.type === "BreakStatement" || parent.type === "ContinueStatement") ||
|
|
167
|
+
(parent.type === "CatchClause" && parent.param === node)) return false;
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function validateBody(body: string): void {
|
|
172
|
+
const program = ast(body, "script");
|
|
173
|
+
const declared = new Set<string>();
|
|
174
|
+
const nodes: { node: Node; parent?: Node }[] = [];
|
|
175
|
+
const visit = (node: Node, parent?: Node) => {
|
|
176
|
+
nodes.push({ node, parent });
|
|
177
|
+
if (node.type === "VariableDeclarator") patternNames(node.id as Node, declared);
|
|
178
|
+
if ((node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ClassDeclaration" || node.type === "ClassExpression") && node.id)
|
|
179
|
+
patternNames(node.id as Node, declared);
|
|
180
|
+
if (node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression")
|
|
181
|
+
for (const param of node.params as Node[]) patternNames(param, declared);
|
|
182
|
+
if (node.type === "CatchClause") patternNames(node.param as Node | null, declared);
|
|
183
|
+
for (const child of children(node)) visit(child, node);
|
|
184
|
+
};
|
|
185
|
+
visit(program);
|
|
186
|
+
for (const name of declared)
|
|
187
|
+
if (injected.has(name) || name === "tools" || name === "search") throw new Error(`Workflow cannot shadow injected binding ${name}`);
|
|
188
|
+
|
|
189
|
+
for (const { node, parent } of nodes) {
|
|
190
|
+
if (node.type === "ImportDeclaration" || node.type === "ImportExpression" || node.type.startsWith("Export"))
|
|
191
|
+
throw new Error("Imports and exports are not supported in workflow bodies");
|
|
192
|
+
if (node.type === "Identifier" && isReference(node, parent)) {
|
|
193
|
+
const name = node.name as string;
|
|
194
|
+
if (forbiddenGlobals.has(name)) throw new Error(`Workflow cannot access ${name}`);
|
|
195
|
+
if (name === "Math") {
|
|
196
|
+
if (parent?.type !== "MemberExpression" || parent.object !== node)
|
|
197
|
+
throw new Error("Workflow may only use Math through a deterministic static property");
|
|
198
|
+
const property = parent.property as Node;
|
|
199
|
+
const propertyName = parent.computed ? (property.type === "Literal" ? property.value : undefined) : property.name;
|
|
200
|
+
if (typeof propertyName !== "string" || !deterministicMathProperties.has(propertyName))
|
|
201
|
+
throw new Error(`Workflow cannot access nondeterministic or unknown Math property ${String(propertyName)}`);
|
|
202
|
+
}
|
|
203
|
+
if (!declared.has(name) && !safeGlobals.has(name) && !injected.has(name)) throw new Error(`Unknown workflow global: ${name}`);
|
|
204
|
+
}
|
|
205
|
+
if (node.type === "MemberExpression") {
|
|
206
|
+
const property = node.property as Node;
|
|
207
|
+
const name = node.computed ? (property.type === "Literal" ? property.value : undefined) : property.name;
|
|
208
|
+
if (typeof name === "string" && prototypeEscapeProperties.has(name))
|
|
209
|
+
throw new Error(`Workflow cannot access prototype escape property ${name}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const anySchema = {};
|
|
215
|
+
const objectSchema = (properties: Record<string, unknown>, required: string[] = []) => ({
|
|
216
|
+
type: "object", properties, required, additionalProperties: true,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const prelude = `
|
|
220
|
+
const args = await tools.runtime.args({});
|
|
221
|
+
const agent = async (prompt, options = {}) => tools.runtime.agent({ ...options, prompt });
|
|
222
|
+
const phase = async (title) => tools.runtime.phase({ title });
|
|
223
|
+
const log = async (message) => tools.runtime.log({ message });
|
|
224
|
+
const checkpoint = async (prompt, options = {}) => tools.runtime.checkpoint({ ...options, prompt });
|
|
225
|
+
const workflow = async (name, workflowArgs) => workflowArgs === undefined
|
|
226
|
+
? tools.runtime.workflow({ name })
|
|
227
|
+
: tools.runtime.workflow({ name, args: workflowArgs });
|
|
228
|
+
const MAX_HELPER_ITERATIONS = 1000;
|
|
229
|
+
const positiveInteger = (value, name) => {
|
|
230
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > MAX_HELPER_ITERATIONS)
|
|
231
|
+
throw new RangeError(name + " must be a safe positive integer no greater than " + MAX_HELPER_ITERATIONS);
|
|
232
|
+
return value;
|
|
233
|
+
};
|
|
234
|
+
const parallel = async (thunks) => Promise.all(thunks.map((thunk) => thunk()));
|
|
235
|
+
const pipeline = async (items, ...stages) => Promise.all(items.map(async (item) => {
|
|
236
|
+
let value = item;
|
|
237
|
+
for (const stage of stages) value = await stage(value);
|
|
238
|
+
return value;
|
|
239
|
+
}));
|
|
240
|
+
const retry = async (thunk, options = {}) => {
|
|
241
|
+
const attempts = positiveInteger(options.attempts === undefined ? 3 : options.attempts, "attempts");
|
|
242
|
+
let lastError;
|
|
243
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
244
|
+
try { return await thunk(attempt); } catch (error) { lastError = error; }
|
|
245
|
+
}
|
|
246
|
+
throw lastError;
|
|
247
|
+
};
|
|
248
|
+
const gate = async (thunk, validator, options = {}) => retry(async (attempt) => {
|
|
249
|
+
const value = await thunk(attempt);
|
|
250
|
+
const verdict = await validator(value, attempt);
|
|
251
|
+
const accepted = verdict === true || (verdict !== null && typeof verdict === "object" && verdict.ok === true);
|
|
252
|
+
if (verdict !== true && verdict !== false && (verdict === null || typeof verdict !== "object" || typeof verdict.ok !== "boolean"))
|
|
253
|
+
throw new TypeError("Workflow gate validator must return a boolean or { ok, feedback? }");
|
|
254
|
+
if (!accepted) {
|
|
255
|
+
const feedback = verdict !== null && typeof verdict === "object" && typeof verdict.feedback === "string" ? ": " + verdict.feedback : "";
|
|
256
|
+
throw new Error("Workflow gate rejected the result" + feedback);
|
|
257
|
+
}
|
|
258
|
+
return value;
|
|
259
|
+
}, options);
|
|
260
|
+
const loopUntilDry = async ({ round, key, consecutiveEmpty = 2, maxRounds = 10 }) => {
|
|
261
|
+
positiveInteger(consecutiveEmpty, "consecutiveEmpty");
|
|
262
|
+
positiveInteger(maxRounds, "maxRounds");
|
|
263
|
+
if (typeof round !== "function") throw new TypeError("loopUntilDry round must be a function");
|
|
264
|
+
if (typeof key !== "function" && typeof key !== "string") throw new TypeError("loopUntilDry key must be a function or property name");
|
|
265
|
+
const seen = new Set();
|
|
266
|
+
const values = [];
|
|
267
|
+
let empty = 0;
|
|
268
|
+
for (let index = 1; index <= maxRounds && empty < consecutiveEmpty; index++) {
|
|
269
|
+
const batch = await round(index);
|
|
270
|
+
if (!Array.isArray(batch)) throw new TypeError("loopUntilDry round must return an array");
|
|
271
|
+
let added = 0;
|
|
272
|
+
for (const item of batch) {
|
|
273
|
+
const identity = typeof key === "function" ? await key(item) : item[key];
|
|
274
|
+
if (!seen.has(identity)) { seen.add(identity); values.push(item); added++; }
|
|
275
|
+
}
|
|
276
|
+
empty = added === 0 ? empty + 1 : 0;
|
|
277
|
+
}
|
|
278
|
+
return values;
|
|
279
|
+
};
|
|
280
|
+
`;
|
|
281
|
+
|
|
282
|
+
function positiveOption(value: number | undefined, name: string): number | undefined {
|
|
283
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value < 1)) throw new RangeError(`${name} must be a positive integer`);
|
|
284
|
+
return value;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export async function executeWorkflowInterpreter(input: {
|
|
288
|
+
script: string;
|
|
289
|
+
args: unknown;
|
|
290
|
+
signal: AbortSignal;
|
|
291
|
+
host: WorkflowHost;
|
|
292
|
+
maxCalls?: number;
|
|
293
|
+
timeoutMs?: number;
|
|
294
|
+
}): Promise<unknown> {
|
|
295
|
+
const { body } = parseWorkflow(input.script);
|
|
296
|
+
validateBody(body);
|
|
297
|
+
const maxCalls = positiveOption(input.maxCalls, "maxCalls");
|
|
298
|
+
const timeoutMs = positiveOption(input.timeoutMs, "timeoutMs");
|
|
299
|
+
if (input.signal.aborted) throw input.signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
300
|
+
let calls = 0;
|
|
301
|
+
const pending = new Set<Promise<void>>();
|
|
302
|
+
const safeMessage = (error: unknown) => error instanceof Error ? error.message : String(error);
|
|
303
|
+
const effect = <T>(operation: () => Promise<T>) => Effect.flatMap(
|
|
304
|
+
Effect.promise(() => {
|
|
305
|
+
const settled = operation().then(
|
|
306
|
+
(value) => ({ ok: true as const, value }),
|
|
307
|
+
(error) => ({ ok: false as const, error }),
|
|
308
|
+
);
|
|
309
|
+
const completion = settled.then(() => {});
|
|
310
|
+
pending.add(completion);
|
|
311
|
+
void completion.then(() => pending.delete(completion));
|
|
312
|
+
return settled;
|
|
313
|
+
}),
|
|
314
|
+
(settled) => settled.ok ? Effect.succeed(settled.value) : Effect.fail(toolError(safeMessage(settled.error))),
|
|
315
|
+
);
|
|
316
|
+
const call = <T>(operation: () => Promise<T>) => effect(async () => {
|
|
317
|
+
calls++;
|
|
318
|
+
if (maxCalls !== undefined && calls > maxCalls) throw new Error(`Workflow call limit exceeded (${maxCalls})`);
|
|
319
|
+
return operation();
|
|
320
|
+
});
|
|
321
|
+
const tool = (description: string, schema: object, execute: (value: unknown) => Promise<unknown>, counted = true) =>
|
|
322
|
+
Tool.make({
|
|
323
|
+
description,
|
|
324
|
+
input: schema,
|
|
325
|
+
output: anySchema,
|
|
326
|
+
execute: (value) => counted ? call(() => execute(value)) : effect(() => execute(value)),
|
|
327
|
+
});
|
|
328
|
+
const runtime = CodeMode.make({
|
|
329
|
+
limits: { timeoutMs, maxOutputBytes: 1024 * 1024 },
|
|
330
|
+
tools: { runtime: {
|
|
331
|
+
args: tool("Return the workflow arguments", objectSchema({}), () => Promise.resolve(input.args), false),
|
|
332
|
+
agent: tool("Run an agent", objectSchema({ prompt: { type: "string" } }, ["prompt"]), async (value) => {
|
|
333
|
+
if (!isRecord(value) || typeof value.prompt !== "string") throw new TypeError("agent prompt must be a string");
|
|
334
|
+
return input.host.agent(value);
|
|
335
|
+
}),
|
|
336
|
+
phase: tool("Enter a workflow phase", objectSchema({ title: { type: "string" } }, ["title"]), async (value) => {
|
|
337
|
+
if (!isRecord(value) || typeof value.title !== "string") throw new TypeError("phase title must be a string");
|
|
338
|
+
await input.host.phase(value.title); return null;
|
|
339
|
+
}),
|
|
340
|
+
log: tool("Write a workflow log message", objectSchema({ message: { type: "string" } }, ["message"]), async (value) => {
|
|
341
|
+
if (!isRecord(value) || typeof value.message !== "string") throw new TypeError("log message must be a string");
|
|
342
|
+
await input.host.log(value.message); return null;
|
|
343
|
+
}),
|
|
344
|
+
checkpoint: tool("Request a checkpoint", objectSchema({ prompt: { type: "string" }, key: { type: "string" } }, ["prompt", "key"]), async (value) => {
|
|
345
|
+
if (!isRecord(value) || typeof value.prompt !== "string" || typeof value.key !== "string" || !value.key)
|
|
346
|
+
throw new TypeError("checkpoint requires string prompt and key");
|
|
347
|
+
return input.host.checkpoint(value);
|
|
348
|
+
}),
|
|
349
|
+
workflow: tool("Run a named workflow", objectSchema({ name: { type: "string" }, args: {} }, ["name"]), async (value) => {
|
|
350
|
+
if (!isRecord(value) || typeof value.name !== "string" || !value.name) throw new TypeError("workflow name must be a non-empty string");
|
|
351
|
+
return input.host.workflow({ name: value.name, ...(Object.hasOwn(value, "args") ? { args: value.args } : {}) });
|
|
352
|
+
}),
|
|
353
|
+
} },
|
|
354
|
+
});
|
|
355
|
+
let result: CodeMode.Result;
|
|
356
|
+
try {
|
|
357
|
+
result = await Effect.runPromise(runtime.execute(`${prelude}\n${body}`), { signal: input.signal });
|
|
358
|
+
} catch (error) {
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
if (!result.ok) throw new Error(`${result.error.kind}: ${result.error.message}`);
|
|
362
|
+
if (pending.size > 0) throw new Error("Workflow returned with unawaited host operations; await every agent and helper call");
|
|
363
|
+
if (result.truncated || result.warnings?.some((warning) => warning.kind === "Truncated" || warning.kind === "TimeoutExceeded"))
|
|
364
|
+
throw new Error("Workflow execution was truncated or timed out");
|
|
365
|
+
const failedBackgroundWork = result.warnings?.find((warning) => warning.kind === "ToolFailure" || warning.kind === "ExecutionFailure");
|
|
366
|
+
if (failedBackgroundWork) throw new Error(`${failedBackgroundWork.kind}: ${failedBackgroundWork.message}`);
|
|
367
|
+
return result.value;
|
|
368
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const WORKFLOW_MESSAGE_LIMIT_BYTES = 1024 * 1024;
|
|
2
|
+
export const WORKFLOW_DIAGNOSTIC_LIMIT = 8_192;
|
|
3
|
+
|
|
4
|
+
export type HostMethod = "agent" | "phase" | "log" | "checkpoint" | "workflow";
|
|
5
|
+
|
|
6
|
+
export type ParentToWorker =
|
|
7
|
+
| { type: "start"; script: string; args: unknown; maxCalls?: number; timeoutMs?: number }
|
|
8
|
+
| { type: "hostResult"; id: number; ok: true; value: unknown }
|
|
9
|
+
| { type: "hostResult"; id: number; ok: false; error: string };
|
|
10
|
+
|
|
11
|
+
export type WorkerToParent =
|
|
12
|
+
| { type: "hostCall"; id: number; method: HostMethod; input: unknown }
|
|
13
|
+
| { type: "result"; ok: true; value: unknown }
|
|
14
|
+
| { type: "result"; ok: false; error: string };
|
|
15
|
+
|
|
16
|
+
export function boundedDiagnostic(value: unknown): string {
|
|
17
|
+
const message = value instanceof Error ? value.message : String(value);
|
|
18
|
+
if (Buffer.byteLength(message) <= WORKFLOW_DIAGNOSTIC_LIMIT) return message;
|
|
19
|
+
const suffix = "… [truncated]";
|
|
20
|
+
const prefixBytes = WORKFLOW_DIAGNOSTIC_LIMIT - Buffer.byteLength(suffix);
|
|
21
|
+
let prefix = Buffer.from(message).subarray(0, prefixBytes).toString("utf8");
|
|
22
|
+
while (Buffer.byteLength(prefix + suffix) > WORKFLOW_DIAGNOSTIC_LIMIT) prefix = prefix.slice(0, -1);
|
|
23
|
+
return prefix + suffix;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function assertBoundedMessage(value: unknown, label: string): void {
|
|
27
|
+
let encoded: string;
|
|
28
|
+
try {
|
|
29
|
+
encoded = JSON.stringify(value);
|
|
30
|
+
} catch {
|
|
31
|
+
throw new Error(`${label} must be JSON serializable`);
|
|
32
|
+
}
|
|
33
|
+
if (encoded === undefined) encoded = "null";
|
|
34
|
+
if (Buffer.byteLength(encoded) > WORKFLOW_MESSAGE_LIMIT_BYTES)
|
|
35
|
+
throw new Error(`${label} exceeds ${WORKFLOW_MESSAGE_LIMIT_BYTES} bytes`);
|
|
36
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { executeWorkflowInterpreter, type WorkflowHost } from "./workflow-runtime-interpreter";
|
|
2
|
+
import { assertBoundedMessage, boundedDiagnostic, type HostMethod, type ParentToWorker, type WorkerToParent } from "./workflow-runtime-protocol";
|
|
3
|
+
|
|
4
|
+
declare const self: {
|
|
5
|
+
onmessage: ((event: MessageEvent<ParentToWorker>) => void) | null;
|
|
6
|
+
postMessage(message: WorkerToParent): void;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const controller = new AbortController();
|
|
10
|
+
const pending = new Map<number, { resolve(value: unknown): void; reject(error: unknown): void }>();
|
|
11
|
+
let nextID = 1;
|
|
12
|
+
let started = false;
|
|
13
|
+
|
|
14
|
+
function send(message: WorkerToParent): void {
|
|
15
|
+
self.postMessage(message);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function hostCall(method: HostMethod, input: unknown): Promise<unknown> {
|
|
19
|
+
assertBoundedMessage(input, `Workflow ${method} request`);
|
|
20
|
+
const id = nextID++;
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
pending.set(id, { resolve, reject });
|
|
23
|
+
send({ type: "hostCall", id, method, input });
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const host: WorkflowHost = {
|
|
28
|
+
agent: (input) => hostCall("agent", input),
|
|
29
|
+
phase: async (input) => { await hostCall("phase", input); },
|
|
30
|
+
log: async (input) => { await hostCall("log", input); },
|
|
31
|
+
checkpoint: (input) => hostCall("checkpoint", input),
|
|
32
|
+
workflow: (input) => hostCall("workflow", input),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
self.onmessage = (event) => {
|
|
36
|
+
const message = event.data;
|
|
37
|
+
if (message.type === "hostResult") {
|
|
38
|
+
const operation = pending.get(message.id);
|
|
39
|
+
if (!operation) return;
|
|
40
|
+
pending.delete(message.id);
|
|
41
|
+
if (message.ok) operation.resolve(message.value);
|
|
42
|
+
else operation.reject(new Error(message.error));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (message.type !== "start" || started) return;
|
|
46
|
+
started = true;
|
|
47
|
+
void (async () => {
|
|
48
|
+
try {
|
|
49
|
+
assertBoundedMessage(message.args, "Workflow arguments");
|
|
50
|
+
const value = await executeWorkflowInterpreter({
|
|
51
|
+
script: message.script,
|
|
52
|
+
args: message.args,
|
|
53
|
+
signal: controller.signal,
|
|
54
|
+
host,
|
|
55
|
+
maxCalls: message.maxCalls,
|
|
56
|
+
timeoutMs: message.timeoutMs,
|
|
57
|
+
});
|
|
58
|
+
assertBoundedMessage(value, "Workflow result");
|
|
59
|
+
send({ type: "result", ok: true, value });
|
|
60
|
+
} catch (error) {
|
|
61
|
+
send({ type: "result", ok: false, error: boundedDiagnostic(error) });
|
|
62
|
+
}
|
|
63
|
+
})();
|
|
64
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
export { parseWorkflow } from "./workflow-runtime-interpreter";
|
|
2
|
+
export type { WorkflowMeta } from "./workflow-runtime-interpreter";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
assertBoundedMessage,
|
|
6
|
+
boundedDiagnostic,
|
|
7
|
+
type HostMethod,
|
|
8
|
+
type ParentToWorker,
|
|
9
|
+
type WorkerToParent,
|
|
10
|
+
} from "./workflow-runtime-protocol";
|
|
11
|
+
|
|
12
|
+
export type WorkflowHost = {
|
|
13
|
+
agent(input: unknown, signal?: AbortSignal): Promise<unknown>;
|
|
14
|
+
phase(title: string, signal?: AbortSignal): Promise<void>;
|
|
15
|
+
log(message: string, signal?: AbortSignal): Promise<void>;
|
|
16
|
+
checkpoint(input: unknown, signal?: AbortSignal): Promise<unknown>;
|
|
17
|
+
workflow(input: { name: string; args?: unknown }, signal?: AbortSignal): Promise<unknown>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type ExecuteInput = {
|
|
21
|
+
script: string;
|
|
22
|
+
args: unknown;
|
|
23
|
+
signal: AbortSignal;
|
|
24
|
+
host: WorkflowHost;
|
|
25
|
+
maxCalls?: number;
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const MAX_RUNTIME_WORKERS = 64;
|
|
30
|
+
const HOST_DRAIN_GRACE_MS = 100;
|
|
31
|
+
let liveWorkers = 0;
|
|
32
|
+
|
|
33
|
+
function positiveOption(value: number | undefined, name: string): number | undefined {
|
|
34
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value < 1))
|
|
35
|
+
throw new RangeError(`${name} must be a positive integer`);
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function abortError(signal: AbortSignal): unknown {
|
|
40
|
+
return signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function boundedDrain(operations: Set<Promise<void>>): Promise<void> {
|
|
44
|
+
if (operations.size === 0) return;
|
|
45
|
+
await Promise.race([
|
|
46
|
+
Promise.allSettled([...operations]).then(() => undefined),
|
|
47
|
+
Bun.sleep(HOST_DRAIN_GRACE_MS),
|
|
48
|
+
]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Runs parsing and CodeMode execution in a terminable worker. Host effects remain
|
|
53
|
+
* in this process and receive an internal cancellation signal as an optional
|
|
54
|
+
* second argument. At most 64 interpreter calls may coexist; excess fan-out fails
|
|
55
|
+
* rather than waiting and deadlocking nested workflows.
|
|
56
|
+
*/
|
|
57
|
+
export async function executeWorkflow(input: ExecuteInput): Promise<unknown> {
|
|
58
|
+
const maxCalls = positiveOption(input.maxCalls, "maxCalls");
|
|
59
|
+
const timeoutMs = positiveOption(input.timeoutMs, "timeoutMs");
|
|
60
|
+
if (input.signal.aborted) throw abortError(input.signal);
|
|
61
|
+
if (liveWorkers >= MAX_RUNTIME_WORKERS) throw new Error(`Workflow runtime worker limit reached (${MAX_RUNTIME_WORKERS})`);
|
|
62
|
+
const worker = new Worker(new URL("./workflow-runtime-worker.ts", import.meta.url), { type: "module" });
|
|
63
|
+
liveWorkers++;
|
|
64
|
+
const hostController = new AbortController();
|
|
65
|
+
const operations = new Set<Promise<void>>();
|
|
66
|
+
let acceptingCalls = true;
|
|
67
|
+
let settled = false;
|
|
68
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
69
|
+
let resolveResult!: (value: unknown) => void;
|
|
70
|
+
let rejectResult!: (error: unknown) => void;
|
|
71
|
+
const result = new Promise<unknown>((resolve, reject) => { resolveResult = resolve; rejectResult = reject; });
|
|
72
|
+
|
|
73
|
+
const stop = (reason: unknown) => {
|
|
74
|
+
if (settled) return;
|
|
75
|
+
settled = true;
|
|
76
|
+
acceptingCalls = false;
|
|
77
|
+
hostController.abort(reason);
|
|
78
|
+
void worker.terminate();
|
|
79
|
+
rejectResult(reason);
|
|
80
|
+
};
|
|
81
|
+
const onAbort = () => stop(abortError(input.signal));
|
|
82
|
+
input.signal.addEventListener("abort", onAbort, { once: true });
|
|
83
|
+
if (timeoutMs !== undefined) {
|
|
84
|
+
timer = setTimeout(() => stop(new Error(`Workflow execution timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const invokeHost = (method: HostMethod, value: unknown): Promise<unknown> => {
|
|
88
|
+
switch (method) {
|
|
89
|
+
case "agent": return input.host.agent(value, hostController.signal);
|
|
90
|
+
case "phase": return input.host.phase(value as string, hostController.signal);
|
|
91
|
+
case "log": return input.host.log(value as string, hostController.signal);
|
|
92
|
+
case "checkpoint": return input.host.checkpoint(value, hostController.signal);
|
|
93
|
+
case "workflow": return input.host.workflow(value as { name: string; args?: unknown }, hostController.signal);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
worker.onmessage = (event: MessageEvent<WorkerToParent>) => {
|
|
98
|
+
const message = event.data;
|
|
99
|
+
if (message.type === "hostCall") {
|
|
100
|
+
if (!acceptingCalls) return;
|
|
101
|
+
const operation = Promise.resolve().then(() => invokeHost(message.method, message.input)).then(
|
|
102
|
+
(value) => {
|
|
103
|
+
if (!acceptingCalls) return;
|
|
104
|
+
try {
|
|
105
|
+
assertBoundedMessage(value, `Workflow ${message.method} response`);
|
|
106
|
+
worker.postMessage({ type: "hostResult", id: message.id, ok: true, value } satisfies ParentToWorker);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
worker.postMessage({ type: "hostResult", id: message.id, ok: false, error: `truncated: ${boundedDiagnostic(error)}` } satisfies ParentToWorker);
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
(error) => {
|
|
112
|
+
if (!acceptingCalls) return;
|
|
113
|
+
worker.postMessage({ type: "hostResult", id: message.id, ok: false, error: boundedDiagnostic(error) } satisfies ParentToWorker);
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
const tracked = operation.then(() => {}, () => {});
|
|
117
|
+
operations.add(tracked);
|
|
118
|
+
void tracked.finally(() => operations.delete(tracked));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (message.type !== "result" || settled) return;
|
|
122
|
+
settled = true;
|
|
123
|
+
acceptingCalls = false;
|
|
124
|
+
if (message.ok) resolveResult(message.value);
|
|
125
|
+
else {
|
|
126
|
+
hostController.abort(new Error(message.error));
|
|
127
|
+
rejectResult(new Error(message.error));
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
worker.onerror = (event: ErrorEvent) => stop(new Error(boundedDiagnostic(event.error ?? event.message)));
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
worker.postMessage({ type: "start", script: input.script, args: input.args, maxCalls, timeoutMs } satisfies ParentToWorker);
|
|
134
|
+
return await result;
|
|
135
|
+
} finally {
|
|
136
|
+
acceptingCalls = false;
|
|
137
|
+
input.signal.removeEventListener("abort", onAbort);
|
|
138
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
139
|
+
if (!hostController.signal.aborted) hostController.abort(new Error("Workflow runtime finished"));
|
|
140
|
+
void worker.terminate();
|
|
141
|
+
await boundedDrain(operations);
|
|
142
|
+
liveWorkers--;
|
|
143
|
+
}
|
|
144
|
+
}
|