// Props
import { Runbook } from "smithers-orchestrator";
type RunbookProps = {
id?: string; // used as step-id prefix; defaults to "runbook" when omitted
steps: RunbookStep[];
defaultAgent?: AgentLike;
stepOutput: OutputTarget;
approvalRequest?: Partial<ApprovalRequest>;
onDeny?: "fail" | "skip"; // default "fail"
skipIf?: boolean;
};
type RunbookStep = {
id: string;
agent?: AgentLike;
command?: string;
risk: "safe" | "risky" | "critical"; // critical adds `elevated: true` to approval meta
label?: string;
output?: OutputTarget;
};
export default smithers(() => (
<Workflow name="deploy-runbook">
<Runbook
defaultAgent={ops}
stepOutput={outputs.stepResult}
steps={[
{ id: "health-check", command: "curl -f https://api.example.com/health", risk: "safe" },
{ id: "backup-db", command: "pg_dump prod > backup.sql", risk: "risky" },
{ id: "run-migration", command: "npx prisma migrate deploy", risk: "critical" },
{ id: "smoke-test", command: "npm run test:smoke", risk: "safe" },
]}
/>
</Workflow>
));
Notes
- Each step depends on the previous via
needs; execution order is guaranteed. - Critical steps set
elevated: truein approval metadata for stronger auth UIs. - Approval output is stored at
{prefix}-{step.id}-approval-decision.
Source
The<Runbook> implementation and the files it imports, straight from the package source. This section is generated; edit the source, not this block.
// @smithers-type-exports-begin
/** @typedef {import("./RunbookProps.ts").RunbookProps} RunbookProps */
/** @typedef {import("./RunbookStep.ts").RunbookStep} RunbookStep */
// @smithers-type-exports-end
import React from "react";
import { SmithersContext } from "@smithers-orchestrator/react-reconciler/context";
import { Sequence } from "./Sequence.js";
import { Task } from "./Task.js";
import { Approval } from "./Approval.js";
/**
* <Runbook> — Sequential steps with risk classification.
*
* Safe steps auto-execute. Risky and critical steps require human approval first.
* Composes: Sequence of [Approval? → Task] per step, chained via `needs`.
* @param {RunbookProps} props
*/
export function Runbook(props) {
if (props.skipIf)
return null;
const ctx = React.useContext(SmithersContext);
const prefix = props.id ?? "runbook";
const onDeny = props.onDeny ?? "fail";
const children = [];
let previousStepId;
for (let i = 0; i < props.steps.length; i++) {
const step = props.steps[i];
const stepId = `${prefix}-${step.id}`;
const agent = step.agent ?? props.defaultAgent;
const output = step.output ?? props.stepOutput;
const label = step.label ?? step.id;
// Build needs: each step depends on the previous step's completion
const needs = previousStepId
? { previousStep: previousStepId }
: undefined;
if (step.risk === "safe") {
// Safe: plain Task, auto-executes
children.push(React.createElement(Task, {
key: stepId,
id: stepId,
output,
agent,
needs,
label: `[safe] ${label}`,
children: step.command ?? `Execute step: ${label}`,
}));
previousStepId = stepId;
}
else {
// Risky or critical: Approval gate then Task
const approvalId = `${stepId}-approval`;
const approvalOutput = `${approvalId}-decision`;
const shouldGateOnDecision = onDeny === "skip" && Boolean(ctx);
const decision = ctx?.outputMaybe(approvalOutput, { nodeId: approvalId });
const deniedAndSkipping = shouldGateOnDecision &&
decision != null &&
typeof decision === "object" &&
/** @type {Record<string, unknown>} */ (decision).approved === false;
const isCritical = step.risk === "critical";
const approvalTitle = props.approvalRequest?.title ??
`Approve ${isCritical ? "CRITICAL" : "risky"} step: ${label}`;
const approvalSummary = props.approvalRequest?.summary ??
(isCritical
? `CRITICAL step requires elevated approval. Command: ${step.command ?? label}`
: `Risky step requires approval before execution. Command: ${step.command ?? label}`);
const approvalMeta = {
stepId: step.id,
risk: step.risk,
...props.approvalRequest?.metadata,
};
if (isCritical) {
approvalMeta.elevated = true;
}
children.push(React.createElement(Approval, {
key: approvalId,
id: approvalId,
output: approvalOutput,
request: {
title: approvalTitle,
summary: approvalSummary,
metadata: approvalMeta,
},
onDeny,
needs,
label: `Approve: ${label}`,
}));
if (!deniedAndSkipping) {
children.push(React.createElement(Task, {
key: stepId,
id: stepId,
output,
agent,
deps: shouldGateOnDecision ? { approval: approvalOutput } : undefined,
needs: { approval: approvalId },
label: `[${step.risk}] ${label}`,
children: step.command ?? `Execute step: ${label}`,
}));
}
previousStepId = deniedAndSkipping ? approvalId : stepId;
}
}
return React.createElement(Sequence, null, ...children);
}
// @smithers-type-exports-begin
/** @typedef {import("./OutputSnapshot.ts").OutputSnapshot} OutputSnapshot */
/** @typedef {import("./SmithersCtxOptions.ts").SmithersCtxOptions} SmithersCtxOptions */
// @smithers-type-exports-end
import React from "react";
import { SmithersCtx } from "@smithers-orchestrator/driver/SmithersCtx";
import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
export { SmithersCtx } from "@smithers-orchestrator/driver/SmithersCtx";
/** @type {React.Context<SmithersCtx<any> | null>} */
export const SmithersContext = React.createContext(null);
SmithersContext.displayName = "SmithersContext";
/**
* @template Schema
* @returns {{ SmithersContext: React.Context<SmithersCtx<Schema> | null>, useCtx: () => SmithersCtx<Schema> }}
*/
export function createSmithersContext() {
/** @type {React.Context<SmithersCtx<Schema> | null>} */
const Context = React.createContext(null);
Context.displayName = "SmithersContext";
/**
* @returns {SmithersCtx<Schema>}
*/
function useCtx() {
const ctx = React.useContext(Context);
if (!ctx) {
throw new SmithersError("CONTEXT_OUTSIDE_WORKFLOW", "useCtx() must be called inside a <Workflow> created by createSmithers()");
}
return ctx;
}
return { SmithersContext: Context, useCtx };
}
import React from "react";
/** @typedef {import("./SequenceProps.ts").SequenceProps} SequenceProps */
/**
* @param {SequenceProps} props
*/
export function Sequence(props) {
if (props.skipIf)
return null;
// Sequence carries only a display label; pass a sanitized bag (align with
// the sanitizing structural components) so control props don't leak through.
// `label` names the phase group in run views (graph, the Claude /workflows
// mirror) and is preserved in the persisted frame XML.
const next = props.label === undefined ? {} : { label: props.label };
return React.createElement("smithers:sequence", next, props.children);
}
// @smithers-type-exports-begin
/**
* @template D
* @typedef {import("./InferDeps.ts").InferDeps<D>} InferDeps
*/
/** @typedef {import("./OutputTarget.ts").OutputTarget} OutputTarget */
// @smithers-type-exports-end
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { markdownComponents } from "../markdownComponents.js";
import { zodSchemaToJsonExample } from "../zod-to-example.js";
import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
import { SmithersContext } from "@smithers-orchestrator/react-reconciler/context";
import { AspectContext } from "../aspects/AspectContext.js";
import { AntigravityAgent } from "@smithers-orchestrator/agents/AntigravityAgent";
import { ClaudeCodeAgent } from "@smithers-orchestrator/agents/ClaudeCodeAgent";
import { GeminiAgent } from "@smithers-orchestrator/agents/GeminiAgent";
import { PiAgent } from "@smithers-orchestrator/agents/PiAgent";
/** @typedef {import("@smithers-orchestrator/agents/AgentLike").AgentLike} AgentLike */
/** @typedef {import("./DepsSpec.ts").DepsSpec} DepsSpec */
/**
* @template Row, Output, D
* @typedef {import("./TaskProps.ts").TaskProps<Row, Output, D>} TaskProps
*/
/**
* Reverse the HTML-entity escaping that renderToStaticMarkup applies to text
* (& < > " '). Without this, a prompt like `a < b && c` reaches the agent as
* `a < b && c`, and injected JSON schema hints (`"key"`) arrive as
* `"key"`, both of which corrupt the agent-facing text.
* `&` is decoded last so an escaped literal (`&lt;`, meaning the source
* text `<`) is not double-decoded into `<`.
* @param {string} html
* @returns {string}
*/
function decodeHtmlEntities(html) {
return html
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/'/g, "'")
.replace(/&/g, "&");
}
/**
* Render a prompt React node to plain markdown text.
*
* If the prompt is a React element (e.g. a compiled MDX component), we inject
* `markdownComponents` via the standard MDX `components` prop so that
* renderToStaticMarkup outputs clean markdown instead of HTML. The static
* render entity-escapes all text, so we decode the entities back to literal
* characters before handing the prompt to the agent.
* @param {unknown} prompt
* @returns {string}
*/
export function renderPromptToText(prompt) {
if (prompt == null)
return "";
if (typeof prompt === "string")
return prompt;
if (typeof prompt === "number")
return String(prompt);
try {
let element;
if (React.isValidElement(prompt)) {
// Inject markdown components into the element so MDX components
// render fragments instead of HTML tags.
element = React.cloneElement(prompt, {
components: markdownComponents,
});
}
else {
element = React.createElement(React.Fragment, null, prompt);
}
return decodeHtmlEntities(renderToStaticMarkup(element))
.replace(/\n{3,}/g, "\n\n")
.trim();
}
catch (err) {
const result = String(prompt ?? "");
if (result === "[object Object]") {
throw new SmithersError("MDX_PRELOAD_INACTIVE", `MDX prompt could not be rendered — the prompt resolved to [object Object] instead of a React component.\n\n` +
`This usually means the MDX preload is not active. Common causes:\n` +
` • bunfig.toml uses [run] preload instead of top-level preload (the [run] section doesn't apply to dynamic imports)\n` +
` • bunfig.toml is not in the current working directory\n` +
` • mdxPlugin() is not registered in the preload script\n` +
` • The MDX file is imported without a default import (use: import MyPrompt from "./prompt.mdx")\n\n` +
`Original error: ${err instanceof Error ? err.message : String(err)}`);
}
return result;
}
}
/**
* @param {unknown} value
* @returns {value is import("zod").ZodObject<import("zod").ZodRawShape>}
*/
function isZodObject(value) {
return Boolean(value && typeof value === "object" && "shape" in value);
}
/**
* @param {DepsSpec | undefined} deps
* @param {Record<string, string> | undefined} needs
* @returns {string[] | undefined}
*/
function deriveDepNodeIds(deps, needs) {
if (!deps)
return undefined;
const ids = new Set();
for (const key of Object.keys(deps)) {
const nodeId = needs?.[key] ?? key;
if (nodeId)
ids.add(nodeId);
}
return ids.size > 0 ? [...ids] : undefined;
}
/**
* @param {string[] | undefined} dependsOn
* @param {string[] | undefined} depNodeIds
* @returns {string[] | undefined}
*/
function mergeDependsOn(dependsOn, depNodeIds) {
const merged = new Set();
for (const id of dependsOn ?? [])
merged.add(id);
for (const id of depNodeIds ?? [])
merged.add(id);
return merged.size > 0 ? [...merged] : undefined;
}
/**
* @param {any} ctx
* @param {DepsSpec | undefined} deps
* @param {Record<string, string> | undefined} needs
* @param {boolean | undefined} depsOptional
* @returns {Record<string, unknown> | null}
*/
function resolveDeps(ctx, deps, needs, depsOptional) {
if (!deps)
return Object.create(null);
const keys = Object.keys(deps);
if (keys.length === 0)
return Object.create(null);
const resolved = Object.create(null);
for (const key of keys) {
const target = deps[key];
const nodeId = needs?.[key] ?? key;
const value = ctx.outputMaybe(target, { nodeId });
if (value === undefined) {
// Optional deps mode: omit unresolved keys instead of deferring the
// whole task. Used when the task is already gated by `needs`/`dependsOn`
// and upstream tasks may legitimately fail (continueOnFail) without
// producing an output row.
if (depsOptional)
continue;
return null;
}
resolved[key] = value;
}
return resolved;
}
/**
* @param {AgentLike} agent
* @param {string[] | undefined} allowTools
* @returns {AgentLike}
*/
function applyCliToolAllowlist(agent, allowTools) {
if (!allowTools) {
return agent;
}
if (agent instanceof ClaudeCodeAgent) {
const opts = { ...agent.opts };
if (allowTools.length === 0) {
return new ClaudeCodeAgent({
...opts,
allowedTools: [],
tools: "",
});
}
return new ClaudeCodeAgent({
...opts,
allowedTools: [...allowTools],
});
}
if (agent instanceof PiAgent) {
const opts = { ...agent.opts };
if (allowTools.length === 0) {
return new PiAgent({
...opts,
tools: [],
noTools: true,
});
}
return new PiAgent({
...opts,
tools: [...allowTools],
noTools: false,
});
}
if (agent instanceof GeminiAgent) {
const opts = { ...agent.opts };
return new GeminiAgent({
...opts,
allowedTools: [...allowTools],
});
}
if (agent instanceof AntigravityAgent) {
const opts = { ...agent.opts };
return new AntigravityAgent({
...opts,
allowedTools: [...allowTools],
});
}
return agent;
}
/**
* @param {unknown} ctx
* @param {string[] | undefined} allowTools
* @returns {string[] | undefined}
*/
function resolveCliToolAllowlist(ctx, allowTools) {
if (allowTools !== undefined) {
return allowTools;
}
const cliAgentToolsDefault = ctx && typeof ctx === "object"
? ctx.__smithersRuntime?.cliAgentToolsDefault
: undefined;
return cliAgentToolsDefault === "explicit-only" ? [] : undefined;
}
/**
* @template Row, Output, D
* @param {TaskProps<Row, Output, D>} props
* @returns {React.ReactElement | null}
*/
export function Task(props) {
const { children, agent, fallbackAgent, deps, depsOptional, ...rest } = props;
const taskContext = props.smithersContext ?? SmithersContext;
const ctx = React.useContext(taskContext);
const aspectCtx = React.useContext(AspectContext);
const depNodeIds = deriveDepNodeIds(deps, rest.needs);
if (deps && !ctx) {
throw new SmithersError("CONTEXT_OUTSIDE_WORKFLOW", "Task deps require a workflow context. Build the workflow with createSmithers().");
}
const resolvedDeps = deps ? resolveDeps(ctx, deps, rest.needs, depsOptional) : undefined;
if (deps && resolvedDeps == null) {
// Deps not yet available — component defers until upstream tasks complete.
// This is normal reactive behavior; the task will re-render once deps are
// ready. Record the deferral so the engine can distinguish a transient wait
// from a permanent one: a deferral that survives to quiescence means a
// dependency that can never resolve (e.g. a deps key that maps to a node id
// no task produces), which would otherwise be a silent skip.
ctx?.recordDeferredDep?.(props.id, depNodeIds ?? []);
return null;
}
// Build aspect metadata to attach to the task element so the engine can
// enforce budgets and track metrics at execution time.
const aspectMeta = aspectCtx ? buildAspectMeta(aspectCtx) : undefined;
const agentChain = Array.isArray(agent)
? fallbackAgent
? [...agent, fallbackAgent]
: agent
: agent && fallbackAgent
? [agent, fallbackAgent]
: agent;
const effectiveAllowTools = resolveCliToolAllowlist(ctx, rest.allowTools);
const restrictedAgentChain = Array.isArray(agentChain)
? agentChain.map((entry) => applyCliToolAllowlist(entry, effectiveAllowTools))
: agentChain
? applyCliToolAllowlist(agentChain, effectiveAllowTools)
: agentChain;
const nextDependsOn = mergeDependsOn(rest.dependsOn, depNodeIds);
const childValue = typeof children === "function" && (agent || deps)
? children(resolvedDeps ?? Object.create(null))
: children;
if (agent) {
// Auto-inject `schema` prop into React element children when output is a ZodObject
let childElement = childValue;
const schemaForInjection = props.outputSchema ??
(isZodObject(props.output) ? props.output : undefined);
if (React.isValidElement(childValue) && schemaForInjection) {
childElement = React.cloneElement(childValue, {
schema: zodSchemaToJsonExample(schemaForInjection),
});
}
const prompt = renderPromptToText(childElement);
return React.createElement("smithers:task", {
...rest,
dependsOn: nextDependsOn,
waitAsync: rest.async === true,
agent: restrictedAgentChain,
__smithersKind: "agent",
...aspectMeta,
}, prompt);
}
if (typeof children === "function" && !deps) {
const nextProps = {
...rest,
dependsOn: nextDependsOn,
waitAsync: rest.async === true,
__smithersKind: "compute",
__smithersComputeFn: children,
...aspectMeta,
};
return React.createElement("smithers:task", nextProps, null);
}
const nextProps = {
...rest,
dependsOn: nextDependsOn,
waitAsync: rest.async === true,
__smithersKind: "static",
__smithersPayload: childValue,
__payload: childValue,
...aspectMeta,
};
return React.createElement("smithers:task", nextProps, null);
}
/**
* Build the __aspects metadata object from the current AspectContext.
* This is attached to the smithers:task element props so the engine can read
* budgets and tracking config at execution time.
* @param {{
* tokenBudget?: unknown;
* latencySlo?: unknown;
* tracking?: unknown;
* accumulator?: unknown;
* }} aspectCtx
* @returns {{ __aspects: Record<string, unknown> }}
*/
function buildAspectMeta(aspectCtx) {
return {
__aspects: {
tokenBudget: aspectCtx.tokenBudget,
latencySlo: aspectCtx.latencySlo,
tracking: aspectCtx.tracking,
accumulator: aspectCtx.accumulator,
},
};
}
// @smithers-type-exports-begin
/** @typedef {import("./ApprovalDecision.ts").ApprovalDecision} ApprovalDecision */
/** @typedef {import("./ApprovalRanking.ts").ApprovalRanking} ApprovalRanking */
/** @typedef {import("./ApprovalRequest.ts").ApprovalRequest} ApprovalRequest */
/** @typedef {import("./ApprovalSelection.ts").ApprovalSelection} ApprovalSelection */
// @smithers-type-exports-end
import React from "react";
import { z } from "zod";
import { SmithersContext } from "@smithers-orchestrator/react-reconciler/context";
import { getTaskRuntime } from "@smithers-orchestrator/driver/task-runtime";
import { SmithersDb } from "@smithers-orchestrator/db/adapter";
import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
/** @typedef {import("./ApprovalAutoApprove.ts").ApprovalAutoApprove} ApprovalAutoApprove */
/** @typedef {import("./ApprovalMode.ts").ApprovalMode} ApprovalMode */
/** @typedef {import("./ApprovalOption.ts").ApprovalOption} ApprovalOption */
/**
* @template Row, Output
* @typedef {import("./ApprovalProps.ts").ApprovalProps<Row, Output>} ApprovalProps
*/
export const approvalDecisionSchema = z.object({
approved: z.boolean(),
// `note` is omitted entirely when no note was provided, so the default
// decision schema must accept an absent key (optional) as well as the
// legacy null/string shapes.
note: z.string().nullable().optional(),
decidedBy: z.string().nullable(),
decidedAt: z.string().datetime().nullable(),
});
export const approvalSelectionSchema = z.object({
selected: z.string(),
notes: z.string().nullable(),
});
export const approvalRankingSchema = z.object({
ranked: z.array(z.string()),
notes: z.string().nullable(),
});
/**
* @param {unknown} value
* @returns {value is import("zod").ZodObject<import("zod").ZodRawShape>}
*/
function isZodObject(value) {
return Boolean(value && typeof value === "object" && "shape" in value);
}
/**
* @template T
* @param {unknown} value
* @returns {T | null}
*/
function parseJson(value) {
if (typeof value !== "string" || value.length === 0) {
return null;
}
try {
return JSON.parse(value);
}
catch {
return null;
}
}
/**
* @param {ApprovalMode} mode
* @returns {import("zod").ZodObject<import("zod").ZodRawShape>}
*/
function defaultSchemaForMode(mode) {
switch (mode) {
case "select":
return approvalSelectionSchema;
case "rank":
return approvalRankingSchema;
default:
return approvalDecisionSchema;
}
}
/**
* @param {{ status?: string | null; note?: string | null; decidedBy?: string | null; decidedAtMs?: number | null } | undefined | null} approval
* @param {import("zod").ZodObject<import("zod").ZodRawShape>} outputSchema
* @returns {Record<string, unknown>}
*/
function buildDecisionPayload(approval, outputSchema) {
const base = {
approved: approval?.status === "approved",
decidedBy: approval?.decidedBy ?? null,
decidedAt: approval?.decidedAtMs != null ? new Date(approval.decidedAtMs).toISOString() : null,
};
if (typeof approval?.note === "string") {
return { ...base, note: approval.note };
}
if (outputSchema.safeParse(base).success) {
return base;
}
return { ...base, note: null };
}
/**
* @param {ApprovalMode | undefined} mode
* @returns {"select" | "rank" | "decision"}
*/
function normalizeMode(mode) {
switch (mode) {
case "select":
return "select";
case "rank":
return "rank";
default:
return "decision";
}
}
/**
* @param {ApprovalOption[] | undefined} options
* @returns {ApprovalOption[] | undefined}
*/
function normalizeOptions(options) {
return options?.map((option) => ({
key: option.key,
label: option.label,
...(option.summary ? { summary: option.summary } : {}),
...(option.metadata ? { metadata: option.metadata } : {}),
}));
}
/**
* @param {ApprovalAutoApprove[keyof ApprovalAutoApprove]} callback
* @param {import("@smithers-orchestrator/driver").SmithersCtx<unknown> | null} ctx
* @returns {boolean | undefined}
*/
function evaluateBooleanCallback(callback, ctx) {
if (typeof callback !== "function") {
return undefined;
}
return Boolean(/** @type {(ctx: import("@smithers-orchestrator/driver").SmithersCtx<unknown> | null) => boolean} */ (callback)(ctx));
}
/**
* @template Row
* @param {ApprovalProps<Row>} props
* @returns {React.ReactElement | null}
*/
export function Approval(props) {
if (props.skipIf)
return null;
const smithersContext = props.smithersContext ?? SmithersContext;
const ctx = React.useContext(smithersContext);
const mode = props.mode ?? "approve";
const approvalMode = normalizeMode(mode);
const options = normalizeOptions(props.options);
const outputSchema = props.outputSchema ??
(isZodObject(props.output) ? props.output : defaultSchemaForMode(mode));
if ((mode === "select" || mode === "rank") && (!options || options.length === 0)) {
throw new SmithersError("APPROVAL_OPTIONS_REQUIRED", `Approval ${props.id} requires options when mode="${mode}".`);
}
const conditionMet = props.autoApprove
? evaluateBooleanCallback(props.autoApprove.condition, ctx)
: undefined;
const revertOnMet = props.autoApprove
? evaluateBooleanCallback(props.autoApprove.revertOn, ctx)
: undefined;
const autoApprove = props.autoApprove
? {
...(typeof props.autoApprove.after === "number" ? { after: props.autoApprove.after } : {}),
audit: props.autoApprove.audit !== false,
...(conditionMet !== undefined ? { conditionMet } : {}),
...(revertOnMet !== undefined ? { revertOnMet } : {}),
}
: undefined;
const requestMeta = {
requestTitle: props.request.title,
...(props.request.summary ? { requestSummary: props.request.summary } : {}),
...(options ? { approvalOptions: options } : {}),
...(props.allowedScopes?.length ? { approvalAllowedScopes: props.allowedScopes } : {}),
...(props.allowedUsers?.length ? { approvalAllowedUsers: props.allowedUsers } : {}),
...(autoApprove ? { approvalAutoApprove: autoApprove } : {}),
...props.request.metadata,
...props.meta,
};
/**
* @returns {Promise<Row>}
*/
const computeDecision = async () => {
const runtime = getTaskRuntime();
if (!runtime) {
throw new SmithersError("APPROVAL_OUTSIDE_TASK", "Approval decisions can only be resolved while a Smithers task is executing.");
}
const adapter = new SmithersDb(runtime.db);
const approval = await adapter.getApproval(runtime.runId, props.id, runtime.iteration);
const decision = parseJson(approval?.decisionJson);
if (approvalMode === "select") {
return {
selected: typeof decision?.selected === "string" ? decision.selected : "",
notes: typeof decision?.notes === "string"
? decision.notes
: approval?.note ?? null,
};
}
if (approvalMode === "rank") {
return {
ranked: Array.isArray(decision?.ranked)
? decision.ranked.filter((value) => typeof value === "string")
: [],
notes: typeof decision?.notes === "string"
? decision.notes
: approval?.note ?? null,
};
}
return buildDecisionPayload(approval, outputSchema);
};
return React.createElement("smithers:task", {
id: props.id,
key: props.key,
output: props.output,
outputSchema,
dependsOn: props.dependsOn,
needs: props.needs,
needsApproval: true,
waitAsync: props.async === true,
approvalMode,
approvalOnDeny: props.onDeny,
approvalOptions: options,
approvalAllowedScopes: props.allowedScopes,
approvalAllowedUsers: props.allowedUsers,
approvalAutoApprove: autoApprove,
timeoutMs: props.timeoutMs,
heartbeatTimeoutMs: props.heartbeatTimeoutMs,
heartbeatTimeout: props.heartbeatTimeout,
retries: props.retries,
retryPolicy: props.retryPolicy,
continueOnFail: props.continueOnFail,
cache: props.cache,
label: props.label ?? props.request.title,
meta: Object.keys(requestMeta).length > 0 ? requestMeta : undefined,
__smithersKind: "compute",
__smithersComputeFn: computeDecision,
});
}
import type { AgentLike } from "@smithers-orchestrator/agents/AgentLike";
import type { ApprovalRequest } from "./ApprovalRequest.ts";
import type { RunbookStep } from "./RunbookStep.ts";
import type { OutputTarget } from "./OutputTarget.ts";
export type RunbookProps = {
id?: string;
/** Ordered steps to execute. */
steps: RunbookStep[];
/** Default agent for steps that don't specify one. */
defaultAgent?: AgentLike;
/** Default output schema for step results. */
stepOutput: OutputTarget;
/** Template for approval requests on risky/critical steps. */
approvalRequest?: Partial<ApprovalRequest>;
/** Behavior when a risky/critical step is denied: "fail" (default) or "skip". */
onDeny?: "fail" | "skip";
skipIf?: boolean;
};
import type { AgentLike } from "@smithers-orchestrator/agents/AgentLike";
import type { OutputTarget } from "./OutputTarget.ts";
export type RunbookStep = {
/** Unique step identifier. */
id: string;
/** Agent for this step (falls back to `defaultAgent`). */
agent?: AgentLike;
/** Shell command or instruction for the step. */
command?: string;
/** Risk classification: safe auto-executes, risky/critical require approval. */
risk: "safe" | "risky" | "critical";
/** Human-readable label for the step. */
label?: string;
/** Per-step output schema override. */
output?: OutputTarget;
};