Skip to content

Commit 6f82cf2

Browse files
committed
refactor(workflows): extract invokeStep from settle
1 parent 4a03fb5 commit 6f82cf2

2 files changed

Lines changed: 158 additions & 46 deletions

File tree

‎src/workflows/runtime.ts‎

Lines changed: 55 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -180,58 +180,67 @@ export class WorkflowRuntime {
180180
continue;
181181
}
182182

183-
const resolution = resolveStep(step, this.capabilities);
184-
if (!resolution.runnable) {
185-
frame.statuses[frame.stepIndex] = "skipped";
186-
this.emit({
187-
type: "step-skip",
188-
workflow: workflow.name,
189-
step,
190-
reason: resolution.skippedReason ?? "not runnable",
191-
});
192-
frame.stepIndex += 1;
193-
continue;
194-
}
195-
196-
if (step.workflow !== undefined) {
197-
const nested = this.resolve(step.workflow);
198-
if (nested === undefined) {
199-
if (step.optional === true) {
200-
frame.statuses[frame.stepIndex] = "skipped";
201-
this.emit({
202-
type: "step-skip",
203-
workflow: workflow.name,
204-
step,
205-
reason: `sub-workflow not found: ${step.workflow}`,
206-
});
207-
frame.stepIndex += 1;
208-
continue;
209-
}
210-
throw new Error(
211-
`Sub-workflow "${step.workflow}" not found in registry`,
212-
);
213-
}
214-
if (this.stack.length >= MAX_WORKFLOW_DEPTH) {
215-
throw new Error(
216-
`Workflow nesting exceeded the limit of ${MAX_WORKFLOW_DEPTH} (at "${step.workflow}")`,
217-
);
218-
}
219-
frame.statuses[frame.stepIndex] = "active";
220-
this.stack.push(frameFor(nested));
221-
continue;
222-
}
183+
if (this.invokeStep(frame, workflow, step)) return;
184+
else continue;
185+
}
186+
}
223187

224-
// Landed on an executable step.
225-
frame.statuses[frame.stepIndex] = "active";
188+
private invokeStep(
189+
frame: WorkflowFrame,
190+
workflow: Workflow,
191+
step: WorkflowStep,
192+
): boolean {
193+
const resolution = resolveStep(step, this.capabilities);
194+
if (!resolution.runnable) {
195+
frame.statuses[frame.stepIndex] = "skipped";
226196
this.emit({
227-
type: "step-start",
197+
type: "step-skip",
228198
workflow: workflow.name,
229199
step,
230-
index: frame.stepIndex,
231-
total: workflow.steps.length,
200+
reason: resolution.skippedReason ?? "not runnable",
232201
});
233-
return;
202+
frame.stepIndex += 1;
203+
return false;
234204
}
205+
206+
if (step.workflow !== undefined) {
207+
const nested = this.resolve(step.workflow);
208+
if (nested === undefined) {
209+
if (step.optional === true) {
210+
frame.statuses[frame.stepIndex] = "skipped";
211+
this.emit({
212+
type: "step-skip",
213+
workflow: workflow.name,
214+
step,
215+
reason: `sub-workflow not found: ${step.workflow}`,
216+
});
217+
frame.stepIndex += 1;
218+
return false;
219+
}
220+
throw new Error(
221+
`Sub-workflow "${step.workflow}" not found in registry`,
222+
);
223+
}
224+
if (this.stack.length >= MAX_WORKFLOW_DEPTH) {
225+
throw new Error(
226+
`Workflow nesting exceeded the limit of ${MAX_WORKFLOW_DEPTH} (at "${step.workflow}")`,
227+
);
228+
}
229+
frame.statuses[frame.stepIndex] = "active";
230+
this.stack.push(frameFor(nested));
231+
return false;
232+
}
233+
234+
// Landed on an executable step.
235+
frame.statuses[frame.stepIndex] = "active";
236+
this.emit({
237+
type: "step-start",
238+
workflow: workflow.name,
239+
step,
240+
index: frame.stepIndex,
241+
total: workflow.steps.length,
242+
});
243+
return true;
235244
}
236245

237246
state(): WorkflowState {

‎tests/unit/workflows-runtime.test.ts‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,3 +318,106 @@ test("coordinator ignores errored tool calls", () => {
318318
);
319319
expect(rt.currentStep()?.id).toBe("a");
320320
});
321+
322+
test("CL-8289 pin: capability skip emits reason and satisfied run lands on gated step", () => {
323+
const skipped = new WorkflowRuntime(empty, resolver);
324+
const skippedEvents = collect(skipped);
325+
skipped.start(withGatedStep);
326+
skipped.advance();
327+
expect(skipped.currentStep()?.id).toBe("c");
328+
const skip = skippedEvents.find(
329+
(e) => e.type === "step-skip" && e.step.id === "needs-ticket",
330+
);
331+
expect(skip?.type).toBe("step-skip");
332+
if (skip?.type === "step-skip") {
333+
expect(skip.reason).toBe("capability not satisfied: ticket-tracker");
334+
}
335+
336+
const satisfied = new WorkflowRuntime(ticketTracker, resolver);
337+
satisfied.start(withGatedStep);
338+
satisfied.advance();
339+
expect(satisfied.currentStep()?.id).toBe("needs-ticket");
340+
});
341+
342+
test("CL-8289 pin: sub-workflow descends parent to child then pops complete to p3", () => {
343+
const rt = new WorkflowRuntime(empty, resolver);
344+
const events = collect(rt);
345+
rt.start(parent);
346+
expect(rt.currentStep()?.id).toBe("p1");
347+
rt.advance();
348+
expect(rt.currentStep()?.id).toBe("c1");
349+
rt.advance();
350+
expect(rt.currentStep()?.id).toBe("p3");
351+
expect(events.map((e) => e.type)).toEqual([
352+
"step-start",
353+
"step-complete",
354+
"step-start",
355+
"step-complete",
356+
"step-complete",
357+
"step-start",
358+
]);
359+
});
360+
361+
test("CL-8289 pin: optional missing sub-workflow skips with not-found prefix", () => {
362+
const optionalMissing: Workflow = {
363+
name: "optional-missing",
364+
description: "optional absent child",
365+
steps: [{ id: "o", label: "O", workflow: "nope", optional: true }],
366+
};
367+
const rt = new WorkflowRuntime(empty, (n) =>
368+
n === "optional-missing" ? optionalMissing : undefined,
369+
);
370+
const events = collect(rt);
371+
rt.start(optionalMissing);
372+
expect(rt.isComplete()).toBe(true);
373+
const skip = events.find((e) => e.type === "step-skip");
374+
expect(skip?.type).toBe("step-skip");
375+
if (skip?.type === "step-skip") {
376+
expect(skip.reason.startsWith("sub-workflow not found: ")).toBe(true);
377+
expect(skip.reason).toBe("sub-workflow not found: nope");
378+
}
379+
});
380+
381+
test("CL-8289 pin: required missing sub-workflow throws not-found text", () => {
382+
const requiredMissing: Workflow = {
383+
name: "required-missing",
384+
description: "required absent child",
385+
steps: [{ id: "r", label: "R", workflow: "nope" }],
386+
};
387+
const rt = new WorkflowRuntime(empty, (n) =>
388+
n === "required-missing" ? requiredMissing : undefined,
389+
);
390+
expect(() => rt.start(requiredMissing)).toThrow(
391+
'Sub-workflow "nope" not found in registry',
392+
);
393+
});
394+
395+
test("CL-8289 pin: depth limit throws nesting text", () => {
396+
const cyclic: Workflow = {
397+
name: "cyclic",
398+
description: "calls itself",
399+
steps: [{ id: "loop", label: "Loop", workflow: "cyclic" }],
400+
};
401+
const rt = new WorkflowRuntime(empty, (n) =>
402+
n === "cyclic" ? cyclic : undefined,
403+
);
404+
expect(() => rt.start(cyclic)).toThrow(
405+
'Workflow nesting exceeded the limit of 3 (at "cyclic")',
406+
);
407+
});
408+
409+
test("CL-8289 pin: two-step golden transcript", () => {
410+
const rt = new WorkflowRuntime(empty, resolver);
411+
const events = collect(rt);
412+
rt.start(simple);
413+
rt.advance();
414+
rt.advance();
415+
expect(rt.isComplete()).toBe(true);
416+
expect(events.map((e) => e.type)).toEqual([
417+
"step-start",
418+
"step-complete",
419+
"step-start",
420+
"step-complete",
421+
"workflow-complete",
422+
]);
423+
});

0 commit comments

Comments
 (0)