diff --git a/src/tests/workflow/executor.integration.test.ts b/src/tests/workflow/executor.integration.test.ts new file mode 100644 index 0000000..0d9ff51 --- /dev/null +++ b/src/tests/workflow/executor.integration.test.ts @@ -0,0 +1,266 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { runWorkflow } from "../../workflow/executor.js"; +import type { + EndpointInfo, + RunWorkflowOptions, + WorkflowClient, + WorkflowServer, +} from "../../workflow/executor.js"; +import type { WorkflowDefinition, WorkflowStep } from "../../workflow/types.js"; + +const endpoints: Record = { + "channels.list": { method: "GET", path: "/api/v1/channels.list" }, + "chat.postMessage": { method: "POST", path: "/api/v1/chat.postMessage" }, +}; + +function makeClient( + handler: ( + method: string, + path: string, + body?: Record, + ) => unknown, +): { client: WorkflowClient; calls: Array<{ method: string; path: string; body?: Record }> } { + const calls: Array<{ method: string; path: string; body?: Record }> = []; + const client: WorkflowClient = { + async request(method, path, options) { + calls.push({ method, path, body: options?.body }); + return { ok: true, status: 200, data: handler(method, path, options?.body) }; + }, + }; + return { client, calls }; +} + +const silentServer: WorkflowServer = { + async createMessage() { + return { content: { type: "text", text: "" } }; + }, +}; + +function options(client: WorkflowClient, server: WorkflowServer = silentServer): RunWorkflowOptions { + return { client, server, endpoints }; +} + +function workflow(steps: WorkflowStep[]): WorkflowDefinition { + return { + name: "test", + description: "test workflow", + params: { type: "object" }, + steps, + requiredEndpoints: Object.keys(endpoints), + usesSampling: false, + usesElicitation: false, + }; +} + +describe("runWorkflow", () => { + it("runs a single api_call and stores its result", async () => { + const { client, calls } = makeClient(() => ({ channels: [{ _id: "1" }] })); + const result = await runWorkflow( + workflow([ + { + id: "list", + label: "List channels", + config: { type: "api_call", operationId: "channels.list", inputMapping: {} }, + }, + ]), + {}, + options(client), + ); + + assert.equal(result.status, "success"); + assert.deepEqual(result.completedSteps, ["list"]); + assert.deepEqual(result.stepResults.list, { channels: [{ _id: "1" }] }); + assert.equal(calls[0].method, "GET"); + }); + + it("flows data between steps via templates", async () => { + const { client, calls } = makeClient((_m, path, body) => + path.includes("channels.list") ? { channels: [{ _id: "room42" }] } : { ok: true, echoed: body }, + ); + + const result = await runWorkflow( + workflow([ + { + id: "list", + label: "List", + config: { type: "api_call", operationId: "channels.list", inputMapping: {}, outputPath: "channels" }, + }, + { + id: "post", + label: "Post", + dependsOn: ["list"], + config: { + type: "api_call", + operationId: "chat.postMessage", + inputMapping: { roomId: "{{steps.list[0]._id}}", text: "hi" }, + }, + }, + ]), + {}, + options(client), + ); + + assert.equal(result.status, "success"); + const postCall = calls.find((c) => c.path.includes("postMessage"))!; + assert.equal(postCall.body?.roomId, "room42"); + }); + + it("evaluates a transform step", async () => { + const { client } = makeClient(() => ({ value: 10 })); + const result = await runWorkflow( + workflow([ + { + id: "fetch", + label: "Fetch", + config: { type: "api_call", operationId: "channels.list", inputMapping: {} }, + }, + { + id: "double", + label: "Double", + dependsOn: ["fetch"], + config: { type: "transform", expression: "steps.fetch.value * 2" }, + }, + ]), + {}, + options(client), + ); + assert.equal(result.stepResults.double, 20); + }); + + it("skips the not-taken conditional branch", async () => { + const { client } = makeClient(() => ({})); + const def = workflow([ + { + id: "cond", + label: "Check", + config: { type: "conditional", condition: "params.flag === true", thenStep: "yes", elseStep: "no" }, + }, + { + id: "yes", + label: "Yes branch", + dependsOn: ["cond"], + config: { type: "transform", expression: "'yes'" }, + }, + { + id: "no", + label: "No branch", + dependsOn: ["cond"], + config: { type: "transform", expression: "'no'" }, + }, + ]); + + const taken = await runWorkflow(def, { flag: true }, options(client)); + assert.equal(taken.stepResults.yes, "yes"); + assert.equal(taken.completedSteps.includes("no"), false); + + const notTaken = await runWorkflow(def, { flag: false }, options(client)); + assert.equal(notTaken.stepResults.no, "no"); + assert.equal(notTaken.completedSteps.includes("yes"), false); + }); + + it("fans out a forEach api_call", async () => { + const { client, calls } = makeClient((_m, _p, body) => ({ posted: body?.roomId })); + const result = await runWorkflow( + workflow([ + { + id: "post", + label: "Post to each", + config: { + type: "api_call", + operationId: "chat.postMessage", + inputMapping: { roomId: "{{room}}" }, + forEach: "params.rooms", + as: "room", + }, + }, + ]), + { rooms: ["r1", "r2", "r3"] }, + options(client), + ); + + assert.equal(result.status, "success"); + assert.equal((result.stepResults.post as unknown[]).length, 3); + assert.equal(calls.length, 3); + }); + + it("parses JSON sampling output by intent", async () => { + const server: WorkflowServer = { + async createMessage() { + return { content: { type: "text", text: 'Here you go: {"summary":"done"}' } }; + }, + }; + const { client } = makeClient(() => ({})); + const result = await runWorkflow( + workflow([ + { + id: "sum", + label: "Summarize", + config: { type: "sampling", prompt: "Respond with a JSON object", responseFormat: "json" }, + }, + ]), + {}, + { client, server, endpoints }, + ); + assert.deepEqual(result.stepResults.sum, { summary: "done" }); + }); + + it("aborts on declined elicitation", async () => { + const server: WorkflowServer = { + async createMessage() { + return { content: { type: "text", text: "" } }; + }, + async elicitInput() { + return { action: "decline" }; + }, + }; + const { client } = makeClient(() => ({})); + const result = await runWorkflow( + workflow([ + { + id: "ask", + label: "Confirm", + config: { type: "elicitation", message: "ok?", requestedSchema: { type: "object" }, onDecline: "abort" }, + }, + ]), + {}, + { client, server, endpoints }, + ); + assert.equal(result.status, "aborted"); + }); + + it("reports an error result when a step fails", async () => { + const client: WorkflowClient = { + async request() { + return { ok: false, status: 500, data: "boom" }; + }, + }; + const result = await runWorkflow( + workflow([ + { + id: "list", + label: "List", + config: { type: "api_call", operationId: "channels.list", inputMapping: {} }, + }, + ]), + {}, + options(client), + ); + assert.equal(result.status, "error"); + assert.match(result.error ?? "", /failed/); + }); + + it("rejects duplicate step ids", async () => { + const { client } = makeClient(() => ({})); + const result = await runWorkflow( + workflow([ + { id: "x", label: "A", config: { type: "transform", expression: "1" } }, + { id: "x", label: "B", config: { type: "transform", expression: "2" } }, + ]), + {}, + options(client), + ); + assert.equal(result.status, "error"); + assert.match(result.error ?? "", /Duplicate step id/); + }); +}); diff --git a/src/tests/workflow/path-params-and-foreach.integration.test.ts b/src/tests/workflow/path-params-and-foreach.integration.test.ts new file mode 100644 index 0000000..9b1ef53 --- /dev/null +++ b/src/tests/workflow/path-params-and-foreach.integration.test.ts @@ -0,0 +1,243 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { runWorkflow } from "../../workflow/executor.js"; +import type { + ApiResponse, + EndpointInfo, + RunWorkflowOptions, + WorkflowClient, + WorkflowServer, +} from "../../workflow/executor.js"; +import type { WorkflowDefinition, WorkflowStep } from "../../workflow/types.js"; + +const endpoints: Record = { + // Path-parameter endpoints (the case Rocket.Chat uses heavily). + "apps.info": { method: "GET", path: "/api/apps/public/{app-id}/info" }, + "apps.incoming": { + method: "POST", + path: "/api/apps/public/{app-id}/incoming", + }, + // Plain endpoint for forEach fan-out. + "channels.archive": { method: "POST", path: "/api/v1/channels.archive" }, +}; + +interface Call { + method: string; + path: string; + body?: Record; +} + +function makeClient(handler: (call: Call) => ApiResponse): { + client: WorkflowClient; + calls: Call[]; +} { + const calls: Call[] = []; + const client: WorkflowClient = { + async request(method, path, options) { + const call: Call = { method, path, body: options?.body }; + calls.push(call); + return handler(call); + }, + }; + return { client, calls }; +} + +const silentServer: WorkflowServer = { + async createMessage() { + return { content: { type: "text", text: "" } }; + }, +}; + +function options(client: WorkflowClient): RunWorkflowOptions { + return { client, server: silentServer, endpoints }; +} + +function workflow(steps: WorkflowStep[]): WorkflowDefinition { + return { + name: "test", + description: "test workflow", + params: { type: "object" }, + steps, + requiredEndpoints: Object.keys(endpoints), + usesSampling: false, + usesElicitation: false, + }; +} + +const ok = (data: unknown): ApiResponse => ({ ok: true, status: 200, data }); + +describe("path parameter substitution", () => { + it("substitutes {param} into a GET path and drops it from the query string", async () => { + const { client, calls } = makeClient(() => ok({ name: "my-app" })); + const result = await runWorkflow( + workflow([ + { + id: "info", + label: "App info", + config: { + type: "api_call", + operationId: "apps.info", + inputMapping: { "app-id": "{{params.appId}}", lang: "en" }, + }, + }, + ]), + { appId: "abc123" }, + options(client), + ); + + assert.equal(result.status, "success"); + // Placeholder is gone, value is substituted... + assert.equal(calls[0].path, "/api/apps/public/abc123/info?lang=en"); + assert.ok(!calls[0].path.includes("{app-id}")); + // ...and the path param is NOT duplicated as a query field. + assert.ok(!calls[0].path.includes("app-id=")); + }); + + it("substitutes {param} into a POST path without sending it in the body", async () => { + const { client, calls } = makeClient(() => ok({ ok: true })); + const result = await runWorkflow( + workflow([ + { + id: "send", + label: "Send incoming", + config: { + type: "api_call", + operationId: "apps.incoming", + inputMapping: { "app-id": "{{params.appId}}", text: "hello" }, + }, + }, + ]), + { appId: "APP42" }, + options(client), + ); + + assert.equal(result.status, "success"); + assert.equal(calls[0].path, "/api/apps/public/APP42/incoming"); + assert.deepEqual(calls[0].body, { text: "hello" }); + assert.ok(!("app-id" in (calls[0].body ?? {}))); + }); + + it("URL-encodes path parameter values", async () => { + const { client, calls } = makeClient(() => ok({})); + await runWorkflow( + workflow([ + { + id: "info", + label: "App info", + config: { + type: "api_call", + operationId: "apps.info", + inputMapping: { "app-id": "{{params.appId}}" }, + }, + }, + ]), + { appId: "a/b c" }, + options(client), + ); + assert.equal(calls[0].path, "/api/apps/public/a%2Fb%20c/info"); + }); + + it("errors when a required path parameter is missing", async () => { + const { client } = makeClient(() => ok({})); + const result = await runWorkflow( + workflow([ + { + id: "send", + label: "Send incoming", + config: { + type: "api_call", + operationId: "apps.incoming", + inputMapping: { "app-id": "{{params.doesNotExist}}", text: "hi" }, + }, + }, + ]), + {}, + options(client), + ); + + assert.equal(result.status, "error"); + assert.match( + result.error ?? "", + /missing required path parameter "app-id"/, + ); + }); +}); + +describe("forEach failure policy", () => { + const archiveSteps = (continueOnError?: boolean): WorkflowStep[] => [ + { + id: "archive", + label: "Archive each room", + config: { + type: "api_call", + operationId: "channels.archive", + inputMapping: { roomId: "{{room}}" }, + forEach: "params.rooms", + as: "room", + ...(continueOnError !== undefined ? { continueOnError } : {}), + }, + }, + ]; + + // A client that fails only for room "r2". + const partialClient = () => + makeClient((call): ApiResponse => { + if (call.body?.roomId === "r2") { + return { ok: false, status: 500, data: "boom" }; + } + return ok({ archived: call.body?.roomId }); + }); + + it("fails the whole step by default when any iteration fails", async () => { + const { client } = partialClient(); + const result = await runWorkflow( + workflow(archiveSteps()), + { rooms: ["r1", "r2", "r3"] }, + options(client), + ); + + assert.equal(result.status, "error"); + assert.match(result.error ?? "", /failed iteration/); + // The failed step is not reported as completed. + assert.ok(!result.completedSteps.includes("archive")); + }); + + it("continues and reports partial success when continueOnError is set", async () => { + const { client, calls } = partialClient(); + const result = await runWorkflow( + workflow(archiveSteps(true)), + { rooms: ["r1", "r2", "r3"] }, + options(client), + ); + + // All iterations were attempted. + assert.equal(calls.length, 3); + // Overall status is explicitly partial, with per-step error detail. + assert.equal(result.status, "partial"); + assert.match( + result.stepErrors?.archive ?? "", + /1\/3 iteration\(s\) failed/, + ); + + const results = result.stepResults.archive as unknown[]; + assert.equal(results.length, 3); + assert.deepEqual(results[0], { archived: "r1" }); + assert.equal(results[1], null); // failed item is an explicit null + assert.deepEqual(results[2], { archived: "r3" }); + }); + + it("reports plain success when every iteration succeeds", async () => { + const { client } = makeClient((call) => + ok({ archived: call.body?.roomId }), + ); + const result = await runWorkflow( + workflow(archiveSteps(true)), + { rooms: ["r1", "r2"] }, + options(client), + ); + + assert.equal(result.status, "success"); + assert.equal(result.stepErrors, undefined); + assert.equal((result.stepResults.archive as unknown[]).length, 2); + }); +}); diff --git a/src/tests/workflow/sandbox-security.unit.test.ts b/src/tests/workflow/sandbox-security.unit.test.ts new file mode 100644 index 0000000..14fc810 --- /dev/null +++ b/src/tests/workflow/sandbox-security.unit.test.ts @@ -0,0 +1,98 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + evaluateExpression, + evaluateCondition, + resolveValue, +} from "../../workflow/templates.js"; + +const params = { name: "Ada", count: 3, ids: ["a", "b"] }; +const steps = { first: { value: 42 } }; + +describe("expression sandbox security", () => { + describe("known escape patterns are rejected", () => { + const escapes: Array<[string, string]> = [ + ["constructor walk", "({}).constructor.constructor('return 1')()"], + ["function constructor", "(function(){}).constructor('return process')()"], + ["__proto__ access", "params.__proto__"], + ["prototype access", "params.constructor.prototype"], + ["process reference", "process.exit(1)"], + ["global reference", "global.process"], + ["globalThis reference", "globalThis.process"], + ["require call", "require('fs')"], + ["eval call", "eval('1+1')"], + ["import call", "import('fs')"], + ["this escape", "this.constructor.constructor('return this')()"], + ]; + + for (const [label, expr] of escapes) { + it(`rejects ${label}`, () => { + assert.throws( + () => evaluateExpression(expr, params, steps), + /Unsafe/, + `expected "${expr}" to be rejected`, + ); + }); + } + }); + + describe("safe expressions still evaluate", () => { + it("reads params and steps", () => { + assert.equal(evaluateExpression("params.name", params, steps), "Ada"); + assert.equal(evaluateExpression("steps.first.value", params, steps), 42); + }); + + it("exposes params as bare identifiers", () => { + assert.equal(evaluateExpression("name", params, steps), "Ada"); + }); + + it("allows approved array/string methods", () => { + assert.deepEqual( + evaluateExpression("ids.map((x) => x.toUpperCase())", params, steps), + ["A", "B"], + ); + }); + + it("allows Math and JSON", () => { + assert.equal(evaluateExpression("Math.max(1, count)", params, steps), 3); + assert.equal( + evaluateExpression("JSON.stringify({ a: 1 })", params, steps), + '{"a":1}', + ); + }); + }); + + describe("evaluateCondition", () => { + it("coerces to boolean", () => { + assert.equal(evaluateCondition("count > 2", params, steps), true); + assert.equal(evaluateCondition("count > 5", params, steps), false); + }); + + it("rejects unsafe conditions", () => { + assert.throws( + () => evaluateCondition("({}).constructor", params, steps), + /Unsafe/, + ); + }); + }); + + describe("resolveValue type preservation", () => { + it("keeps native type for a sole placeholder", () => { + assert.equal(resolveValue("{{steps.first.value}}", params, steps), 42); + }); + + it("interpolates mixed strings", () => { + assert.equal( + resolveValue("Hello {{params.name}}!", params, steps), + "Hello Ada!", + ); + }); + + it("recurses into objects and arrays", () => { + assert.deepEqual( + resolveValue({ who: "{{params.name}}", n: "{{params.count}}" }, params, steps), + { who: "Ada", n: 3 }, + ); + }); + }); +}); diff --git a/src/workflow/api-call.ts b/src/workflow/api-call.ts new file mode 100644 index 0000000..e4c9b79 --- /dev/null +++ b/src/workflow/api-call.ts @@ -0,0 +1,243 @@ +import type { ApiCallStep, WorkflowStep } from "./types.js"; +import type { + EndpointInfo, + ExecutionState, + WorkflowClient, +} from "./executor.js"; +import { evaluateExpression, resolveMapping } from "./templates.js"; + +const DEFAULT_FOREACH_CONCURRENCY = 5; +const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10 MB + +/** Walk a dotted path into a parsed value, returning null on any miss. */ +export function extractPath(value: unknown, path: string): unknown { + return path.split(".").reduce((acc, key) => { + if (acc != null && typeof acc === "object" && !Array.isArray(acc)) { + return (acc as Record)[key] ?? null; + } + return null; + }, value); +} + +/** + * Substitute `{name}` placeholders in an endpoint path with values from the + * payload, URL-encoding each value and removing the consumed keys so the same + * values are not also sent as query params (GET) or body fields (writes). + * + * OpenAPI path parameters are required by definition, so a placeholder with no + * usable payload value throws — surfacing a malformed request early instead of + * sending `/api/apps/public/{app-id}/incoming` verbatim to the server. + */ +function substitutePathParams( + path: string, + payload: Record, + operationId: string, +): string { + return path.replace(/\{([^}]+)\}/g, (_match, rawName: string) => { + const name = rawName.trim(); + const value = payload[name]; + if (value === undefined || value === null || value === "") { + throw new Error( + `API call "${operationId}" is missing required path parameter "${name}".`, + ); + } + delete payload[name]; + return typeof value === "object" + ? encodeURIComponent(JSON.stringify(value)) + : encodeURIComponent(String(value)); + }); +} + +/** Build a `path?query` string from a payload for GET requests. */ +function buildGetUrl(path: string, payload: Record): string { + const search = new URLSearchParams(); + for (const [k, v] of Object.entries(payload)) { + search.append( + k, + typeof v === "object" && v !== null ? JSON.stringify(v) : String(v ?? ""), + ); + } + const query = search.toString(); + return query ? `${path}?${query}` : path; +} + +/** + * Drop optional params that resolved to empty because their referenced source + * does not exist; throw when a referenced source exists but is empty/broken so + * the failure is visible rather than silently producing a bad request. + */ +function pruneEmptyParams( + step: ApiCallStep, + payload: Record, + state: ExecutionState, +): void { + for (const [key, raw] of Object.entries(step.inputMapping)) { + if (typeof raw !== "string" || !raw.includes("{{")) continue; + const resolved = payload[key]; + if (resolved !== "" && resolved !== undefined && resolved !== null) continue; + + const paramMatch = raw.match(/\{\{\s*params\.(\w+)/); + const stepMatch = raw.match(/\{\{\s*steps\.(\w+)/); + if (paramMatch && !(paramMatch[1] in state.params)) { + delete payload[key]; + } else if (stepMatch && !(stepMatch[1] in state.steps)) { + delete payload[key]; + } else { + throw new Error( + `Parameter "${key}" resolved to empty (template: ${raw}).`, + ); + } + } +} + +async function callOnce( + step: ApiCallStep, + payload: Record, + state: ExecutionState, + client: WorkflowClient, + endpoints: Record, +): Promise { + const endpoint: EndpointInfo | undefined = endpoints[step.operationId]; + const method = (endpoint?.method || "GET").toUpperCase(); + const rawPath = endpoint?.path || ""; + + pruneEmptyParams(step, payload, state); + + // Replace `{param}` placeholders in the path from the payload and drop those + // keys so they are not duplicated into the query string or request body. + const path = substitutePathParams(rawPath, payload, step.operationId); + + if (method === "GET") { + // Decode JSON-looking string values so they ride along as query params. + for (const [k, v] of Object.entries(payload)) { + if (typeof v === "string" && /^[[{]/.test(v)) { + try { + payload[k] = JSON.parse(v); + } catch { + // keep as string + } + } + } + } + + const response = await client.request( + method, + method === "GET" ? buildGetUrl(path, payload) : path, + { auth: true, ...(method !== "GET" ? { body: payload } : {}) }, + ); + + if (!response.ok) { + const detail = + typeof response.data === "string" + ? response.data + : JSON.stringify(response.data); + throw new Error( + `API call "${step.operationId}" failed (status ${response.status}): ${detail}`, + ); + } + + if (typeof response.data === "string" && response.data.length > MAX_RESPONSE_BYTES) { + throw new Error( + `API response for "${step.operationId}" exceeds the 10 MB limit.`, + ); + } + + return step.outputPath ? extractPath(response.data, step.outputPath) : response.data; +} + +/** Execute an `api_call` step, including its optional `forEach` fan-out. */ +export async function executeApiCall( + step: WorkflowStep, + state: ExecutionState, + client: WorkflowClient, + endpoints: Record, + maxForEachIterations: number, +): Promise { + const config = step.config as ApiCallStep; + + if (config.forEach && config.as) { + const itemVar = config.as; + // The composer stores forEach as a bare expression (template braces are + // stripped during inference); evaluate it directly. Tolerate residual + // `{{ }}` wrapping defensively. + const expr = config.forEach.trim(); + const cleaned = + expr.startsWith("{{") && expr.endsWith("}}") + ? expr.slice(2, -2).trim() + : expr; + let raw: unknown; + try { + raw = evaluateExpression(cleaned, state.params, state.steps); + } catch { + raw = []; + } + const collection = Array.isArray(raw) ? raw : []; + if (collection.length > maxForEachIterations) { + throw new Error( + `Step "${step.id}" forEach has ${collection.length} items, ` + + `exceeding the maximum of ${maxForEachIterations}.`, + ); + } + + const results: unknown[] = new Array(collection.length).fill(null); + const failures: Array<{ index: number; error: string }> = []; + for (let i = 0; i < collection.length; i += DEFAULT_FOREACH_CONCURRENCY) { + const slice = collection.slice(i, i + DEFAULT_FOREACH_CONCURRENCY); + const settled = await Promise.allSettled( + slice.map((item) => { + const locals = { [itemVar]: item }; + const augmentedSteps = { ...state.steps, [itemVar]: item }; + const payload = resolveMapping( + config.inputMapping, + state.params, + augmentedSteps, + locals, + ); + return callOnce(config, payload, state, client, endpoints); + }), + ); + for (let j = 0; j < settled.length; j++) { + const r = settled[j]; + const index = i + j; + if (r.status === "fulfilled") { + results[index] = r.value; + } else { + failures.push({ + index, + error: + r.reason instanceof Error ? r.reason.message : String(r.reason), + }); + } + } + } + + if (failures.length > 0) { + const summary = failures + .map((f) => `item ${f.index}: ${f.error}`) + .join("; "); + // Fail-fast (default): a bulk action must not report success when any + // side effect failed. Opt into partial success with `continueOnError`. + if (!config.continueOnError) { + throw new Error( + `forEach had ${failures.length}/${collection.length} failed ` + + `iteration(s): ${summary}`, + ); + } + // Partial success is now explicit: failed items stay `null` in the + // result array and the per-item errors are recorded on the step. + state.errors[step.id] = + `${failures.length}/${collection.length} iteration(s) failed: ${summary}`; + } + + state.steps[step.id] = results; + state.status[step.id] = failures.length > 0 ? "partial" : "success"; + state.completed.push(step.id); + return; + } + + const payload = resolveMapping(config.inputMapping, state.params, state.steps); + const result = await callOnce(config, payload, state, client, endpoints); + state.steps[step.id] = result; + state.status[step.id] = "success"; + state.completed.push(step.id); +} diff --git a/src/workflow/executor.ts b/src/workflow/executor.ts new file mode 100644 index 0000000..07456b5 --- /dev/null +++ b/src/workflow/executor.ts @@ -0,0 +1,419 @@ +import type { JSONSchema7 } from "json-schema"; +import type { + ConditionalStep, + ElicitationStep, + TransformStep, + WorkflowDefinition, + WorkflowStep, +} from "./types.js"; +import { + evaluateCondition, + evaluateExpressionBlock, + resolveTemplate, +} from "./templates.js"; +import { executeApiCall } from "./api-call.js"; +import { executeSampling } from "./sampling.js"; + +// ── Runtime contracts ───────────────────────────────────────────────────────── + +export interface EndpointInfo { + method: string; + path: string; +} + +export interface ApiResponse { + ok: boolean; + status: number; + /** Parsed JSON body, or the raw text when the body is not JSON. */ + data: unknown; +} + +/** Minimal HTTP surface the engine needs. Generated servers supply a concrete client. */ +export interface WorkflowClient { + request( + method: string, + path: string, + options?: { auth?: boolean; body?: Record }, + ): Promise; +} + +export interface SamplingMessage { + prompt: string; + systemPrompt?: string; + maxTokens?: number; +} + +/** Minimal MCP server surface the engine needs for sampling and elicitation. */ +export interface WorkflowServer { + createMessage( + message: SamplingMessage, + ): Promise<{ content: { type: string; text: string } }>; + elicitInput?(params: { + message: string; + requestedSchema: JSONSchema7; + }): Promise<{ action: string; content?: unknown }>; +} + +export type StepStatus = "success" | "skipped" | "error" | "partial"; + +export interface ExecutionState { + params: Record; + /** Step results keyed by step id. Exposed to expressions as `steps`. */ + steps: Record; + status: Record; + errors: Record; + completed: string[]; + /** When set, the named step is force-skipped (a not-taken conditional branch). */ + skipStep: string | null; + /** Tracks which conditional skipped a step and which branch is still live. */ + conditionalSkips: Record< + string, + { conditional: string; runningBranchStep: string | null } + >; +} + +export interface RunWorkflowOptions { + client: WorkflowClient; + server: WorkflowServer; + endpoints: Record; + /** Whole-workflow wall-clock budget. Default: 300_000 ms. */ + timeoutMs?: number; + /** Maximum forEach iterations for a single step. Default: 500. */ + maxForEachIterations?: number; + /** Cancel the workflow mid-flight. */ + signal?: AbortSignal; +} + +export interface WorkflowResult { + status: "success" | "error" | "aborted" | "partial"; + completedSteps: string[]; + stepResults: Record; + stepErrors?: Record; + error?: string; +} + +const DEFAULT_TIMEOUT_MS = 300_000; +const DEFAULT_MAX_FOREACH = 500; + +// ── Dependency analysis ─────────────────────────────────────────────────────── + +/** Build the transitive ancestor set for every step id. */ +function buildAncestorMap( + deps: Record, +): Map> { + const cache = new Map>(); + const resolve = (id: string, visiting: Set): Set => { + const cached = cache.get(id); + if (cached) return cached; + if (visiting.has(id)) return new Set(); + visiting.add(id); + const ancestors = new Set([id]); + for (const dep of deps[id] || []) { + for (const a of resolve(dep, visiting)) ancestors.add(a); + } + cache.set(id, ancestors); + return ancestors; + }; + for (const id of Object.keys(deps)) resolve(id, new Set()); + return cache; +} + +function hasAncestor( + id: string, + ancestor: string, + map: Map>, +): boolean { + const ancestors = map.get(id); + return ancestors ? ancestors.has(ancestor) : id === ancestor; +} + +function markSkipped(state: ExecutionState, id: string): void { + state.steps[id] = null; + state.status[id] = "skipped"; +} + +/** + * Decide whether a step is ready to run now. Side effect: marks the step as + * skipped when a not-taken conditional branch or an all-skipped dependency set + * means it can never run. + */ +function shouldRun( + id: string, + state: ExecutionState, + deps: Record, + ancestorMap: Map>, +): boolean { + if (state.skipStep === id) { + state.skipStep = null; + markSkipped(state, id); + return false; + } + + const stepDeps = deps[id] || []; + const allTerminal = stepDeps.every( + (d) => state.completed.includes(d) || state.status[d] === "skipped", + ); + if (!allTerminal) return false; + + for (const dep of stepDeps) { + if (state.status[dep] !== "skipped") continue; + const skipInfo = state.conditionalSkips[dep]; + if (!skipInfo) continue; + const { runningBranchStep } = skipInfo; + if (runningBranchStep && hasAncestor(id, runningBranchStep, ancestorMap)) { + continue; + } + state.conditionalSkips[id] = skipInfo; + markSkipped(state, id); + return false; + } + + if (stepDeps.length > 0 && stepDeps.every((d) => state.status[d] === "skipped")) { + markSkipped(state, id); + return false; + } + + return true; +} + +// ── Step executors ───────────────────────────────────────────────────────────── + +function executeTransform(step: WorkflowStep, state: ExecutionState): void { + const config = step.config as TransformStep; + const result = evaluateExpressionBlock( + config.expression || "null", + state.params, + state.steps, + ); + state.steps[step.id] = result; + state.status[step.id] = "success"; + state.completed.push(step.id); +} + +function executeConditional(step: WorkflowStep, state: ExecutionState): void { + const config = step.config as ConditionalStep; + let taken: boolean; + try { + taken = evaluateCondition(config.condition || "false", state.params, state.steps); + } catch { + taken = false; + } + + state.steps[step.id] = taken; + state.status[step.id] = "success"; + + if (taken) { + if (config.elseStep) { + state.skipStep = config.elseStep; + state.conditionalSkips[config.elseStep] = { + conditional: step.id, + runningBranchStep: config.thenStep || null, + }; + } + } else if (config.thenStep) { + state.skipStep = config.thenStep; + state.conditionalSkips[config.thenStep] = { + conditional: step.id, + runningBranchStep: config.elseStep || null, + }; + } + + state.completed.push(step.id); +} + +async function executeElicitation( + step: WorkflowStep, + state: ExecutionState, + server: WorkflowServer, +): Promise { + const config = step.config as ElicitationStep; + if (!server.elicitInput) { + throw new Error( + `Step "${step.id}" requires elicitation but the server does not support it.`, + ); + } + const message = resolveTemplate(config.message || "", state.params, state.steps); + const result = await server.elicitInput({ + message, + requestedSchema: config.requestedSchema, + }); + + if (result.action !== "accept") { + if (config.onDecline === "abort") { + return { + status: "aborted", + completedSteps: state.completed, + stepResults: state.steps, + error: `User declined at step "${step.label}"`, + }; + } + // skip_remaining (default): stop here, return what we have. + markSkipped(state, step.id); + return { + status: "partial", + completedSteps: state.completed, + stepResults: state.steps, + }; + } + + state.steps[step.id] = result.content ?? null; + state.status[step.id] = "success"; + state.completed.push(step.id); + return null; +} + +// ── Orchestrator ──────────────────────────────────────────────────────────────── + +/** Validate step ids are unique and every dependsOn target exists. */ +function validateGraph(workflow: WorkflowDefinition): void { + const ids = new Set(); + for (const step of workflow.steps) { + if (ids.has(step.id)) { + throw new Error( + `Duplicate step id "${step.id}" in workflow "${workflow.name}".`, + ); + } + ids.add(step.id); + } + for (const step of workflow.steps) { + for (const dep of step.dependsOn || []) { + if (!ids.has(dep)) { + throw new Error( + `Step "${step.id}" depends on unknown step "${dep}" in workflow "${workflow.name}".`, + ); + } + } + } +} + +/** Execute a composed workflow against a client and MCP server. */ +export async function runWorkflow( + workflow: WorkflowDefinition, + args: Record, + options: RunWorkflowOptions, +): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const maxForEach = options.maxForEachIterations ?? DEFAULT_MAX_FOREACH; + const startTime = Date.now(); + + const deps: Record = Object.fromEntries( + workflow.steps.map((s) => [s.id, s.dependsOn || []]), + ); + const ancestorMap = buildAncestorMap(deps); + const stepById = new Map(workflow.steps.map((s) => [s.id, s])); + + const state: ExecutionState = { + params: args, + steps: {}, + status: {}, + errors: {}, + completed: [], + skipStep: null, + conditionalSkips: {}, + }; + + const runStep = async (step: WorkflowStep): Promise => { + switch (step.config.type) { + case "api_call": + await executeApiCall(step, state, options.client, options.endpoints, maxForEach); + return null; + case "sampling": + await executeSampling(step, state, options.server); + return null; + case "elicitation": + return executeElicitation(step, state, options.server); + case "transform": + executeTransform(step, state); + return null; + case "conditional": + executeConditional(step, state); + return null; + default: + throw new Error( + `Unknown step type for step "${step.id}".`, + ); + } + }; + + try { + validateGraph(workflow); + const remaining = new Set(workflow.steps.map((s) => s.id)); + + while (remaining.size > 0) { + if (Date.now() - startTime > timeoutMs) { + throw new Error( + `Workflow "${workflow.name}" timed out after ${timeoutMs}ms ` + + `(${state.completed.length}/${workflow.steps.length} steps done).`, + ); + } + if (options.signal?.aborted) { + return { + status: "aborted", + completedSteps: state.completed, + stepResults: state.steps, + error: `Workflow "${workflow.name}" was aborted.`, + }; + } + if (state.skipStep && !remaining.has(state.skipStep)) { + state.skipStep = null; + } + + const ready: WorkflowStep[] = []; + const skippedNow: string[] = []; + for (const id of remaining) { + const step = stepById.get(id)!; + if (shouldRun(id, state, deps, ancestorMap)) ready.push(step); + else if (state.status[id] === "skipped") skippedNow.push(id); + } + for (const id of skippedNow) remaining.delete(id); + + if (ready.length === 0) { + if (skippedNow.length > 0) continue; + // Deadlock guard: mark anything still pending as skipped and stop. + for (const id of remaining) { + if (!state.status[id]) markSkipped(state, id); + } + break; + } + + // Conditional/elicitation steps run alone so branch decisions settle + // before dependents are scheduled; everything else runs in parallel. + const solo = ready.find( + (s) => s.config.type === "conditional" || s.config.type === "elicitation", + ); + const batch = solo ? [solo] : ready; + + const settled = await Promise.allSettled(batch.map((s) => runStep(s))); + for (let i = 0; i < settled.length; i++) { + const step = batch[i]; + remaining.delete(step.id); + const outcome = settled[i]; + if (outcome.status === "fulfilled") { + if (outcome.value) return outcome.value; + } else { + const msg = + outcome.reason instanceof Error + ? outcome.reason.message + : String(outcome.reason); + throw new Error(`Step "${step.id}" failed: ${msg}`); + } + } + } + + const hasStepErrors = Object.keys(state.errors).length > 0; + return { + status: hasStepErrors ? "partial" : "success", + completedSteps: state.completed, + stepResults: state.steps, + ...(hasStepErrors ? { stepErrors: state.errors } : {}), + }; + } catch (err) { + return { + status: "error", + error: err instanceof Error ? err.message : String(err), + completedSteps: state.completed, + stepResults: state.steps, + ...(Object.keys(state.errors).length > 0 ? { stepErrors: state.errors } : {}), + }; + } +} diff --git a/src/workflow/index.ts b/src/workflow/index.ts index f86354e..191a26d 100644 --- a/src/workflow/index.ts +++ b/src/workflow/index.ts @@ -1,3 +1,7 @@ export * from "../composer/index.js"; export * from "./expression-security.js"; export * from "./types.js"; +export * from "./templates.js"; +export * from "./executor.js"; +export * from "./api-call.js"; +export * from "./sampling.js"; diff --git a/src/workflow/sampling.ts b/src/workflow/sampling.ts new file mode 100644 index 0000000..4133c5a --- /dev/null +++ b/src/workflow/sampling.ts @@ -0,0 +1,127 @@ +import type { SamplingStep, WorkflowStep } from "./types.js"; +import type { ExecutionState, WorkflowServer } from "./executor.js"; +import { resolveTemplate } from "./templates.js"; + +/** + * True when the prompt/systemPrompt signal that the model should respond with + * JSON. Shared heuristic so runtime parsing and compose-time validation agree. + */ +export function detectJsonIntent(step: { + prompt?: string; + systemPrompt?: string; +}): boolean { + const haystack = `${step.systemPrompt || ""} ${step.prompt || ""}`.toLowerCase(); + return ( + haystack.includes("json") || + haystack.includes("respond only with") || + haystack.includes("output format:") + ); +} + +/** + * Find the bracket that closes the JSON value opened at `start`, tracking depth + * and skipping string literals. Returns -1 when no balanced closer exists. + */ +function matchingClose(s: string, start: number): number { + let depth = 0; + let inStr = false; + let quote = ""; + for (let i = start; i < s.length; i++) { + const c = s[i]; + if (inStr) { + if (c === "\\") { + i++; + continue; + } + if (c === quote) inStr = false; + continue; + } + if (c === '"' || c === "'") { + inStr = true; + quote = c; + } else if (c === "{" || c === "[") { + depth++; + } else if (c === "}" || c === "]") { + depth--; + if (depth === 0) return i; + } + } + return -1; +} + +/** Extract the first parseable JSON object/array embedded in `text`. */ +export function extractJson(text: string): string | null { + const MAX_CANDIDATES = 100; + let tried = 0; + for (let i = 0; i < text.length && tried < MAX_CANDIDATES; i++) { + const ch = text[i]; + if (ch !== "{" && ch !== "[") continue; + const end = matchingClose(text, i); + if (end === -1) continue; + tried++; + const candidate = text.substring(i, end + 1); + try { + JSON.parse(candidate); + return candidate; + } catch { + // try the next opener + } + } + return null; +} + +function buildPrompt(config: SamplingStep, state: ExecutionState): string { + let prompt: string; + if (config.content && config.content.length > 0) { + prompt = config.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => resolveTemplate(c.text, state.params, state.steps)) + .join("\n"); + } else { + prompt = resolveTemplate(config.prompt || "", state.params, state.steps); + } + + if (config.responseSchema && Object.keys(config.responseSchema).length > 0) { + const fields = Object.entries(config.responseSchema) + .map(([name, type]) => `- ${name} (${type})`) + .join("\n"); + prompt += `\n\nRespond with a JSON object containing exactly these fields:\n${fields}`; + } + return prompt; +} + +/** Execute a `sampling` step via the MCP server's `createMessage`. */ +export async function executeSampling( + step: WorkflowStep, + state: ExecutionState, + server: WorkflowServer, +): Promise { + const config = step.config as SamplingStep; + const jsonMode = + config.responseFormat === "json" || + Boolean(config.responseSchema && Object.keys(config.responseSchema).length > 0) || + detectJsonIntent(config); + + const response = await server.createMessage({ + prompt: buildPrompt(config, state), + systemPrompt: config.systemPrompt + ? resolveTemplate(config.systemPrompt, state.params, state.steps) + : undefined, + maxTokens: config.maxTokens, + }); + + const text = response.content?.text ?? ""; + let result: unknown = text; + if (jsonMode) { + try { + result = JSON.parse(text); + } catch { + const extracted = extractJson(text); + if (extracted !== null) result = JSON.parse(extracted); + } + } + + state.steps[step.id] = result; + state.status[step.id] = "success"; + state.completed.push(step.id); +} diff --git a/src/workflow/templates.ts b/src/workflow/templates.ts new file mode 100644 index 0000000..01481e0 --- /dev/null +++ b/src/workflow/templates.ts @@ -0,0 +1,342 @@ +import vm from "node:vm"; +import { + autoReturnExpression, + validateSafeExpression, +} from "./expression-security.js"; + +/** + * SECURITY MODEL + * -------------- + * The security boundary for workflow expressions is the AST allowlist in + * `expression-security.ts` (`validateSafeExpression`), NOT Node's `vm` module. + * `node:vm` is explicitly documented as *not* a sandbox for untrusted code — a + * determined expression can reach the host realm. Every expression is therefore + * statically validated against the allowlist *before* it is passed to the VM; + * the VM only adds a wall-clock `timeout` as defense-in-depth. + * + * The timeout interrupts synchronous CPU loops but does NOT bound memory. Inputs + * are expected to be author/AI-generated and validated, not adversarial. If this + * engine is ever exposed to untrusted expression authors, replace `vm` with a + * hard-isolation runtime (e.g. `isolated-vm`) and add a memory limit. + */ + +const VM_TIMEOUT_MS = 250; + +type Scope = Record; + +const JS_RESERVED = new Set([ + "arguments", + "await", + "break", + "case", + "catch", + "class", + "const", + "continue", + "debugger", + "default", + "delete", + "do", + "else", + "enum", + "eval", + "export", + "extends", + "finally", + "for", + "function", + "if", + "implements", + "import", + "in", + "instanceof", + "interface", + "let", + "new", + "package", + "private", + "protected", + "public", + "return", + "static", + "super", + "switch", + "this", + "throw", + "try", + "typeof", + "var", + "void", + "while", + "with", + "yield", +]); + +function isValidName(name: string): boolean { + return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) && !JS_RESERVED.has(name); +} + +/** + * Build the prototype-less sandbox object exposed to an expression. Params and + * forEach locals are spread in as bare identifiers so authors can write + * `roomName` instead of `params.roomName`, while `params` and `steps` remain + * available for fully-qualified access. + */ +function buildSandbox(params: Scope, steps: Scope, locals: Scope = {}): Scope { + const sandbox: Scope = Object.create(null); + sandbox.params = params; + sandbox.steps = steps; + for (const [k, v] of Object.entries(params)) { + if (isValidName(k)) sandbox[k] = v; + } + for (const [k, v] of Object.entries(locals)) { + if (isValidName(k)) sandbox[k] = v; + } + // Safe globals — the same set the allowlist permits as callees. + sandbox.Array = Array; + sandbox.Boolean = Boolean; + sandbox.Date = Date; + sandbox.JSON = JSON; + sandbox.Math = Math; + sandbox.Number = Number; + sandbox.Object = Object; + sandbox.String = String; + sandbox.parseInt = parseInt; + sandbox.parseFloat = parseFloat; + sandbox.isNaN = isNaN; + sandbox.isFinite = isFinite; + sandbox.encodeURIComponent = encodeURIComponent; + sandbox.decodeURIComponent = decodeURIComponent; + sandbox.undefined = undefined; + sandbox.NaN = NaN; + sandbox.Infinity = Infinity; + return sandbox; +} + +function runInSandbox(code: string, sandbox: Scope): unknown { + return vm.runInNewContext(code, sandbox, { timeout: VM_TIMEOUT_MS }); +} + +/** Evaluate a single expression and return its native value. */ +export function evaluateExpression( + expr: string, + params: Scope, + steps: Scope, + locals: Scope = {}, +): unknown { + validateSafeExpression(expr, "expression"); + const sandbox = buildSandbox(params, steps, locals); + return runInSandbox(`"use strict"; (${expr});`, sandbox); +} + +/** + * Evaluate a transform block. A bare expression is tried first; if it is not a + * valid expression (e.g. it contains statements or a trailing object literal), + * the block is auto-wrapped with a `return` and run inside an IIFE. + */ +export function evaluateExpressionBlock( + expr: string, + params: Scope, + steps: Scope, + locals: Scope = {}, +): unknown { + const withReturn = autoReturnExpression(expr); + validateSafeExpression(expr, "transform"); + validateSafeExpression(withReturn, "transform"); + const sandbox = buildSandbox(params, steps, locals); + try { + return runInSandbox(`"use strict"; (${expr});`, sandbox); + } catch { + return runInSandbox(`"use strict"; (function() { ${withReturn} })();`, sandbox); + } +} + +/** Evaluate a condition expression to a boolean. */ +export function evaluateCondition( + expr: string, + params: Scope, + steps: Scope, +): boolean { + validateSafeExpression(expr, "conditional"); + const sandbox = buildSandbox(params, steps); + return runInSandbox(`"use strict"; !!(${expr});`, sandbox) as boolean; +} + +/** + * Locate the `}}` that closes a `{{ ... }}` placeholder starting at `from` (the + * first character after the opening `{{`). Tracks brace depth and skips string + * literals so braces inside the embedded expression do not terminate early. + */ +function findTemplateClose( + s: string, + from: number, +): { exprEnd: number; end: number } | null { + let depth = 0; + let i = from; + while (i < s.length) { + const ch = s[i]; + if (ch === '"' || ch === "'" || ch === "`") { + const quote = ch; + i++; + while (i < s.length) { + if (s[i] === "\\") { + i += 2; + continue; + } + if (s[i] === quote) { + i++; + break; + } + i++; + } + continue; + } + if (ch === "{") { + depth++; + i++; + continue; + } + if (ch === "}") { + if (depth > 0) { + depth--; + i++; + continue; + } + if (s[i + 1] === "}") return { exprEnd: i, end: i + 2 }; + i++; + continue; + } + i++; + } + return null; +} + +/** + * Normalize the template escape forms before evaluation: + * - bracket-wrapped refs `{{[params.x]}}` → `{{params.x}}` + * - over-braced `{{{ ... }}}` (3+) → `{{ ... }}` + * Idempotent. + */ +function cleanTemplate(template: string): string { + return template + .replace(/\{\{\[params\.([^\]]+)\]\}\}/g, "{{params.$1}}") + .replace(/\{\{\[steps\.([^\]]+)\]\}\}/g, "{{steps.$1}}") + .replace(/\{{3,}([^}]+)\}{3,}/g, "{{$1}}"); +} + +/** + * If `cleaned` is a single placeholder spanning the entire string, return the + * inner expression; otherwise `null`. Used to decide whether a value can keep + * its native (non-string) type. + */ +function soleExpression(cleaned: string): string | null { + if (!cleaned.startsWith("{{")) return null; + const close = findTemplateClose(cleaned, 2); + if (close && close.end === cleaned.length) { + return cleaned.slice(2, close.exprEnd).trim(); + } + return null; +} + +/** Resolve every `{{ ... }}` placeholder in a string to its stringified value. */ +export function resolveTemplate( + template: string, + params: Scope, + steps: Scope, + locals: Scope = {}, +): string { + const cleaned = cleanTemplate(template); + const sandbox = buildSandbox(params, steps, locals); + + const resolveExpr = (rawExpr: string, match: string): string => { + const expr = rawExpr.trim(); + try { + validateSafeExpression(expr, "template"); + const val = runInSandbox(`"use strict"; (${expr});`, sandbox); + return typeof val === "object" && val !== null + ? JSON.stringify(val) + : String(val ?? ""); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[template] Failed to resolve ${match}: ${msg}`); + return ""; + } + }; + + let result = ""; + let i = 0; + while (i < cleaned.length) { + const start = cleaned.indexOf("{{", i); + if (start === -1) { + result += cleaned.slice(i); + break; + } + result += cleaned.slice(i, start); + const close = findTemplateClose(cleaned, start + 2); + if (close === null) { + result += cleaned.slice(start); + break; + } + const rawExpr = cleaned.slice(start + 2, close.exprEnd); + const match = cleaned.slice(start, close.end); + result += resolveExpr(rawExpr, match); + i = close.end; + } + return result; +} + +/** + * Resolve a value of any shape. Strings containing a single whole-string + * placeholder keep their native evaluated type; mixed strings interpolate to a + * string (parsed back only when the whole result is a JSON container). Arrays + * and objects are resolved recursively. + */ +export function resolveValue( + value: unknown, + params: Scope, + steps: Scope, + locals: Scope = {}, +): unknown { + if (typeof value === "string" && value.includes("{{")) { + const cleaned = cleanTemplate(value); + const sole = soleExpression(cleaned); + if (sole !== null) { + try { + return evaluateExpression(sole, params, steps, locals) ?? ""; + } catch { + return resolveTemplate(value, params, steps, locals); + } + } + const result = resolveTemplate(value, params, steps, locals); + const trimmed = result.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + return JSON.parse(result); + } catch { + return result; + } + } + return result; + } + if (Array.isArray(value)) { + return value.map((el) => resolveValue(el, params, steps, locals)); + } + if (typeof value === "object" && value !== null) { + return resolveMapping(value as Scope, params, steps, locals); + } + return value; +} + +/** Resolve every value in a mapping object. */ +export function resolveMapping( + mapping: Scope, + params: Scope, + steps: Scope, + locals: Scope = {}, +): Record { + const resolved: Record = {}; + for (const [key, value] of Object.entries(mapping)) { + resolved[key] = resolveValue(value, params, steps, locals); + } + return resolved; +} diff --git a/src/workflow/types.ts b/src/workflow/types.ts index 2a31754..9ecb70f 100644 --- a/src/workflow/types.ts +++ b/src/workflow/types.ts @@ -7,6 +7,15 @@ export interface ApiCallStep { outputPath?: string; forEach?: string; as?: string; + /** + * forEach failure policy. When omitted or `false`, the step fails (and the + * workflow errors) if any iteration fails — the safe default for bulk + * side-effecting actions like archive/delete/post. When `true`, iterations + * continue on failure: failed items are recorded as `null` in the result + * array and the per-item errors are surfaced on the step so partial success + * is explicit rather than silent. + */ + continueOnError?: boolean; } export interface SamplingStep {