diff --git a/packages/workflow-utils/src/__tests__/role-decorators.test.ts b/packages/workflow-utils/src/__tests__/role-decorators.test.ts new file mode 100644 index 0000000..30c19f2 --- /dev/null +++ b/packages/workflow-utils/src/__tests__/role-decorators.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; + +import type { + Role, + RoleResult, + StartStep, + WorkflowMessage, +} from "@uncaged/nerve-core"; + +import { decorateRole, onFail, withDryRun } from "../role-decorators.js"; + +type TestMeta = { ok: boolean }; + +function fakeStart(dryRun: boolean): StartStep { + return { + role: "test", + meta: { + threadId: "t1", + dryRun, + startedAt: new Date().toISOString(), + }, + }; +} + +const successRole: Role = async () => ({ + content: "done", + meta: { ok: true }, +}); + +const failRole: Role = async () => { + throw new Error("boom"); +}; + +const failNonErrorRole: Role = async () => { + throw "string error"; +}; + +// --------------------------------------------------------------------------- +// withDryRun +// --------------------------------------------------------------------------- + +describe("withDryRun", () => { + const dec = withDryRun({ label: "test", meta: { ok: true } }); + + it("short-circuits on dry-run", async () => { + const role = dec(successRole); + const result = await role(fakeStart(true), []); + expect(result.content).toBe("[dry-run] test skipped"); + expect(result.meta).toEqual({ ok: true }); + }); + + it("delegates when not dry-run", async () => { + const role = dec(successRole); + const result = await role(fakeStart(false), []); + expect(result.content).toBe("done"); + expect(result.meta).toEqual({ ok: true }); + }); +}); + +// --------------------------------------------------------------------------- +// onFail +// --------------------------------------------------------------------------- + +describe("onFail", () => { + const dec = onFail({ label: "test", meta: { ok: false } }); + + it("passes through on success", async () => { + const role = dec(successRole); + const result = await role(fakeStart(false), []); + expect(result.content).toBe("done"); + expect(result.meta).toEqual({ ok: true }); + }); + + it("catches Error and returns structured failure", async () => { + const role = dec(failRole); + const result = await role(fakeStart(false), []); + expect(result.content).toBe("test failed: boom"); + expect(result.meta).toEqual({ ok: false }); + }); + + it("catches non-Error throws", async () => { + const role = dec(failNonErrorRole); + const result = await role(fakeStart(false), []); + expect(result.content).toBe("test failed: string error"); + expect(result.meta).toEqual({ ok: false }); + }); +}); + +// --------------------------------------------------------------------------- +// decorateRole +// --------------------------------------------------------------------------- + +describe("decorateRole", () => { + it("applies decorators left-to-right", async () => { + const role = decorateRole(failRole, [ + withDryRun({ label: "x", meta: { ok: true } }), + onFail({ label: "x", meta: { ok: false } }), + ]); + // Not dry-run, so withDryRun passes through → failRole throws → onFail catches + const result = await role(fakeStart(false), []); + expect(result.content).toBe("x failed: boom"); + expect(result.meta).toEqual({ ok: false }); + }); + + it("dry-run short-circuits before onFail", async () => { + const role = decorateRole(failRole, [ + withDryRun({ label: "x", meta: { ok: true } }), + onFail({ label: "x", meta: { ok: false } }), + ]); + const result = await role(fakeStart(true), []); + expect(result.content).toBe("[dry-run] x skipped"); + expect(result.meta).toEqual({ ok: true }); + }); +}); diff --git a/packages/workflow-utils/src/index.ts b/packages/workflow-utils/src/index.ts index 376b25f..56bc046 100644 --- a/packages/workflow-utils/src/index.ts +++ b/packages/workflow-utils/src/index.ts @@ -20,6 +20,14 @@ export { type ReadNerveYamlOptions, } from "./shared/context.js"; export { isDryRun } from "./role-types.js"; +export { + decorateRole, + withDryRun, + onFail, + type RoleDecorator, + type WithDryRunOptions, + type OnFailOptions, +} from "./role-decorators.js"; export { nerveCommandEnv, spawnSafe, diff --git a/packages/workflow-utils/src/role-decorators.ts b/packages/workflow-utils/src/role-decorators.ts new file mode 100644 index 0000000..a5cec4e --- /dev/null +++ b/packages/workflow-utils/src/role-decorators.ts @@ -0,0 +1,94 @@ +import type { Role, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; + +import { isDryRun } from "./role-types.js"; + +// --------------------------------------------------------------------------- +// Decorator types +// --------------------------------------------------------------------------- + +/** A role decorator: takes a role, returns an enhanced role. */ +export type RoleDecorator> = ( + role: Role, +) => Role; + +// --------------------------------------------------------------------------- +// decorateRole — compose a chain of decorators +// --------------------------------------------------------------------------- + +/** + * Apply an ordered list of decorators to a role. + * Decorators are applied left-to-right (first in list wraps innermost). + * + * ```ts + * decorateRole(role, [withDryRun(opts), onFail(opts)]); + * // equivalent to: onFail(opts)(withDryRun(opts)(role)) + * ``` + */ +export function decorateRole>( + role: Role, + decorators: RoleDecorator[], +): Role { + return decorators.reduce((r, dec) => dec(r), role); +} + +// --------------------------------------------------------------------------- +// withDryRun — skip execution when dry-run is active +// --------------------------------------------------------------------------- + +export type WithDryRunOptions = { + /** Used in skip message (e.g. "committer", "publish"). */ + label: string; + /** Meta returned when dry-run skips execution. */ + meta: M; +}; + +/** + * Returns a decorator that short-circuits with a stable result when + * `start.meta.dryRun` is true. + */ +export function withDryRun>( + opts: WithDryRunOptions, +): RoleDecorator { + return (role) => + async (start: StartStep, messages: WorkflowMessage[]) => { + if (isDryRun(start)) { + return { + content: `[dry-run] ${opts.label} skipped`, + meta: opts.meta, + }; + } + return role(start, messages); + }; +} + +// --------------------------------------------------------------------------- +// onFail — catch errors and return a structured failure result +// --------------------------------------------------------------------------- + +export type OnFailOptions = { + /** Used in failure message (e.g. "committer", "publish"). */ + label: string; + /** Meta returned when the inner role throws. */ + meta: M; +}; + +/** + * Returns a decorator that catches thrown errors and converts them into + * a structured RoleResult instead of propagating. + */ +export function onFail>( + opts: OnFailOptions, +): RoleDecorator { + return (role) => + async (start: StartStep, messages: WorkflowMessage[]) => { + try { + return await role(start, messages); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return { + content: `${opts.label} failed: ${msg}`, + meta: opts.meta, + }; + } + }; +}