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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user