Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
101 changes: 55 additions & 46 deletions src/workflows/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
103 changes: 103 additions & 0 deletions tests/unit/workflows-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]);
});
Loading