/** * From the document the agent hands over to the function that answers it, * and from that function's return to what the platform records. One path for * production (exec and gRPC) and for `pkg.run()` in a test. * * The rules, in order: * 1. a notification naming a service, link or scope specification of this * package → that specification's bound action (default by type, custom * by slug; links by the link's slug; scopes by slug, then `action`); * 2. a notification with a `source` and an event name (`event`, or `action` * as the notifications API spells it) → the `event()` of that name; * 3. anything else → the `execute()` component, with the document parsed * and raw. A package without one refuses with UNKNOWN_ACTION. */ import * as platformApi from "../api"; import { createApiClient, type ApiClient } from "../api/client"; import type { PlatformClient } from "../platform"; import { buildActionContext, type EmitFn } from "../scope/context"; import type { Narrative } from "../scope/narrative"; import { createScopeState } from "../scope/state"; import type { Notification } from "../scope/index"; import { getActionDefaults } from "../scope/actions"; import { dim, red } from "../term"; import type { EventComponent, ExecuteComponent } from "./components"; import { createBuildContext } from "./build-context"; import { buildEvent } from "./event"; import { ActionError, handlerKey, type Handler, type HandlerContext, type HandlerFn, type HandlerSettings, } from "./handler"; import type { Resource } from "./resource"; import { validateResults } from "./results"; import { secrets as defaultSecrets } from "./secrets"; import { DEFAULT_ACTIONS } from "./service"; export interface Dispatchable { resources: Resource[]; handlers: Handler[]; events: EventComponent[]; execute?: ExecuteComponent; slug?: string; version?: string; } /** What the agent hands over: the parsed document and the string it came as. */ export interface Document { doc: unknown; raw: string; } export interface Resolved { kind: "action" | "event" | "execute"; fn: HandlerFn; resource?: Resource; /** The action key, the event name, or `execute`. */ action: string; } const isDefault = (t: string): t is (typeof DEFAULT_ACTIONS)[number] => (DEFAULT_ACTIONS as readonly string[]).includes(t); /** The notification inside a document: `{ notification }` unwrapped, or the document itself. */ export function unwrap(doc: unknown): Record { if (!doc || typeof doc !== "object" || Array.isArray(doc)) return {}; const d = doc as Record; const n = d.notification; return n && typeof n === "object" && !Array.isArray(n) ? (n as Record) : d; } const eventNameOf = (n: Record): string | undefined => { if (typeof n.event === "string" && n.event) return n.event; if (typeof n.action === "string" && n.action) return n.action; return undefined; }; /** Which component and action a document is for. */ export function resolve(pkg: Dispatchable, doc: unknown): Resolved { const n = unwrap(doc) as Notification & { link?: { specification?: { slug?: string }; specification_slug?: string; } | null; action?: string; source?: string; }; const type = String(n.type ?? ""); const slug = String(n.slug ?? n.specification?.slug ?? ""); const byRef = (kind: string, resSlug: string) => pkg.resources.find((r) => r.kind === kind && r.slug === resSlug); const find = (res: Resource, action: string) => pkg.handlers.find((h) => h.resource === res && h.action === action); const action = (res: Resource, key: string): Resolved => { const h = find(res, key); if (!h) throw new ActionError( "UNKNOWN_ACTION", `no handler for ${res.ref.ref}#${key} (serves: ${served(pkg)})` ); return { kind: "action", fn: h.fn, resource: res, action: key }; }; // 1. A link action: the notification carries the link and its specification. if (n.link && typeof n.link === "object") { const linkSlug = n.link.specification?.slug ?? n.link.specification_slug ?? ""; const specSlug = n.service?.specification?.slug ?? ""; const parent = specSlug ? byRef("service_specification", specSlug) : undefined; const res = pkg.resources.find( (r) => r.kind === "link_specification" && r.slug === linkSlug && (!parent || r.parent === parent) ) ?? pkg.resources.find( (r) => r.kind === "link_specification" && r.slug === linkSlug ); if (res) return action(res, isDefault(type) ? type : slug); // Never fall through to the service: a link delete would run the service's delete. throw new ActionError( "UNKNOWN_ACTION", `no link specification "${ linkSlug || "?" }" in this package (serves: ${served(pkg)})` ); } // 1. A service or scope action: the specification slug, then the type or slug. const specs = pkg.resources.filter((r) => r.kind === "service_specification"); const specSlug = n.service?.specification?.slug ?? ""; const res = specSlug ? byRef("service_specification", specSlug) : n.service && specs.length === 1 ? specs[0] : undefined; if (res) { if ((res.body as { type?: string }).type === "scope") { let key = slug || String(n.action ?? ""); if (!find(res, key) && n.action) key = String(n.action); // log:read, instance:data return action(res, key); } return action(res, isDefault(type) ? type : slug); } // 2. An event: a source and a name. const name = eventNameOf(n); if (n.source && name) { const ev = pkg.events.find((e) => e.name === name); if (ev) return { kind: "event", fn: ev.fn as HandlerFn, action: name, }; } // 3. Everything else. if (pkg.execute) return { kind: "execute", fn: pkg.execute.fn as HandlerFn, action: "execute", }; const what = n.service ? `specification "${specSlug || "?"}"` : name ? `event "${name}"` : "this document"; throw new ActionError( "UNKNOWN_ACTION", `nothing in this package answers ${what} (serves: ${served(pkg)})` ); } function served(pkg: Dispatchable): string { const parts = [ ...pkg.handlers.map(handlerKey), ...pkg.events.map((e) => `event ${e.name}`), ...(pkg.execute ? ["execute"] : []), ]; return parts.join(", ") || "nothing"; } /** The results schema a handler's return is checked against, and whether extra keys are refused. */ export function resultsSchemaFor( res: Resource | undefined, action: string ): { schema?: Record; strict: boolean } { if (!res) return { strict: false }; const body = res.body as { attributes?: { schema?: Record }; actionSpecifications?: Array<{ slug: string; results?: { schema?: Record }; }>; }; if (action === "create" || action === "update") return { schema: body.attributes?.schema, strict: true }; if (action === "delete") return { strict: false }; const spec = body.actionSpecifications?.find((a) => a.slug === action); return { schema: spec?.results?.schema, strict: false }; } export interface DispatchOptions { platform: PlatformClient; emit: EmitFn; /** Wraps every ctx.step call — tracing, or a test recording steps; `run` takes the step's narrator. */ onStep?: (name: string, run: (step: Narrative) => Promise) => Promise; /** The action's narrative on the trace (`ctx.trace`); silent when absent. */ trace?: Narrative; /** What the event block says about the package. */ package?: { slug: string; version: string }; /** Aborted when the worker must stop. */ signal?: AbortSignal; /** Replaces the API-backed settings (a test answers from its own data). */ settings?: HandlerSettings; /** Replaces the typed platform client (a test records calls). */ api?: ApiClient; /** Replaces the deployment write (a test records it). */ deployment?: HandlerContext["deployment"]; } /** The event kind a resolved handler answers. */ export function kindOf( res: Resource | undefined, notification: Record ): string { const ev = notification.event; if ( ev && typeof ev === "object" && typeof (ev as { kind?: unknown }).kind === "string" ) return (ev as { kind: string }).kind; if (notification.link && typeof notification.link === "object") return "link"; if (res && (res.body as { type?: string }).type === "scope") return "scope"; return "service"; } export interface DispatchOutcome { status: "success" | "failed"; results?: Record; error?: { code: string; message: string; retryable: boolean; details?: Record; }; /** The raw handler return (a data-fetch action's payload, for instance). */ data?: unknown; /** * A data action (log:read, instance:data, anything the platform sends an * `output_schema` for): the platform validates the worker's stdout against * that schema, so there is no lifecycle and `data` is printed as it is. */ raw?: boolean; } /** Runs what a document resolves to, reporting the lifecycle when it has one. */ export async function dispatch( pkg: Dispatchable, document: Document, opts: DispatchOptions ): Promise { const { platform, emit } = opts; const n = unwrap(document.doc) as Notification; const serviceId = n.service?.id ?? ""; const actionId = n.id ?? ""; let resolved: Resolved; try { resolved = resolve(pkg, document.doc); } catch (err) { const out = failure(err); emit({ stderr: `${red("✗")} ${out.error!.code}: ${out.error!.message}` }); return out; } const { fn, resource, action, kind } = resolved; // A data action answers a question (log:read, instance:data); the platform // validates the worker's stdout against the notification's output_schema and // tracks no lifecycle for it. The old scope layer did the same. const dataAction = kind === "action" && (!getActionDefaults(action).lifecycle || "output_schema" in (n as object)); const lifecycle = kind === "action" && !dataAction && !!actionId; if (lifecycle) await platform .transitionAction(serviceId, actionId, "in_progress") .catch((e) => warn(emit, `could not mark in_progress: ${msg(e)}`)); try { const ctx = buildContext(document, n, emit, platform, resolved, opts); const data = await fn(ctx); if (dataAction) return { status: "success", data, raw: true }; const results = data && typeof data === "object" && !Array.isArray(data) ? (data as Record) : undefined; if (kind === "action") { const { schema, strict } = resultsSchemaFor(resource, action); validateResults(schema, results, strict); } if (lifecycle) { // Not swallowed: a rejected success is a failed action with the platform's message. await platform.transitionAction(serviceId, actionId, "success", results); } return { status: "success", results, data }; } catch (err) { const out = failure(err); emit({ stderr: `${red("✗")} ${out.error!.code}: ${out.error!.message}` }); if (lifecycle) { const { message, code, details } = out.error!; await platform .transitionAction(serviceId, actionId, "failed", { error: message, code, ...(details ? { details } : {}), }) .catch((e) => warn(emit, `could not mark failed: ${msg(e)}`)); } return out; } } function buildContext( document: Document, notification: Notification, emit: EmitFn, platform: PlatformClient, resolved: Resolved, opts: DispatchOptions ): HandlerContext { const { resource, action, kind } = resolved; const onStep = opts.onStep; const n = notification as Notification & { link?: Record | null; scope?: { id?: unknown }; source?: string; context?: unknown; }; const state = createScopeState( String(n.parameters?.scope_id ?? n.scope?.id ?? ""), platform, (reason) => emit({ stdout: dim(` · state not persisted (${reason})`) }) ); const base = buildActionContext(notification, emit, state); const { entity, ...rest } = base; const link = n.link && typeof n.link === "object" ? (n.link as Record) : undefined; const own = link ? (link.attributes as Record) ?? {} : (n.service?.attributes as Record) ?? {}; const params = { ...own, ...((n.parameters as Record) ?? {}), }; const step = onStep ? (Object.assign(async (name: string, a: unknown, b?: unknown) => { const run = (typeof a === "function" ? a : b) as () => Promise; const stepOpts = typeof a === "function" ? {} : (a as Record); return onStep(name, (narrator: Narrative) => base.step(name, stepOpts as never, ((): Promise => (run as unknown as (s: Narrative) => Promise)(narrator)) as never) ); }) as unknown as typeof base.step) : base.step; const event = buildEvent(notification, { kind: kind === "action" ? kindOf(resource, notification as Record) : kind, action, packageSlug: opts.package?.slug ?? "", packageVersion: opts.package?.version ?? "", }); if (kind === "event") { Object.assign(event, { name: action, source: String(n.source ?? ""), context: n.context && typeof n.context === "object" ? (n.context as Record) : {}, }); } return { ...rest, api: opts.api ?? createApiClient(), step, trace: opts.trace ?? base.trace, deployment: opts.deployment ?? base.deployment, resource: resource ?? { kind: "unknown", body: {}, slug: "", ref: { ref: "" }, children: [], }, action, params, service: (n.service as unknown as Record) ?? {}, link, scope: n.scope && typeof n.scope === "object" ? (n.scope as Record) : undefined, settings: opts.settings ?? { providers: ( categories: string[], opts?: { dimensions?: Record } ) => entity.providers(categories, opts), parameters: () => entity.parameters(), buildContext: createBuildContext(notification, { api: platformApi }), }, event, secrets: defaultSecrets, signal: opts.signal ?? new AbortController().signal, ...(kind === "execute" ? { payload: document.doc, raw: document.raw } : {}), }; } const msg = (e: unknown) => (e instanceof Error ? e.message : String(e)); const warn = (emit: EmitFn, text: string) => emit({ stderr: `${red("!")} ${text}` }); function failure(err: unknown): DispatchOutcome { // An ActionError anywhere in the cause chain names the failure. for (let e: unknown = err; e instanceof Error; e = e.cause) { if (e instanceof ActionError) { return { status: "failed", error: { code: e.code, message: e.message, retryable: e.retryable, ...(e.details ? { details: e.details } : {}), }, }; } } return { status: "failed", error: { code: "EXECUTION_ERROR", message: msg(err), retryable: false }, }; } export { handlerKey };