From 6f82cf2d919a54e6d80980329cd24326e6ceda29 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 08:58:02 -0700 Subject: [PATCH] refactor(workflows): extract invokeStep from settle --- src/workflows/runtime.ts | 101 ++++++++++++++------------ tests/unit/workflows-runtime.test.ts | 103 +++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 46 deletions(-) diff --git a/src/workflows/runtime.ts b/src/workflows/runtime.ts index ef987a5ee..77bb9b814 100644 --- a/src/workflows/runtime.ts +++ b/src/workflows/runtime.ts @@ -180,58 +180,67 @@ export class WorkflowRuntime { continue; } - const resolution = resolveStep(step, this.capabilities); - if (!resolution.runnable) { - frame.statuses[frame.stepIndex] = "skipped"; - this.emit({ - type: "step-skip", - workflow: workflow.name, - step, - reason: resolution.skippedReason ?? "not runnable", - }); - frame.stepIndex += 1; - continue; - } - - if (step.workflow !== undefined) { - const nested = this.resolve(step.workflow); - if (nested === undefined) { - if (step.optional === true) { - frame.statuses[frame.stepIndex] = "skipped"; - this.emit({ - type: "step-skip", - workflow: workflow.name, - step, - reason: `sub-workflow not found: ${step.workflow}`, - }); - frame.stepIndex += 1; - continue; - } - throw new Error( - `Sub-workflow "${step.workflow}" not found in registry`, - ); - } - if (this.stack.length >= MAX_WORKFLOW_DEPTH) { - throw new Error( - `Workflow nesting exceeded the limit of ${MAX_WORKFLOW_DEPTH} (at "${step.workflow}")`, - ); - } - frame.statuses[frame.stepIndex] = "active"; - this.stack.push(frameFor(nested)); - continue; - } + if (this.invokeStep(frame, workflow, step)) return; + else continue; + } + } - // Landed on an executable step. - frame.statuses[frame.stepIndex] = "active"; + private invokeStep( + frame: WorkflowFrame, + workflow: Workflow, + step: WorkflowStep, + ): boolean { + const resolution = resolveStep(step, this.capabilities); + if (!resolution.runnable) { + frame.statuses[frame.stepIndex] = "skipped"; this.emit({ - type: "step-start", + type: "step-skip", workflow: workflow.name, step, - index: frame.stepIndex, - total: workflow.steps.length, + reason: resolution.skippedReason ?? "not runnable", }); - return; + frame.stepIndex += 1; + return false; } + + if (step.workflow !== undefined) { + const nested = this.resolve(step.workflow); + if (nested === undefined) { + if (step.optional === true) { + frame.statuses[frame.stepIndex] = "skipped"; + this.emit({ + type: "step-skip", + workflow: workflow.name, + step, + reason: `sub-workflow not found: ${step.workflow}`, + }); + frame.stepIndex += 1; + return false; + } + throw new Error( + `Sub-workflow "${step.workflow}" not found in registry`, + ); + } + if (this.stack.length >= MAX_WORKFLOW_DEPTH) { + throw new Error( + `Workflow nesting exceeded the limit of ${MAX_WORKFLOW_DEPTH} (at "${step.workflow}")`, + ); + } + frame.statuses[frame.stepIndex] = "active"; + this.stack.push(frameFor(nested)); + return false; + } + + // Landed on an executable step. + frame.statuses[frame.stepIndex] = "active"; + this.emit({ + type: "step-start", + workflow: workflow.name, + step, + index: frame.stepIndex, + total: workflow.steps.length, + }); + return true; } state(): WorkflowState { diff --git a/tests/unit/workflows-runtime.test.ts b/tests/unit/workflows-runtime.test.ts index 8b89de1b6..00c917e5b 100644 --- a/tests/unit/workflows-runtime.test.ts +++ b/tests/unit/workflows-runtime.test.ts @@ -318,3 +318,106 @@ test("coordinator ignores errored tool calls", () => { ); expect(rt.currentStep()?.id).toBe("a"); }); + +test("CL-8289 pin: capability skip emits reason and satisfied run lands on gated step", () => { + const skipped = new WorkflowRuntime(empty, resolver); + const skippedEvents = collect(skipped); + skipped.start(withGatedStep); + skipped.advance(); + expect(skipped.currentStep()?.id).toBe("c"); + const skip = skippedEvents.find( + (e) => e.type === "step-skip" && e.step.id === "needs-ticket", + ); + expect(skip?.type).toBe("step-skip"); + if (skip?.type === "step-skip") { + expect(skip.reason).toBe("capability not satisfied: ticket-tracker"); + } + + const satisfied = new WorkflowRuntime(ticketTracker, resolver); + satisfied.start(withGatedStep); + satisfied.advance(); + expect(satisfied.currentStep()?.id).toBe("needs-ticket"); +}); + +test("CL-8289 pin: sub-workflow descends parent to child then pops complete to p3", () => { + const rt = new WorkflowRuntime(empty, resolver); + const events = collect(rt); + rt.start(parent); + expect(rt.currentStep()?.id).toBe("p1"); + rt.advance(); + expect(rt.currentStep()?.id).toBe("c1"); + rt.advance(); + expect(rt.currentStep()?.id).toBe("p3"); + expect(events.map((e) => e.type)).toEqual([ + "step-start", + "step-complete", + "step-start", + "step-complete", + "step-complete", + "step-start", + ]); +}); + +test("CL-8289 pin: optional missing sub-workflow skips with not-found prefix", () => { + const optionalMissing: Workflow = { + name: "optional-missing", + description: "optional absent child", + steps: [{ id: "o", label: "O", workflow: "nope", optional: true }], + }; + const rt = new WorkflowRuntime(empty, (n) => + n === "optional-missing" ? optionalMissing : undefined, + ); + const events = collect(rt); + rt.start(optionalMissing); + expect(rt.isComplete()).toBe(true); + const skip = events.find((e) => e.type === "step-skip"); + expect(skip?.type).toBe("step-skip"); + if (skip?.type === "step-skip") { + expect(skip.reason.startsWith("sub-workflow not found: ")).toBe(true); + expect(skip.reason).toBe("sub-workflow not found: nope"); + } +}); + +test("CL-8289 pin: required missing sub-workflow throws not-found text", () => { + const requiredMissing: Workflow = { + name: "required-missing", + description: "required absent child", + steps: [{ id: "r", label: "R", workflow: "nope" }], + }; + const rt = new WorkflowRuntime(empty, (n) => + n === "required-missing" ? requiredMissing : undefined, + ); + expect(() => rt.start(requiredMissing)).toThrow( + 'Sub-workflow "nope" not found in registry', + ); +}); + +test("CL-8289 pin: depth limit throws nesting text", () => { + const cyclic: Workflow = { + name: "cyclic", + description: "calls itself", + steps: [{ id: "loop", label: "Loop", workflow: "cyclic" }], + }; + const rt = new WorkflowRuntime(empty, (n) => + n === "cyclic" ? cyclic : undefined, + ); + expect(() => rt.start(cyclic)).toThrow( + 'Workflow nesting exceeded the limit of 3 (at "cyclic")', + ); +}); + +test("CL-8289 pin: two-step golden transcript", () => { + const rt = new WorkflowRuntime(empty, resolver); + const events = collect(rt); + rt.start(simple); + rt.advance(); + rt.advance(); + expect(rt.isComplete()).toBe(true); + expect(events.map((e) => e.type)).toEqual([ + "step-start", + "step-complete", + "step-start", + "step-complete", + "workflow-complete", + ]); +});