diff --git a/packages/gittensory-engine/README.md b/packages/gittensory-engine/README.md index 7d576824bc..8845eb60ca 100644 --- a/packages/gittensory-engine/README.md +++ b/packages/gittensory-engine/README.md @@ -540,6 +540,7 @@ dashboard progress summaries: - `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`) +- `isPlanTerminated(plan)` — plan reached a terminal outcome (`failed` step or every step `completed`/`skipped`; empty plans are not terminated) ## Opportunity competition diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 2c7784e92e..05ec54a5cf 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -150,6 +150,7 @@ 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 { isPlanTerminated } from "./plan-terminated.js"; export * from "./plan-templates.js"; export * from "./portfolio/queue.js"; export { diff --git a/packages/gittensory-engine/src/plan-terminated.ts b/packages/gittensory-engine/src/plan-terminated.ts new file mode 100644 index 0000000000..48fb46b502 --- /dev/null +++ b/packages/gittensory-engine/src/plan-terminated.ts @@ -0,0 +1,11 @@ +import type { PlanDag } from "./plan-export.js"; +import { hasPlanFailedSteps } from "./plan-failure.js"; +import { isPlanProgressComplete } from "./plan-progress-complete.js"; + +/** + * Return whether the plan reached a terminal outcome: any step `failed`, or every step is `completed` or `skipped`. + * Empty plans are not terminated. Pure. + */ +export function isPlanTerminated(plan: PlanDag): boolean { + return hasPlanFailedSteps(plan) || isPlanProgressComplete(plan); +} diff --git a/test/unit/plan-terminated.test.ts b/test/unit/plan-terminated.test.ts new file mode 100644 index 0000000000..84d9325276 --- /dev/null +++ b/test/unit/plan-terminated.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; + +import { isPlanTerminated } from "../../packages/gittensory-engine/src/plan-terminated"; +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("isPlanTerminated", () => { + it("returns false for an empty plan", () => { + expect(isPlanTerminated({ steps: [] })).toBe(false); + }); + + it("returns false while work is still in flight", () => { + expect( + isPlanTerminated({ + steps: [ + step({ id: "a", title: "Build", status: "running" }), + step({ id: "b", title: "Test", status: "pending" }), + ], + }), + ).toBe(false); + }); + + it("returns true when any step failed", () => { + expect( + isPlanTerminated({ + steps: [ + step({ id: "a", title: "Build", status: "completed" }), + step({ id: "b", title: "Deploy", status: "failed" }), + ], + }), + ).toBe(true); + }); + + it("returns true when every step is completed or skipped", () => { + expect( + isPlanTerminated({ + steps: [ + step({ id: "a", title: "Build", status: "completed" }), + step({ id: "b", title: "Deploy", status: "skipped" }), + ], + }), + ).toBe(true); + }); + + it("returns false for a blocked cyclic deadlock", () => { + expect( + isPlanTerminated({ + steps: [ + step({ id: "a", title: "A", dependsOn: ["b"] }), + step({ id: "b", title: "B", dependsOn: ["a"] }), + ], + }), + ).toBe(false); + }); + + it("is exported from the package barrel", async () => { + const barrel = await import("../../packages/gittensory-engine/src/index"); + expect(typeof barrel.isPlanTerminated).toBe("function"); + expect( + barrel.isPlanTerminated({ + steps: [step({ id: "a", title: "A", status: "failed" })], + }), + ).toBe(true); + }); +});