From 9c503437c4fe8ce5e4a3c468685ac8f306033f08 Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:38:42 +0200 Subject: [PATCH] feat(engine): add hasPlanReadySteps plan DAG helper Closes #2298 Mirrors nextReadySteps length check for runnable pending steps. Co-authored-by: Cursor --- packages/gittensory-engine/README.md | 1 + packages/gittensory-engine/src/index.ts | 1 + packages/gittensory-engine/src/plan-ready.ts | 18 ++++ test/unit/plan-ready.test.ts | 103 +++++++++++++++++++ 4 files changed, 123 insertions(+) create mode 100644 packages/gittensory-engine/src/plan-ready.ts create mode 100644 test/unit/plan-ready.test.ts diff --git a/packages/gittensory-engine/README.md b/packages/gittensory-engine/README.md index 37b0da71aa..7d576824bc 100644 --- a/packages/gittensory-engine/README.md +++ b/packages/gittensory-engine/README.md @@ -539,6 +539,7 @@ dashboard progress summaries: - `isPlanBlocked(plan)` — pending steps remain but none are runnable (deadlock; mirrors `planProgress`'s `blocked` status) - `isPlanProgressComplete(plan)` — every step is `completed` or `skipped` (empty plans are not complete; mirrors `planProgress`'s `completed` status) - `resolvePlanOverallStatus(plan)` — coarse status (`pending` | `running` | `completed` | `failed` | `blocked`); mirrors `planProgress`'s `status` +- `hasPlanReadySteps(plan)` — any step is runnable now (`pending` with satisfied dependencies; mirrors `nextReadySteps(plan).length > 0`) ## Opportunity competition diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index e0d49c95c4..2c7784e92e 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -149,6 +149,7 @@ export { hasPlanCompletedSteps } from "./plan-completed.js"; export { isPlanBlocked } from "./plan-blocked.js"; export { isPlanProgressComplete } from "./plan-progress-complete.js"; export { resolvePlanOverallStatus, type PlanOverallStatus } from "./plan-overall-status.js"; +export { hasPlanReadySteps } from "./plan-ready.js"; export * from "./plan-templates.js"; export * from "./portfolio/queue.js"; export { diff --git a/packages/gittensory-engine/src/plan-ready.ts b/packages/gittensory-engine/src/plan-ready.ts new file mode 100644 index 0000000000..a3081be483 --- /dev/null +++ b/packages/gittensory-engine/src/plan-ready.ts @@ -0,0 +1,18 @@ +import type { PlanDag, PlanStep, PlanStepStatus } from "./plan-export.js"; + +const isDone = (status: PlanStepStatus): boolean => status === "completed" || status === "skipped"; + +function nextReadySteps(plan: PlanDag): PlanStep[] { + const statusById = new Map(plan.steps.map((step) => [step.id, step.status])); + return plan.steps.filter( + (step) => step.status === "pending" && step.dependsOn.every((dep) => isDone(statusById.get(dep) ?? "pending")), + ); +} + +/** + * Return whether any step is runnable now: `pending` with every dependency `completed` or `skipped`. Mirrors hosted + * `nextReadySteps(plan).length > 0`. Pure. + */ +export function hasPlanReadySteps(plan: PlanDag): boolean { + return nextReadySteps(plan).length > 0; +} diff --git a/test/unit/plan-ready.test.ts b/test/unit/plan-ready.test.ts new file mode 100644 index 0000000000..ed2699efe7 --- /dev/null +++ b/test/unit/plan-ready.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; + +import { hasPlanReadySteps } from "../../packages/gittensory-engine/src/plan-ready"; +import type { PlanStep } from "../../packages/gittensory-engine/src/plan-export"; + +function step(over: Partial & { id: string; title: string }): PlanStep { + return { + actionClass: undefined, + dependsOn: [], + status: "pending", + attempts: 0, + maxAttempts: 3, + lastError: null, + ...over, + }; +} + +describe("hasPlanReadySteps", () => { + it("returns false for an empty plan", () => { + expect(hasPlanReadySteps({ steps: [] })).toBe(false); + }); + + it("returns true when a pending step has no dependencies", () => { + expect( + hasPlanReadySteps({ + steps: [step({ id: "a", title: "Build", status: "pending" })], + }), + ).toBe(true); + }); + + it("returns true when a pending step's dependencies are satisfied", () => { + expect( + hasPlanReadySteps({ + steps: [ + step({ id: "a", title: "Build", status: "completed" }), + step({ id: "b", title: "Test", status: "pending", dependsOn: ["a"] }), + ], + }), + ).toBe(true); + }); + + it("returns true when only the root pending step is ready in a chain", () => { + expect( + hasPlanReadySteps({ + steps: [ + step({ id: "a", title: "Build", status: "pending" }), + step({ id: "b", title: "Test", status: "pending", dependsOn: ["a"] }), + ], + }), + ).toBe(true); + }); + + it("returns false for a cyclic deadlock with no ready steps", () => { + expect( + hasPlanReadySteps({ + steps: [ + step({ id: "a", title: "A", dependsOn: ["b"] }), + step({ id: "b", title: "B", dependsOn: ["a"] }), + ], + }), + ).toBe(false); + }); + + it("returns false when a pending step depends on a missing step id", () => { + expect( + hasPlanReadySteps({ + steps: [step({ id: "a", title: "A", dependsOn: ["ghost"] })], + }), + ).toBe(false); + }); + + it("returns false when every step is completed or skipped", () => { + expect( + hasPlanReadySteps({ + steps: [ + step({ id: "a", title: "Build", status: "completed" }), + step({ id: "b", title: "Deploy", status: "skipped" }), + ], + }), + ).toBe(false); + }); + + it("returns false when only running or failed steps remain", () => { + expect( + hasPlanReadySteps({ + steps: [ + step({ id: "a", title: "Build", status: "running" }), + step({ id: "b", title: "Deploy", status: "failed" }), + ], + }), + ).toBe(false); + }); + + it("is exported from the package barrel", async () => { + const barrel = await import("../../packages/gittensory-engine/src/index"); + expect(typeof barrel.hasPlanReadySteps).toBe("function"); + expect( + barrel.hasPlanReadySteps({ + steps: [step({ id: "a", title: "A", status: "pending" })], + }), + ).toBe(true); + }); +});