Skip to content

Commit 400d22a

Browse files
refactor(engine): consolidate plan step readiness helpers
Extract shared isDone and nextReadySteps into plan-step-readiness.ts so plan-blocked, plan-overall-status, and plan-ready cannot drift. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b1608e6 commit 400d22a

5 files changed

Lines changed: 75 additions & 30 deletions

File tree

packages/loopover-engine/src/plan-blocked.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,5 @@
1-
import type { PlanDag, PlanStep, PlanStepStatus } from "./plan-export.js";
2-
3-
const isDone = (status: PlanStepStatus): boolean => status === "completed" || status === "skipped";
4-
5-
function nextReadySteps(plan: PlanDag): PlanStep[] {
6-
const statusById = new Map(plan.steps.map((step) => [step.id, step.status]));
7-
return plan.steps.filter(
8-
(step) => step.status === "pending" && step.dependsOn.every((dep) => isDone(statusById.get(dep) ?? "pending")),
9-
);
10-
}
1+
import type { PlanDag } from "./plan-export.js";
2+
import { nextReadySteps } from "./plan-step-readiness.js";
113

124
/**
135
* Return whether the plan is deadlocked: pending steps remain but none are runnable. Mirrors the `blocked`

packages/loopover-engine/src/plan-overall-status.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,8 @@
1-
import type { PlanDag, PlanStep, PlanStepStatus } from "./plan-export.js";
1+
import type { PlanDag } from "./plan-export.js";
2+
import { nextReadySteps } from "./plan-step-readiness.js";
23

34
export type PlanOverallStatus = "pending" | "running" | "completed" | "failed" | "blocked";
45

5-
const isDone = (status: PlanStepStatus): boolean => status === "completed" || status === "skipped";
6-
7-
function nextReadySteps(plan: PlanDag): PlanStep[] {
8-
const statusById = new Map(plan.steps.map((step) => [step.id, step.status]));
9-
return plan.steps.filter(
10-
(step) => step.status === "pending" && step.dependsOn.every((dep) => isDone(statusById.get(dep) ?? "pending")),
11-
);
12-
}
13-
146
/**
157
* Resolve the coarse plan status matching hosted `planProgress`'s `status` field. Pure — reads the plan DAG only.
168
*/

packages/loopover-engine/src/plan-ready.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,5 @@
1-
import type { PlanDag, PlanStep, PlanStepStatus } from "./plan-export.js";
2-
3-
const isDone = (status: PlanStepStatus): boolean => status === "completed" || status === "skipped";
4-
5-
function nextReadySteps(plan: PlanDag): PlanStep[] {
6-
const statusById = new Map(plan.steps.map((step) => [step.id, step.status]));
7-
return plan.steps.filter(
8-
(step) => step.status === "pending" && step.dependsOn.every((dep) => isDone(statusById.get(dep) ?? "pending")),
9-
);
10-
}
1+
import type { PlanDag } from "./plan-export.js";
2+
import { nextReadySteps } from "./plan-step-readiness.js";
113

124
/**
135
* Return whether any step is runnable now: `pending` with every dependency `completed` or `skipped`. Mirrors hosted
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { PlanDag, PlanStep, PlanStepStatus } from "./plan-export.js";
2+
3+
export function isDone(status: PlanStepStatus): boolean {
4+
return status === "completed" || status === "skipped";
5+
}
6+
7+
export function nextReadySteps(plan: PlanDag): PlanStep[] {
8+
const statusById = new Map(plan.steps.map((step) => [step.id, step.status]));
9+
return plan.steps.filter(
10+
(step) => step.status === "pending" && step.dependsOn.every((dep) => isDone(statusById.get(dep) ?? "pending")),
11+
);
12+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { isDone, nextReadySteps } from "../../packages/loopover-engine/src/plan-step-readiness";
4+
import type { PlanStep, PlanStepStatus } from "../../packages/loopover-engine/src/plan-export";
5+
6+
function step(over: Partial<PlanStep> & { id: string; title: string }): PlanStep {
7+
return {
8+
actionClass: undefined,
9+
dependsOn: [],
10+
status: "pending",
11+
attempts: 0,
12+
maxAttempts: 3,
13+
lastError: null,
14+
...over,
15+
};
16+
}
17+
18+
describe("isDone", () => {
19+
it.each<PlanStepStatus>(["pending", "running", "failed"])("returns false for %s", (status) => {
20+
expect(isDone(status)).toBe(false);
21+
});
22+
23+
it.each<PlanStepStatus>(["completed", "skipped"])("returns true for %s", (status) => {
24+
expect(isDone(status)).toBe(true);
25+
});
26+
});
27+
28+
describe("nextReadySteps", () => {
29+
it("returns a pending step with no dependencies", () => {
30+
const ready = step({ id: "a", title: "Build", status: "pending" });
31+
expect(nextReadySteps({ steps: [ready] })).toEqual([ready]);
32+
});
33+
34+
it("returns a pending step when its dependency is completed", () => {
35+
const dep = step({ id: "a", title: "Build", status: "completed" });
36+
const ready = step({ id: "b", title: "Test", status: "pending", dependsOn: ["a"] });
37+
expect(nextReadySteps({ steps: [dep, ready] })).toEqual([ready]);
38+
});
39+
40+
it("returns a pending step when its dependency is skipped", () => {
41+
const dep = step({ id: "a", title: "Build", status: "skipped" });
42+
const ready = step({ id: "b", title: "Test", status: "pending", dependsOn: ["a"] });
43+
expect(nextReadySteps({ steps: [dep, ready] })).toEqual([ready]);
44+
});
45+
46+
it.each<PlanStepStatus>(["running", "failed"])("returns no ready steps when a dependency is %s", (status) => {
47+
const dep = step({ id: "a", title: "Build", status });
48+
const blocked = step({ id: "b", title: "Test", status: "pending", dependsOn: ["a"] });
49+
expect(nextReadySteps({ steps: [dep, blocked] })).toEqual([]);
50+
});
51+
52+
it("returns no ready steps when a dependency is still pending", () => {
53+
const dep = step({ id: "a", title: "Build", status: "pending", dependsOn: ["ghost"] });
54+
const blocked = step({ id: "b", title: "Test", status: "pending", dependsOn: ["a"] });
55+
expect(nextReadySteps({ steps: [dep, blocked] })).toEqual([]);
56+
});
57+
});

0 commit comments

Comments
 (0)