From d7048cb75a7c1ff1a4a8b1ed6255b6d739087746 Mon Sep 17 00:00:00 2001 From: Guy Date: Sat, 22 Aug 2026 12:21:11 +0300 Subject: [PATCH] fix(api): store expanded workflow, not raw body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleCreateRun stored the raw request body as workflow_json, discarding the expanded steps from expandStrategy. With strategy: sequential, applyChain added depends_on to the expanded steps — but those were only used for DB step prepopulation, never persisted in workflow_json. The engine then read the raw body at runtime, saw no depends_on, and dispatched every step as a graph root (no chain, no ordering). When a strategy is present, replace obj.steps with the expanded effective_steps and serialize that as workflow_json, so the chain dependencies persist for normalizeWorkflowRoot to convert into DAG edges. Idempotency-conflict check compares against the stored expanded form. Fixes #34 Fixes #36 --- src/api.zig | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/api.zig b/src/api.zig index 3d88b15..15ab8aa 100644 --- a/src/api.zig +++ b/src/api.zig @@ -499,8 +499,8 @@ fn handleCreateRun(ctx: *Context, body: []const u8) HttpResponse { if (root != .object) { return jsonResponse(400, "{\"error\":{\"code\":\"bad_request\",\"message\":\"body must be a JSON object\"}}"); } + var obj = root.object; - const obj = root.object; const body_idempotency_key = getJsonString(obj, "idempotency_key"); const idempotency_key = ctx.request_idempotency_key orelse body_idempotency_key; if (idempotency_key) |ik| { @@ -582,12 +582,34 @@ fn handleCreateRun(ctx: *Context, body: []const u8) HttpResponse { const run_id_buf = ids.generateId(); const run_id = ctx.allocator.dupe(u8, &run_id_buf) catch return jsonResponse(500, "{\"error\":{\"code\":\"internal\",\"message\":\"out of memory\"}}"); - // Insert run — workflow_json = the original body (frozen snapshot) - ctx.store.insertRun(run_id, idempotency_key, "running", body, input_json, callbacks_json) catch { + // Build expanded workflow JSON from effective_steps (nullboiler-7mn). + // When strategy is used, effective_steps has depends_on from applyChain. + // The raw body does NOT — storing raw body loses the expansion and the + // engine dispatches all steps as roots (no chain, no parallel ordering). + // Fix: replace steps in obj with expanded version, remove strategy, serialize. + if (obj.get("strategy") != null) { + var steps_arr = std.json.Array.initCapacity(ctx.allocator, effective_steps.len) catch { + return jsonResponse(500, "{\"error\":{\"code\":\"internal\",\"message\":\"failed to build expanded workflow\"}}"); + }; + for (effective_steps) |step| { + steps_arr.append(step) catch { + return jsonResponse(500, "{\"error\":{\"code\":\"internal\",\"message\":\"failed to build steps array\"}}"); + }; + } + obj.put(ctx.allocator, "steps", .{ .array = steps_arr }) catch { + return jsonResponse(500, "{\"error\":{\"code\":\"internal\",\"message\":\"failed to update workflow object\"}}"); + }; + // strategy field is now consumed — leave it in the stored JSON (harmless, + // normalizeWorkflowRoot only reads steps/nodes/edges, not strategy) + } + const stored_workflow_json = serializeJsonValue(ctx.allocator, .{ .object = obj }) catch body; + + // Insert run — workflow_json = expanded workflow (with depends_on from strategy) + ctx.store.insertRun(run_id, idempotency_key, "running", stored_workflow_json, input_json, callbacks_json) catch { if (idempotency_key) |ik| { const existing = ctx.store.getRunByIdempotencyKey(ctx.allocator, ik) catch null; if (existing) |run| { - if (!std.mem.eql(u8, run.workflow_json, body)) { + if (!std.mem.eql(u8, run.workflow_json, stored_workflow_json)) { return jsonResponse(409, "{\"error\":{\"code\":\"conflict\",\"message\":\"idempotency key already used with different payload\"}}"); } if (ctx.metrics) |m| {