Merge pull request 'feat(workflow-utils): add withDryRun role wrapper' (#255) from feat/254-with-dry-run into main

Reviewed-on: #255
This commit was merged in pull request #255.
This commit is contained in:
2026-04-29 13:28:22 +00:00
3 changed files with 216 additions and 0 deletions
@@ -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<TestMeta> = async () => ({
content: "done",
meta: { ok: true },
});
const failRole: Role<TestMeta> = async () => {
throw new Error("boom");
};
const failNonErrorRole: Role<TestMeta> = async () => {
throw "string error";
};
// ---------------------------------------------------------------------------
// withDryRun
// ---------------------------------------------------------------------------
describe("withDryRun", () => {
const dec = withDryRun<TestMeta>({ 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<TestMeta>({ 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 });
});
});
+8
View File
@@ -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,
@@ -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<M extends Record<string, unknown>> = (
role: Role<M>,
) => Role<M>;
// ---------------------------------------------------------------------------
// 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<M extends Record<string, unknown>>(
role: Role<M>,
decorators: RoleDecorator<M>[],
): Role<M> {
return decorators.reduce((r, dec) => dec(r), role);
}
// ---------------------------------------------------------------------------
// withDryRun — skip execution when dry-run is active
// ---------------------------------------------------------------------------
export type WithDryRunOptions<M> = {
/** 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<M extends Record<string, unknown>>(
opts: WithDryRunOptions<M>,
): RoleDecorator<M> {
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<M> = {
/** 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<M extends Record<string, unknown>>(
opts: OnFailOptions<M>,
): RoleDecorator<M> {
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,
};
}
};
}