# Actions module — `@suaveplan/rules/actions` _(no submodules)_ ## Purpose The actions module is the post-evaluation effect surface — named, timeout-bounded, abort-aware handlers that fire after a rule passes (or fails). Callers register handler functions on an `ActionRegistry`, wrap them in `Action` instances with `ActionConfig` (handler name, params, timeout, continue-on-error), and execute them sequentially or in parallel via `ActionExecutor`. The 4 built-in handlers (`log`, `set`, `delay`, `noop`) cover the common cases; the `log` handler takes an injectable `LoggerLike` rather than calling `console.log` directly (CLAUDE.md §2 bans `console.log` in `src/`). Every async path threads `AbortSignal` so callers can cancel long-running effects; every failure mode maps to a typed `RulesError` code (`RULES.ACTION_TIMEOUT`, `RULES.ACTION_ABORTED`, `RULES.ACTION_HANDLER_FAILED`, `RULES.ACTION_NOT_FOUND`). The module emits OTel histogram (`rules.action.execute.duration_ms`) and counter (`rules.action.executions_total{status}`) for every action invocation. ## Features - `ActionRegistry` — `register`, `get`, `has`, `names`, `unregister`. Empty names rejected with `RULES.ACTION_HANDLER_FAILED`. - `Action` — wraps an `ActionConfig` + `ActionRegistry`; constructor Zod-validates the config; `execute(context, options?)` returns an `ActionResult` rather than throwing. - `ActionExecutor` — `executeSequential` (respects `continueOnError`, reports later actions as `skipped` on hard stop); `executeParallel` (bounded concurrency via `parallelism`, defaults from constants module). - `builtInHandlers({ logger? })` — factory returning the `{ log, set, delay, noop }` handler bundle bound to a caller-supplied `LoggerLike`. - `validateActionConfig`, `createAction`, `createActionRegistry`, `createActionExecutor` — function factories matching the convention used by every other package. ## Usage: Basic Example ```ts import { context } from "@suaveplan/rules/context"; import { builtInHandlers, createAction, createActionExecutor, createActionRegistry, } from "@suaveplan/rules/actions"; const registry = createActionRegistry(); for (const [name, handler] of Object.entries(builtInHandlers())) { registry.register(name, handler); } const executor = createActionExecutor(); const result = await executor.executeSequential( [createAction({ handler: "log", params: { message: "ran" } }, registry)], context({}), ); result[0]?.status; // "success" ``` ## Usage: Advanced Example ```ts import { context } from "@suaveplan/rules/context"; import { Action, ActionExecutor, ActionRegistry, builtInHandlers, } from "@suaveplan/rules/actions"; import type { LoggerLike } from "@suaveplan/types"; const logger: LoggerLike = { debug: (m, ctx) => process.stderr.write(`debug ${m} ${JSON.stringify(ctx)}\n`), info: (m, ctx) => process.stderr.write(`info ${m} ${JSON.stringify(ctx)}\n`), warn: (m, ctx) => process.stderr.write(`warn ${m} ${JSON.stringify(ctx)}\n`), error: (m, ctx) => process.stderr.write(`error ${m} ${JSON.stringify(ctx)}\n`), }; const registry = new ActionRegistry(); for (const [name, handler] of Object.entries(builtInHandlers({ logger }))) { registry.register(name, handler); } registry.register("fetch", async (params, _ctx, { signal }) => { const response = await fetch(params.url as string, { signal }); return response.json(); }); const executor = new ActionExecutor({ parallelism: 4 }); const controller = new AbortController(); setTimeout(() => controller.abort(), 500); // hard deadline const results = await executor.executeParallel( [ new Action({ handler: "fetch", params: { url: "/api/a" }, timeoutMs: 200 }, registry), new Action({ handler: "fetch", params: { url: "/api/b" }, timeoutMs: 200 }, registry), new Action({ handler: "log", params: { message: "done" } }, registry), ], context({}), { signal: controller.signal, parallelism: 2 }, ); for (const r of results) { if (r.status === "failed") console.warn(r.handler, r.errorCode, r.errorMessage); } ``` ## API Reference ### `class ActionRegistry` Standard register/get/has/names/unregister surface. Empty handler name raises `RULES.ACTION_HANDLER_FAILED`. ### `class Action` - `new Action(config, registry)` — Zod-validates `config`; raises `RULES.ACTION_HANDLER_FAILED` on rejection. - `execute(context, options?)` → `Promise`. Never throws — every failure becomes an `ActionResult { status: "failed", errorCode, errorMessage }`. - Pre-aborted signal → `errorCode: "RULES.ACTION_ABORTED"`. - Timeout (config.timeoutMs or `DEFAULT_ACTION_TIMEOUT_MS`) → `errorCode: "RULES.ACTION_TIMEOUT"`. - Caller aborts mid-flight → `errorCode: "RULES.ACTION_ABORTED"`. - Handler throws → `errorCode: "RULES.ACTION_HANDLER_FAILED"`, original message preserved. ### `class ActionExecutor` - `new ActionExecutor(defaults?)` — `defaults.clock` is used for duration measurement; `defaults.parallelism` (default `DEFAULT_ACTION_PARALLELISM = 8`) is the executeParallel ceiling when not overridden. - `executeSequential(actions, context, options?)` → `Promise`. Stops on first failure unless `action.config.continueOnError === true`; remaining actions return `status: "skipped"` with `durationMs: 0`. - `executeParallel(actions, context, options?)` → `Promise`. Bounded by `options.parallelism ?? defaults.parallelism ?? DEFAULT_ACTION_PARALLELISM`. `parallelism <= 0` raises `RULES.ACTION_HANDLER_FAILED`. ### `function builtInHandlers(deps?)` Returns the `{ log, set, delay, noop }` handler bundle. - `log(params, context, { signal })` — `params.level` (one of `debug` / `info` / `warn` / `error`, default `info`), `params.message` (default `"log"`). Calls `deps.logger[level](message)`; no-op fallback when `deps.logger` is omitted. - `set(params, context, { signal })` — writes `params.value` onto `context.data[params.target]`. Missing or empty `params.target` raises `RULES.ACTION_HANDLER_FAILED`. - `delay(params, context, { signal })` — `params.ms` milliseconds (default 0). Aborts cleanly with `RULES.ACTION_ABORTED` when the signal fires. - `noop` — always returns `undefined`. ### Factory aliases `createActionRegistry()`, `createAction(config, registry)`, `createActionExecutor(defaults?)`, `validateActionConfig(config)`. ## Implementation Notes - `Action.execute` uses a local `AbortController` linked to the caller signal AND a `setTimeout`-driven timeout abort. The race is implemented as `Promise.race([handlerPromise, abortPromise])`; the abort promise discriminates timeout vs caller abort by inspecting the caller's signal state at fire time. - The signal listener is registered with `{ once: true }` and the `finally` clears the timeout and removes the listener, so a long-lived caller signal does not accumulate listeners across many actions. - `executeSequential` reports skipped actions deterministically: every action after a hard-stop failure returns `{ status: "skipped", handler, durationMs: 0 }` in the result array, preserving 1:1 correspondence with the input. - `executeParallel` uses a shared cursor with `Math.min(limit, actions.length)` worker promises; each worker pulls the next index until the cursor exhausts. This is the simplest bounded-concurrency pattern that works under `AbortSignal` without an external semaphore library. - OTel emission is best-effort: a global no-op meter is used when no SDK is installed, so handlers in `vitest run` without a `MeterProvider` still execute correctly. - The `delay` handler is intentionally simple — it does not return a `Promise.race` with a `signal.reason` because callers can read `signal.reason` themselves; surfacing it inside the handler would couple `RulesError` shape to `DOMException` shape.