Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
f5e9252
feat(workflow): isolate script execution in child process
Waishnav Jul 26, 2026
09a7fc4
test(workflow): cover sandbox termination and escapes
Waishnav Jul 26, 2026
12cee07
fix(workflow): keep sandbox values in vm realm
Waishnav Jul 27, 2026
d4efd02
fix(workflow): harden sandbox process teardown
Waishnav Jul 27, 2026
a78357a
fix(workflow): journal terminal transitions atomically
Waishnav Jul 26, 2026
5528337
feat(workflow): add shared lifecycle supervisor
Waishnav Jul 26, 2026
e0b078a
fix(workflow): supervise cancellation and stale workers
Waishnav Jul 26, 2026
244d42e
fix(workflow): close stale reaper races
Waishnav Jul 27, 2026
0b52e5e
fix(workflow): bound MCP event pages
Waishnav Jul 27, 2026
ed623da
feat(workflow): persist exact replay values
Waishnav Jul 26, 2026
749ebb7
fix(workflow): replay only deterministic call prefixes
Waishnav Jul 26, 2026
4b0802f
docs(workflow): explain deterministic prefix replay
Waishnav Jul 26, 2026
8f30225
test(workflow): cover oversized structured results
Waishnav Jul 27, 2026
287cbce
refactor(agents): share profile prompt identity helpers
Waishnav Jul 26, 2026
550f776
feat(workflow): select configured agent profiles
Waishnav Jul 26, 2026
229f282
fix(workflow): honor nested workflow context
Waishnav Jul 26, 2026
7675c14
fix(workflow): confine project workflow scripts
Waishnav Jul 26, 2026
b9c78eb
fix(workflow): parse module syntax without raw scans
Waishnav Jul 26, 2026
323fe50
docs(workflow): teach profile selection and project scripts
Waishnav Jul 26, 2026
a23be1c
fix(workflow): preserve resume names and typed reads
Waishnav Jul 26, 2026
94ac40d
fix(workflow): parse metadata as pure literals
Waishnav Jul 27, 2026
3f01013
test(workflow): cover path and profile identity guards
Waishnav Jul 27, 2026
620b180
refactor(config): remove experimental provider persistence
Waishnav Jul 27, 2026
d640cf8
refactor(agents): share execution target resolution
Waishnav Jul 27, 2026
f2482bb
refactor(agents): describe provider model and effort capabilities
Waishnav Jul 27, 2026
321b81c
refactor(features): separate subagent and workflow capabilities
Waishnav Jul 27, 2026
69fee30
fix(workspace): expose only usable agent capabilities
Waishnav Jul 27, 2026
f25d8e9
refactor(skills): replace subagent delegation guidance
Waishnav Jul 27, 2026
2f17478
docs(skills): add provider-specific override references
Waishnav Jul 27, 2026
f214c19
fix(skills): keep bundled agent skills package-managed
Waishnav Jul 27, 2026
1ace1f0
refactor(workflow): name live provider availability accurately
Waishnav Jul 27, 2026
5c1ddea
docs(agents): document experimental capability boundaries
Waishnav Jul 27, 2026
691998e
test(workflow): follow provider availability rename
Waishnav Jul 27, 2026
5bf5374
docs(config): describe effective agent capability gates
Waishnav Jul 27, 2026
562e54a
feat(agents): discover usable delegation targets
Waishnav Jul 27, 2026
b347a82
feat(workflow): merge dw-16 agent/subagent overhaul (#118)
Waishnav Jul 27, 2026
b677c3e
feat(workflow): merge dw-15 profiles and project workflows (#117)
Waishnav Jul 27, 2026
8ce8274
Merge pull request #116 from pr/dw-14-prefix-replay-integrity
Waishnav Jul 27, 2026
0b0f5e2
Merge pull request #115 from pr/dw-13-workflow-lifecycle-supervisor
Waishnav Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/workflow-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export async function executeWorkflow(
parsed,
api,
timeoutMs: options.timeoutMs ?? WORKFLOW_HOST_TIMEOUT_MS,
signal,
});
return {
result,
Expand Down Expand Up @@ -180,6 +181,7 @@ async function executeNestedOnApi(input: {
parsed,
api: childApi,
timeoutMs: input.parentOptions.timeoutMs ?? WORKFLOW_HOST_TIMEOUT_MS,
signal: input.parentOptions.signal,
});
}

Expand Down
292 changes: 292 additions & 0 deletions src/workflow-sandbox-child.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
import vm from "node:vm";
import { parseWorkflowScript } from "./workflow-script.js";
import type { JsonValue } from "./json-types.js";
import { WORKFLOW_MAX_ITEMS } from "./workflow-types.js";

type SandboxMethod = "agent" | "workflow" | "phase" | "log";

interface StartMessage {
type: "start";
source: string;
filename: string;
args: JsonValue | undefined;
budget: {
total: number | null;
spent: number;
remaining: number;
};
}

interface CallResultMessage {
type: "call_result";
id: number;
value?: unknown;
error?: SerializedError;
}

interface SerializedError {
name: string;
message: string;
stack?: string;
kind?: string;
}

interface BridgeSuccessEnvelope {
ok: true;
value?: unknown;
}

interface BridgeErrorEnvelope {
ok: false;
error: SerializedError;
}

type BridgeEnvelope = BridgeSuccessEnvelope | BridgeErrorEnvelope;

let nextCallId = 1;
const pending = new Map<
number,
{ resolve(value: string): void }
>();

process.on("message", (message: StartMessage | CallResultMessage) => {
if (message.type === "call_result") {
const waiter = pending.get(message.id);
if (!waiter) return;
pending.delete(message.id);
const envelope: BridgeEnvelope = message.error
? { ok: false, error: message.error }
: { ok: true, value: message.value };
waiter.resolve(JSON.stringify(envelope));
return;
}
void execute(message);
});

async function execute(message: StartMessage): Promise<void> {
try {
const parsed = parseWorkflowScript(message.source, { filename: message.filename });
const bridge = (method: SandboxMethod, args: unknown[]): unknown => {
if (method === "phase" || method === "log") {
process.send?.({ type: "notify", method, args });
return undefined;
}
const id = nextCallId;
nextCallId += 1;
process.send?.({ type: "call", id, method, args });
return new Promise<string>((resolve) => {
pending.set(id, { resolve });
});
};

const context = vm.createContext({ __workflowBridge: bridge });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
installContextApi(context, message);
const factory = parsed.script.runInContext(context, {
timeout: 5_000,
displayErrors: true,
}) as () => Promise<unknown>;
if (typeof factory !== "function") {
throw new Error("Workflow script did not compile to a function");
}
const value = await factory();
process.send?.({ type: "result", value }, () => disconnect());
} catch (error) {
process.send?.({ type: "error", error: serializeError(error) }, () => disconnect());
}
}

function installContextApi(context: vm.Context, message: StartMessage): void {
const bootstrap = `(() => {
const bridge = globalThis.__workflowBridge;
delete globalThis.__workflowBridge;

class WorkflowDeterminismError extends Error {
constructor(message) {
super(message);
this.name = "WorkflowDeterminismError";
}
}

class WorkflowEngineError extends Error {
constructor(kind, message) {
super(message);
this.name = "WorkflowEngineError";
this.kind = kind;
}
}

Object.defineProperty(Math, "random", {
configurable: false,
writable: false,
value() {
throw new WorkflowDeterminismError("Math.random() is banned in workflow scripts");
},
});

const RealDate = Date;
function DateShim(...dateArgs) {
if (!new.target) {
throw new WorkflowDeterminismError("Date() is banned in workflow scripts");
}
if (dateArgs.length === 0) {
throw new WorkflowDeterminismError(
"new Date() without arguments is banned in workflow scripts (pass an ISO string)",
);
}
return Reflect.construct(RealDate, dateArgs, DateShim);
}
DateShim.now = () => {
throw new WorkflowDeterminismError("Date.now() is banned in workflow scripts");
};
DateShim.parse = RealDate.parse.bind(RealDate);
DateShim.UTC = RealDate.UTC.bind(RealDate);
DateShim.prototype = Object.create(RealDate.prototype, {
constructor: {
value: DateShim,
writable: false,
configurable: false,
},
});
Object.freeze(DateShim.prototype);
Object.freeze(DateShim);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const rehydrateError = (input) => {
const error = input?.name === "WorkflowDeterminismError"
? new WorkflowDeterminismError(input.message)
: input?.name === "WorkflowEngineError" && typeof input.kind === "string"
? new WorkflowEngineError(input.kind, input.message)
: new Error(input?.message ?? "Workflow bridge call failed");
if (typeof input?.name === "string") error.name = input.name;
if (typeof input?.stack === "string") error.stack = input.stack;
return error;
};
const call = (method, callArgs) => new Promise((resolve, reject) => {
bridge(method, callArgs).then(
(payloadJson) => {
let payload;
try {
payload = JSON.parse(payloadJson);
} catch {
reject(new WorkflowEngineError("internal", "Workflow bridge returned invalid JSON"));
return;
}
if (payload?.ok === true) resolve(payload.value);
else reject(rehydrateError(payload?.error));
},
() => reject(new WorkflowEngineError("internal", "Workflow bridge call failed")),
);
});
const agent = (...callArgs) => call("agent", callArgs);
const workflow = (...callArgs) => call("workflow", callArgs);
const phase = (title) => {
if (typeof title !== "string" || !title.trim()) {
throw new WorkflowEngineError("internal", "phase(title) requires a non-empty string");
}
return bridge("phase", [title]);
};
const log = (...callArgs) => bridge("log", callArgs);
const parallel = async (tasks) => {
if (!Array.isArray(tasks)) {
throw new WorkflowEngineError("internal", "parallel(thunks) requires an array of functions");
}
if (tasks.length > ${WORKFLOW_MAX_ITEMS}) {
throw new WorkflowEngineError(
"internal",
"parallel exceeds max items ${WORKFLOW_MAX_ITEMS} (got " + tasks.length + ")",
);
}
return Promise.all(tasks.map(async (task, index) => {
if (typeof task !== "function") {
throw new WorkflowEngineError(
"internal",
"parallel thunks[" + index + "] must be a function",
);
}
try { return await task(); } catch { return null; }
}));
};
const pipeline = async (items, ...stages) => {
if (!Array.isArray(items)) {
throw new WorkflowEngineError(
"internal",
"pipeline(items, ...stages) requires an items array",
);
}
if (items.length > ${WORKFLOW_MAX_ITEMS}) {
throw new WorkflowEngineError(
"internal",
"pipeline exceeds max items ${WORKFLOW_MAX_ITEMS} (got " + items.length + ")",
);
}
for (let index = 0; index < stages.length; index += 1) {
if (typeof stages[index] !== "function") {
throw new WorkflowEngineError(
"internal",
"pipeline stage[" + index + "] must be a function",
);
}
}
return Promise.all(items.map(async (item, index) => {
let value = item;
for (const stage of stages) {
try { value = await stage(value, item, index); } catch { return null; }
}
return value;
}));
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const args = JSON.parse(${JSON.stringify(JSON.stringify(message.args ?? null))});
const budget = Object.freeze({
total: ${JSON.stringify(message.budget.total)},
spent: () => ${JSON.stringify(message.budget.spent)},
remaining: () => ${String(message.budget.remaining)},
});
const stringifyConsoleArg = (value) => {
if (typeof value === "string") return value;
try { return JSON.stringify(value); } catch { return String(value); }
};
const consoleLine = (...callArgs) => log(callArgs.map(stringifyConsoleArg).join(" "));
const console = Object.freeze({
log: consoleLine,
warn: consoleLine,
error: consoleLine,
info: consoleLine,
debug: consoleLine,
});

Object.defineProperties(globalThis, {
agent: { value: Object.freeze(agent), writable: false, configurable: false },
workflow: { value: Object.freeze(workflow), writable: false, configurable: false },
phase: { value: Object.freeze(phase), writable: false, configurable: false },
log: { value: Object.freeze(log), writable: false, configurable: false },
parallel: { value: Object.freeze(parallel), writable: false, configurable: false },
pipeline: { value: Object.freeze(pipeline), writable: false, configurable: false },
args: { value: Object.freeze(args), writable: false, configurable: false },
budget: { value: budget, writable: false, configurable: false },
console: { value: console, writable: false, configurable: false },
Date: { value: DateShim, writable: false, configurable: false },
});
})()`;
vm.runInContext(bootstrap, context, { timeout: 5_000 });
}

function serializeError(error: unknown): SerializedError {
if (!error || typeof error !== "object") {
return { name: "Error", message: String(error) };
}
const record = error as {
name?: unknown;
message?: unknown;
stack?: unknown;
kind?: unknown;
};
return {
name: typeof record.name === "string" ? record.name : "Error",
message: typeof record.message === "string" ? record.message : String(error),
stack: typeof record.stack === "string" ? record.stack : undefined,
kind: typeof record.kind === "string" ? record.kind : undefined,
};
}

function disconnect(): void {
if (process.connected) process.disconnect?.();
}
Loading
Loading