Files
skills/docs/foundation/flow/rules/src/actions/actions.md
2026-05-29 08:20:56 +01:00

7.8 KiB

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

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

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<ActionResult>. 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<readonly ActionResult[]>. Stops on first failure unless action.config.continueOnError === true; remaining actions return status: "skipped" with durationMs: 0.
  • executeParallel(actions, context, options?) → Promise<readonly ActionResult[]>. 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.