import { encode, type Encodable, type ObjectProducingSchema, type ProducingSchema, type SensitivityMask, type StructuralSensitivityMask, } from "@automate.ax/codec" import { DEFAULT_ACTION_MAX_ATTEMPTS, MAX_ACTION_ATTEMPTS, actionReplaySafetySchema, automationInvocationInputSchema, automationInvocationOutputSchema, type ActionReplaySafety, type EvaluationInput, type EvaluationResult, type GenerationResult, type PlatformImageGenerationInput, type PlatformWebScrapeInput, type RuntimeActionAttemptCompletion, type RuntimeGenerationInput, } from "@automate.ax/api-contract/runtime" import type { StandardSchemaV1 } from "@standard-schema/spec" import { isPlainObject } from "@zachsents/zippy" import { isDeepEqual, pick } from "remeda" import type { RequireAtLeastOne } from "type-fest" import * as z from "zod" import { EMAIL_ORIGIN_TOKEN } from "../lib/email-origin" import { isAutomationDescriptor, normalizeAutomationSelector, } from "./automation-descriptor" import { planActionContextBoundary } from "./context-boundary-planning" import { actionAccountRequirementDefinition, actionAccountServiceDefinition, DEFAULT_ACCOUNT_BINDING, isIntegrationAccountReference, serializedIntegrationAccountDefinition, type ActionAccountConnectionRequirements, type ActionAccountOption, type AccountAuthorizationAction, type IntegrationAccountConnectionOption, type IntegrationAccountRequirement, type IntegrationAccountReference, type IntegrationScopeRequirement, type ResolvedIntegrationAccount, } from "./integrations" import { planIntegrationAccountUse } from "./account-planning" import { getCurrentContextPrerequisite, getCurrentSignalPrerequisites, } from "./signal-operators" import { buildSignalDerivation, combineSignalDependencyOutcomes, ClosedSignalError, FailedSignalError, isSignal, normalizeAutomationError, signalDependencyCollector, signalContextDependencyCollector, signalValueType, transform, type Signal, type SignalDependencyResolution, type SignalDependencySets, } from "./signal-protocol" import { createActionSignal, materializeSignal } from "./signal-resolution" import { AutomationCompatibilityError, consumeHookLocation, formatHookLocation, getAutomationPlanningContext, getAutomationRuntimeState, isSameHookLocation, type RuntimeValueContext, } from "./runtime" import { validateStandardSchema } from "./standard-schema" const DEFAULT_ACTION_INPUT_SCHEMA: StandardSchemaV1> = z.record(z.string(), z.never()) const DEFAULT_ACTION_OUTPUT_SCHEMA: StandardSchemaV1 = z.void() type ActionInputSignal = Signal & { readonly [signalValueType]: () => T } type JsonValue = StandardSchemaV1.InferOutput> type DeepJsonActionInput = | ActionInputSignal | boolean | null | number | string | DeepJsonActionInput[] | { [key: string]: DeepJsonActionInput } type DeepEncodableActionInput = | ActionInputSignal | Encodable | DeepEncodableActionInput[] | readonly DeepEncodableActionInput[] | { readonly [key: string]: DeepEncodableActionInput } /** Object types kept atomic during deep action input traversal. */ type AtomicActionInput = | ((...arguments_: never[]) => unknown) | ArrayBuffer | ArrayBufferView | Blob | Date | File | Headers | Map | RegExp | Request | Response | Set | URL | URLSearchParams type ActionAccountOptionServiceId = TOption extends string ? TOption : TOption extends { serviceId: infer TServiceId extends string } ? TServiceId : never type HasMultipleAccountOptions< TOptions extends readonly ActionAccountOption[], > = TOptions extends readonly [ActionAccountOption] ? false : true /** One durable structured log written by an action handler. */ export interface ActionLogInput { /** * Structured values available to local SQL queries. Keys allow 128 * characters. */ fields?: Record /** Severity used to filter the entry. */ level: "debug" | "info" | "warn" | "error" /** Human-readable message, limited to 16,384 characters. */ message: string /** Explicit policy for message and structured field values. */ sensitive?: StructuralSensitivityMask<{ fields?: Record message: string }> } /** * Action-only platform capabilities available to handlers. * * These methods perform platform-managed effects without exposing credentials * or execution infrastructure to the action. */ export interface ActionRuntime { /** Internal signed provenance for email-sending actions. */ readonly [EMAIL_ORIGIN_TOKEN]?: string /** Automation currently executing this action. */ readonly automationId: string /** Durable context currently executing this action. */ readonly contextId: string /** * Protects an HTTP callback endpoint with a signed, expiring capability. * * The endpoint must come from `onHttpRequest({ protection: "callback" })` in * the same automation. Each valid delivery still starts an independent root * context. */ createCallbackUrl(endpoint: string): Promise /** * Evaluates typed questions through the platform AI Gateway. * * @param input - Shared state, questions, model, and evaluation settings. */ evaluate(input: EvaluationInput): Promise /** * Generates text or structured data through the platform AI Gateway. * * @param input - Model, prompt, generation settings, and optional JSON * Schema. */ generate(input: RuntimeGenerationInput): Promise /** * Queues image generation through the platform AI Gateway. * * @param input - Completion entrypoint and image-generation settings. */ startImageGeneration( input: PlatformImageGenerationInput, ): Promise<{ key: string }> /** Queues a scrape through the platform Browser Run account. */ startWebScrape(input: PlatformWebScrapeInput): Promise<{ key: string }> /** * Accepts an immediate or delayed invocation from the current action. * * @param input - Target automation, payload, and optional delivery timing. */ invokeAutomation( input: StandardSchemaV1.InferOutput, ): Promise< StandardSchemaV1.InferOutput > /** * Persists one structured log entry with action-attempt provenance. * * Each action invocation can write at most 1,000 log entries. Field maps * accept codec values and are limited to 64 entries and 64 KiB encoded. * * @param input - Structured level, message, and optional fields. */ log(input: ActionLogInput): Promise /** * Emits a typed value from the current action execution. * * @param output - Value and application-defined type used to classify it. * @param output.data - Encodable payload to emit. * @param output.type - Application-defined output type. */ sendOutput( output: { /** Encodable payload to emit. */ data: TData /** Application-defined output type. */ type: string }, options?: { /** Explicit policy for the emitted data value. */ sensitive: StructuralSensitivityMask }, ): Promise /** * Sends an email through the platform. * * Resolves with Resend's internal email resource ID when available. This is * not the RFC 5322 Message-ID used for email threading. To reply to a * received mailhook message, use its `messageId` for `In-Reply-To`. Build * `References` from its `references`, or its `inReplyTo` when references are * empty, followed by its `messageId`. * * @param input - Email fields. * @param input.attachments - Files attached to the message. * @param input.headers - Custom email headers. * @param input.html - HTML message body. * @param input.markdown - Markdown message body. * @param input.replyTo - Address that should receive replies. * @param input.subject - Email subject line. * @param input.text - Plain-text message body. * @param input.to - Recipient email address, or `"*"` for all organization * members. */ sendEmail( input: { /** Files attached to the message. */ attachments?: File[] /** Custom email headers. */ headers?: Record /** Address that should receive replies. */ replyTo?: string /** Email subject line. */ subject: string /** Recipient email address, or `"*"` for all organization members. */ to: string } & RequireAtLeastOne<{ /** HTML message body. */ html?: string /** Markdown message body. */ markdown?: string /** Plain-text message body. */ text?: string }>, ): Promise<{ /** * Resend internal email resource ID, or `null` when unavailable. This is * not an RFC 5322 Message-ID and is not a valid threading target. */ id: string | null }> } /** Optional diagnostics shared by explicit action-control failures. */ export interface ActionFailureOptions { /** Stable provider or application classification. */ code?: string /** Original failure retained by normalized causal diagnostics. */ cause?: unknown /** Structured author-safe provider details. */ details?: JsonValue } /** Retry state attached without replacing a provider-specific error instance. */ type ActionFailureDisposition = | { status: "failed" | "indeterminate" } | { retryAt?: Date; status: "retrying" } const ACTION_FAILURE_DISPOSITIONS = new WeakMap< object, ActionFailureDisposition >() /** * Marks an existing provider error as safe to retry without changing identity. * * @param error - Provider error to classify. * @param options - Optional provider-directed retry timing. * @param options.retryAt - Absolute earliest time for the next attempt. */ export function retryableActionError( error: TError, options: { retryAt?: Date } = {}, ) { ACTION_FAILURE_DISPOSITIONS.set(error, { ...(options.retryAt && { retryAt: options.retryAt }), status: "retrying", }) return error } /** * Marks an existing provider error as terminal without changing identity. * * @param error - Provider error to classify. */ export function terminalActionError(error: TError) { ACTION_FAILURE_DISPOSITIONS.set(error, { status: "failed" }) return error } /** * Marks an existing provider error as ambiguous without changing identity. * * @param error - Provider error to classify. */ export function indeterminateActionError(error: TError) { ACTION_FAILURE_DISPOSITIONS.set(error, { status: "indeterminate" }) return error } /** A failure known to be permanent for the current action input. */ export class TerminalActionError extends Error { readonly code?: string readonly details?: JsonValue override name = "TerminalActionError" /** * Creates a permanent action failure. * * @param message - Author-safe failure message. * @param options - Optional diagnostic classification and cause. */ constructor(message: string, options: ActionFailureOptions = {}) { super(message, { cause: options.cause }) this.code = options.code this.details = options.details } } /** A failure whose provider effect cannot be determined safely. */ export class IndeterminateActionError extends Error { readonly code?: string readonly details?: JsonValue override name = "IndeterminateActionError" /** * Creates a failure whose provider outcome cannot be determined. * * @param message - Author-safe failure message. * @param options - Optional diagnostic classification and cause. */ constructor(message: string, options: ActionFailureOptions = {}) { super(message, { cause: options.cause }) this.code = options.code this.details = options.details } } /** A failure known to permit a fresh logical action attempt. */ export class RetryableActionError extends Error { readonly code?: string readonly details?: JsonValue override name = "RetryableActionError" readonly retryAt?: Date /** * Creates a failure that permits a fresh logical attempt. * * @param message - Author-safe failure message. * @param options - Optional diagnostic classification, cause, and timing. */ constructor( message: string, options: ActionFailureOptions & { retryAt?: Date } = {}, ) { super(message, { cause: options.cause }) this.code = options.code this.details = options.details this.retryAt = options.retryAt } } /** * Parses provider `Retry-After` seconds or an HTTP date into an absolute time. * Malformed and past hints are ignored so platform backoff remains in force. * * @param value - Raw provider header. * @param now - Current time, injectable for deterministic provider tests. */ export function parseRetryAfter( value: string | null | undefined, now = new Date(), ) { if (value == null) return undefined const normalized = value.trim() if (/^\d+$/.test(normalized)) { const retryAt = new Date(now.getTime() + Number(normalized) * 1_000) return Number.isNaN(retryAt.getTime()) ? undefined : retryAt } const timestamp = Date.parse(normalized) return Number.isNaN(timestamp) || timestamp <= now.getTime() ? undefined : new Date(timestamp) } /** Static retry bound and replay decision for one action definition. */ export interface ActionRetryPolicy { /** Maximum logical attempts, including the first. Defaults to three. */ maxAttempts?: number /** Constant or validated-input-dependent ambiguous replay safety. */ replaySafety: | ActionReplaySafety | ((input: TInput) => ActionReplaySafety | Promise) } /** * Implements an action after its input and account selection are materialized. * * The handler receives schema-validated input and, for account-backed actions, * the resolved account secret. Its result is subsequently validated by the * action's output schema. * * @template TInputSchema - Schema used to validate materialized action input. * @template TOutputSchema - Schema used to validate the handler result. * @template TAccountServiceId - Required integration service, or `never` when * the action does not use an account. */ export type ActionHandler< TInputSchema extends ObjectProducingSchema = ObjectProducingSchema, TOutputSchema extends ProducingSchema = ProducingSchema, TAccountServiceId extends string = never, TDefaultAccount = never, > = ( options: { /** Parsed input produced by the action's input schema. */ input: StandardSchemaV1.InferOutput /** Platform-managed capabilities available during execution. */ runtime: ActionRuntime } & ([TAccountServiceId] extends [never] ? object : { /** * Resolved credentials for the integration service required by the * action. */ account: ResolvedIntegrationAccount | TDefaultAccount }), ) => | Promise> | StandardSchemaV1.InferInput /** Display metadata attached to a defined action. */ export interface ActionMetadata { /** Optional explanation of what the action does. */ readonly description?: string /** Human-readable action name. */ readonly name: string } /** * Fluent definition state for an action. * * The generic state records the configured schemas and account requirement so * the final handler and callable action remain fully inferred. * * @template TInputSchema - Currently configured input schema. * @template TOutputSchema - Currently configured output schema. * @template TAccountServiceId - Required integration service, or `never` for an * action without an account requirement. */ export interface ActionBuilder< TInputSchema extends ObjectProducingSchema = typeof DEFAULT_ACTION_INPUT_SCHEMA, TOutputSchema extends ProducingSchema = typeof DEFAULT_ACTION_OUTPUT_SCHEMA, TAccountServiceId extends string = never, TMultipleAccountServices extends boolean = false, TDefaultAccount = never, > { /** * Requires an integration account when planning and executing the action. * * @template TNextAccountServiceId - Integration service required by the * resulting builder. * @param serviceId - Stable identifier of the required integration service. * @param requirement - Provider scope required through any connection, or * connection-specific alternatives. */ account( serviceId: TNextAccountServiceId, requirement?: | ActionAccountConnectionRequirements | IntegrationScopeRequirement, ): ActionBuilder /** * Requires an account for one of several alternative integration services. * * @template TOptions - Non-empty alternative service declaration tuple. * @param options - Service identifiers or service-specific scope objects. */ account< const TOptions extends readonly [ ActionAccountOption, ...ActionAccountOption[], ], >( options: TOptions, ): ActionBuilder< TInputSchema, TOutputSchema, ActionAccountOptionServiceId, HasMultipleAccountOptions > /** * Requires one of several integration services while using a custom value * when the caller omits `account`. * * The default does not create a deployment account requirement. It is passed * directly to the action handler so platform-backed actions can branch away * from user-managed credentials. * * @template TOptions - Non-empty alternative service declaration tuple. * @template TNextDefaultAccount - Handler value used for omitted accounts. * @param options - Service identifiers or service-specific scope objects. * @param configuration - Omitted-account behavior. * @param configuration.default - Value passed to the action handler. */ account< const TOptions extends readonly [ ActionAccountOption, ...ActionAccountOption[], ], const TNextDefaultAccount, >( options: TOptions, configuration: { default: TNextDefaultAccount }, ): ActionBuilder< TInputSchema, TOutputSchema, ActionAccountOptionServiceId, HasMultipleAccountOptions, TNextDefaultAccount > /** * Adds a human-readable description to action metadata. * * @param description - Explanation shown to users and planning tools. */ describe( description: string, ): ActionBuilder< TInputSchema, TOutputSchema, TAccountServiceId, TMultipleAccountServices, TDefaultAccount > /** * Sets the schema used to validate materialized action input. * * @template TNextInputSchema - Input schema stored by the resulting builder. * @param schema - Standard Schema-compatible schema producing an object. */ input>( schema: TNextInputSchema, ): ActionBuilder< TNextInputSchema, TOutputSchema, TAccountServiceId, TMultipleAccountServices, TDefaultAccount > /** * Sets the schema used to validate the action handler's result. * * @template TNextOutputSchema - Output schema stored by the resulting * builder. * @param schema - Standard Schema-compatible output schema. * @param options - Optional explicit sensitivity policy for the result. * @param options.sensitive - Whole-value or structural policy to persist. */ output>( schema: TNextOutputSchema, options?: { sensitive: StructuralSensitivityMask< StandardSchemaV1.InferOutput > }, ): ActionBuilder< TInputSchema, TNextOutputSchema, TAccountServiceId, TMultipleAccountServices, TDefaultAccount > /** * Declares the logical retry bound and ambiguous replay safety. * * The replay decision runs only after input validation and before the action * handler. Every action must declare this policy before `.handler()`. * * @param policy - Static attempt bound and constant or input-dependent * safety. */ retry( policy: ActionRetryPolicy>, ): ActionBuilder< TInputSchema, TOutputSchema, TAccountServiceId, TMultipleAccountServices, TDefaultAccount > /** * Completes the definition with its execution implementation. * * @param handler - Implementation invoked with validated input and runtime * capabilities. */ handler( handler: ActionHandler< TInputSchema, TOutputSchema, TAccountServiceId, TDefaultAccount >, ): DefinedAction< TInputSchema, TOutputSchema, TAccountServiceId, TMultipleAccountServices, TDefaultAccount > } /** * Complete internal definition consumed when registering an action invocation. * * @template TInputSchema - Schema used to validate materialized action input. * @template TOutputSchema - Schema used to validate action output. * @template TAccountServiceId - Required integration service, or `never` for * accountless actions. */ interface ActionDefinition< TInputSchema extends ObjectProducingSchema, TOutputSchema extends ProducingSchema, TAccountServiceId extends string, TDefaultAccount, > { /** Integration account requirement declared by the action. */ account?: { /** Handler value used when the caller omits `account`. */ default?: TDefaultAccount /** Alternative services and provider scopes accepted by the action. */ requirements: IntegrationAccountRequirement[] } /** Execution implementation for the action. */ handler: ActionHandler< TInputSchema, TOutputSchema, TAccountServiceId, TDefaultAccount > /** Schema used to validate materialized action input. */ input: TInputSchema /** Display metadata exposed by the defined action. */ meta: ActionMetadata /** Schema used to validate the handler result. */ output: TOutputSchema /** Structural output policy snapshotted on every durable invocation. */ outputSensitivity?: SensitivityMask /** Logical attempt policy required before the handler is registered. */ retryPolicy: Required< Pick< ActionRetryPolicy>, "maxAttempts" > > & Pick< ActionRetryPolicy>, "replaySafety" > } /** * Object shape accepted from callers before action input is parsed. * * Schemas with an object-shaped declared input preserve that input type so * coercions and transforms remain callable. Schemas whose declared input is * broader use their known object output shape instead. * * @template TInputSchema - Object-producing action input schema. */ export type ActionSchemaInput< TInputSchema extends ObjectProducingSchema, > = StandardSchemaV1.InferInput extends Record ? StandardSchemaV1.InferInput : StandardSchemaV1.InferOutput /** * Recursively signal-backed values accepted when invoking an action. * * A signal may supply the complete input, an object or array subtree, or one * leaf. Arrays and plain objects are traversed; other object types stay * atomic. * * @template T - Input value that may be supplied directly or by signal. */ export type DeepActionInput = | ActionInputSignal | (0 extends 1 & T ? T : [T] extends [JsonValue] ? [JsonValue] extends [T] ? DeepJsonActionInput : DeepStructuredActionInput : [T] extends [Encodable] ? [Encodable] extends [T] ? DeepEncodableActionInput : DeepStructuredActionInput : DeepStructuredActionInput) type DeepStructuredActionInput = T extends AtomicActionInput ? T : T extends readonly unknown[] ? { [TKey in keyof T]: DeepActionInput } : T extends object ? { [TKey in keyof T]: DeepActionInput } : T /** * Object form of an action input with signals accepted at every nested node. * Object union members remain distinct through the recursive transformation. */ export type ActionObjectInput< TInputSchema extends ObjectProducingSchema, > = DeepStructuredActionInput> /** Whole-value or recursively signal-backed input accepted by an action. */ export type ActionCallInput< TInputSchema extends ObjectProducingSchema, > = | ActionInputSignal> | ActionObjectInput /** Whether the schema accepts an action invocation with no input properties. */ type AllowsEmptyActionInput< TInputSchema extends ObjectProducingSchema, > = Record extends StandardSchemaV1.InferInput ? true : false /** Input tuple with omission allowed when the schema accepts an empty object. */ type ActionInputArguments< TInputSchema extends ObjectProducingSchema, TRequired extends boolean, > = AllowsEmptyActionInput extends true ? TRequired extends true ? [input: ActionCallInput | undefined] : [input?: ActionCallInput] : [input: ActionCallInput] /** * Account selection added to account-backed action calls. * * Single-service actions accept an optional binding name, typed account * reference, or signal resolving to either. Multi-service actions require a * typed account reference so the provider is known before authorization. * * @template TAccountServiceId - Required integration service. */ type ActionAccountSelection< TAccountServiceId extends string, TMultipleAccountServices extends boolean, > = TMultipleAccountServices extends true ? IntegrationAccountReference : | string | IntegrationAccountReference | ActionInputSignal< string | IntegrationAccountReference > type ActionAccountCallOptions< TAccountServiceId extends string, TMultipleAccountServices extends boolean, TDefaultAccount, > = TMultipleAccountServices extends true ? [TDefaultAccount] extends [never] ? { /** Typed account reference that selects one allowed service. */ account: ActionAccountSelection< TAccountServiceId, TMultipleAccountServices > } : { /** Typed account reference, or omission for the action's custom default. */ account?: ActionAccountSelection< TAccountServiceId, TMultipleAccountServices > } : { /** Project account binding to use for this invocation. */ account?: ActionAccountSelection< TAccountServiceId, TMultipleAccountServices > } /** Conditional invocation tuple that enforces account-selection requirements. */ type ActionInvocationArguments< TInputSchema extends ObjectProducingSchema, TAccountServiceId extends string, TMultipleAccountServices extends boolean, TDefaultAccount, > = [TAccountServiceId] extends [never] ? ActionInputArguments : TMultipleAccountServices extends true ? [TDefaultAccount] extends [never] ? [ ...ActionInputArguments, options: ActionAccountCallOptions< TAccountServiceId, TMultipleAccountServices, TDefaultAccount >, ] : [ ...ActionInputArguments, options?: ActionAccountCallOptions< TAccountServiceId, TMultipleAccountServices, TDefaultAccount >, ] : [ ...ActionInputArguments, options?: ActionAccountCallOptions< TAccountServiceId, TMultipleAccountServices, TDefaultAccount >, ] /** Action callable returned after binding an account selection. */ type AccountBoundAction< TInputSchema extends ObjectProducingSchema, TOutputSchema extends ProducingSchema, > = ( ...arguments_: ActionInputArguments ) => Signal> interface ActionAccountBinding< TInputSchema extends ObjectProducingSchema, TOutputSchema extends ProducingSchema, TAccountServiceId extends string, TMultipleAccountServices extends boolean, > { /** Creates an immutable action callable bound to one account selection. */ usingAccount( account: ActionAccountSelection< TAccountServiceId, TMultipleAccountServices >, ): AccountBoundAction } /** * Callable action produced by a completed action definition. * * Calling it registers an invocation and returns a signal for the eventual * validated output. Account-backed actions also carry their authorization * requirement for planning. * * @template TInputSchema - Schema governing invocation input. * @template TOutputSchema - Schema governing the resulting signal value. * @template TAccountServiceId - Required integration service, or `never` for * accountless actions. */ export type DefinedAction< TInputSchema extends ObjectProducingSchema = ObjectProducingSchema, TOutputSchema extends ProducingSchema = ProducingSchema, TAccountServiceId extends string = never, TMultipleAccountServices extends boolean = false, TDefaultAccount = never, > = { /** * Registers an invocation and exposes its eventual output. * * @param arguments_ - Signal-capable input followed by optional account * selection options. */ ( ...arguments_: ActionInvocationArguments< TInputSchema, TAccountServiceId, TMultipleAccountServices, TDefaultAccount > ): Signal> /** Display metadata configured while defining the action. */ readonly meta: ActionMetadata } & ([TAccountServiceId] extends [never] ? object : AccountAuthorizationAction & ActionAccountBinding< TInputSchema, TOutputSchema, TAccountServiceId, TMultipleAccountServices >) /** * Starts a fluent action definition. * * @param name - Human-readable action name. */ export function defineAction(name: string): ActionBuilder { return createActionBuilder({ input: DEFAULT_ACTION_INPUT_SCHEMA, meta: { name }, output: DEFAULT_ACTION_OUTPUT_SCHEMA, }) } /** * Creates a builder carrying the action metadata and latest configured schemas. * * @param definition - Metadata and schemas configured by prior builder calls. * @param definition.account - Optional integration account requirement. * @param definition.account.default - Handler value for omitted accounts. * @param definition.account.requirements - Alternative services and scopes. * @param definition.input - Current input schema. * @param definition.meta - Display metadata for the action. * @param definition.output - Current output schema. * @param definition.outputSensitivity - Optional structural result policy. * @param definition.retryPolicy - Optional policy configured before a handler. */ function createActionBuilder< TInputSchema extends ObjectProducingSchema, TOutputSchema extends ProducingSchema, TAccountServiceId extends string = never, TMultipleAccountServices extends boolean = false, TDefaultAccount = never, >(definition: { account?: { default?: TDefaultAccount requirements: IntegrationAccountRequirement[] } input: TInputSchema meta: ActionMetadata output: TOutputSchema outputSensitivity?: SensitivityMask retryPolicy?: ActionRetryPolicy< StandardSchemaV1.InferOutput > & { maxAttempts: number } }): ActionBuilder< TInputSchema, TOutputSchema, TAccountServiceId, TMultipleAccountServices, TDefaultAccount > { /** * Adds one integration service requirement to the current builder. * * @param serviceId - Stable integration service identifier. * @param requirement - Provider scope required through any connection, or * connection-specific alternatives. */ function account( serviceId: TNextAccountServiceId, requirement?: | ActionAccountConnectionRequirements | IntegrationScopeRequirement, ): ActionBuilder /** * Adds alternative integration service requirements to the current builder. * * @param options - Non-empty service identifiers and scope declarations. */ function account< const TOptions extends readonly [ ActionAccountOption, ...ActionAccountOption[], ], >( options: TOptions, ): ActionBuilder< TInputSchema, TOutputSchema, ActionAccountOptionServiceId, HasMultipleAccountOptions > /** * Adds alternative account services with custom omitted-account behavior. * * @param options - Non-empty service alternatives. * @param configuration - Omitted-account behavior. * @param configuration.default - Handler value used when omitted. */ function account< const TOptions extends readonly [ ActionAccountOption, ...ActionAccountOption[], ], const TNextDefaultAccount, >( options: TOptions, configuration: { default: TNextDefaultAccount }, ): ActionBuilder< TInputSchema, TOutputSchema, ActionAccountOptionServiceId, HasMultipleAccountOptions, TNextDefaultAccount > /** * Normalizes one or more account service requirements. * * @param serviceIdOrOptions - One service or alternative service options. * @param requirement - Scope or connection alternatives supplied with one * service. * @throws When service or connection alternatives are empty or duplicated. */ function account( serviceIdOrOptions: string | readonly ActionAccountOption[], requirement?: | ActionAccountConnectionRequirements | IntegrationScopeRequirement | { default: unknown }, ): ActionBuilder { const requirements: IntegrationAccountRequirement[] = typeof serviceIdOrOptions === "string" ? [ { connectionOptions: normalizeActionAccountConnectionOptions( serviceIdOrOptions, isActionAccountDefaultConfiguration(requirement) ? undefined : requirement, ), serviceId: serviceIdOrOptions, }, ] : serviceIdOrOptions.map((option) => typeof option === "string" ? { connectionOptions: normalizeActionAccountConnectionOptions(option), serviceId: option, } : { connectionOptions: normalizeActionAccountConnectionOptions( option.serviceId, option.connections ? { connections: option.connections } : option.requiredScope, ), serviceId: option.serviceId, }, ) if (requirements.length === 0) { throw new Error("Action account requires at least one service.") } if ( new Set(requirements.map(({ serviceId }) => serviceId)).size !== requirements.length ) { throw new Error("Action account services must be unique.") } return createActionBuilder< TInputSchema, TOutputSchema, string, boolean, unknown >({ ...definition, account: { ...(typeof serviceIdOrOptions !== "string" && isActionAccountDefaultConfiguration(requirement) && requirement), requirements, }, }) } return { account, describe(description) { return createActionBuilder({ ...definition, meta: { ...definition.meta, description }, }) }, input(input) { return createActionBuilder({ ...pick(definition, ["account", "meta", "output", "outputSensitivity"]), input, }) }, output(output, options) { return createActionBuilder({ ...definition, output, outputSensitivity: options?.sensitive, }) }, retry(policy) { const maxAttempts = policy.maxAttempts ?? DEFAULT_ACTION_MAX_ATTEMPTS if ( !Number.isInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > MAX_ACTION_ATTEMPTS ) { throw new Error( `Action maxAttempts must be an integer from 1 through ${MAX_ACTION_ATTEMPTS}.`, ) } if ( policy.replaySafety !== "safe" && policy.replaySafety !== "unsafe" && typeof policy.replaySafety !== "function" ) { throw new Error( 'Action replaySafety must be "safe", "unsafe", or a function.', ) } return createActionBuilder({ ...definition, retryPolicy: { ...policy, maxAttempts }, }) }, handler(handler) { if (!definition.retryPolicy) { throw new Error( `Action ${definition.meta.name} must declare .retry({ replaySafety }) before .handler().`, ) } const retryPolicy = definition.retryPolicy const registerInvocation = (input: unknown, account: unknown) => invokeAction( { ...pick(definition, ["account", "meta", "outputSensitivity"]), handler, input: definition.input, output: definition.output, retryPolicy, }, input, account, ) const invoke = ( ...arguments_: ActionInvocationArguments< TInputSchema, TAccountServiceId, TMultipleAccountServices, TDefaultAccount > ) => { const [input = {}, options] = arguments_ return registerInvocation( input, isPlainObject(options) && "account" in options ? options.account : undefined, ) } const usingAccount = (account: unknown): AccountBoundAction => (...arguments_) => registerInvocation( arguments_[0] === undefined ? {} : arguments_[0], account, ) return Object.assign( invoke, { meta: definition.meta }, definition.account && { usingAccount }, { [actionAccountRequirementDefinition]: Object.fromEntries( (definition.account?.requirements ?? []).map( ({ connectionOptions, serviceId }) => [ serviceId, connectionOptions, ], ), ), [actionAccountServiceDefinition]: (_serviceId: TAccountServiceId) => undefined, }, ) }, } } /** * Normalizes wildcard or connection-specific requirements for one service. * * @param serviceId - Service owning the connection alternatives. * @param requirement - Wildcard scope or explicit connection configuration. * @throws When explicit connection alternatives are empty or duplicated. */ function normalizeActionAccountConnectionOptions( serviceId: string, requirement?: | ActionAccountConnectionRequirements | IntegrationScopeRequirement, ): IntegrationAccountConnectionOption[] { if (typeof requirement === "object" && "connections" in requirement) { if (requirement.connections.length === 0) { throw new Error( `Action account service ${serviceId} requires at least one connection option.`, ) } const connectionMethodIds = requirement.connections.map( ({ connectionMethodId }) => connectionMethodId, ) if (new Set(connectionMethodIds).size !== connectionMethodIds.length) { throw new Error( `Action account service ${serviceId} connection method IDs must be unique.`, ) } return requirement.connections.map( ({ connectionMethodId, requiredScope }) => ({ connectionMethodId, requiredScopes: requiredScope === undefined ? [] : [requiredScope], }), ) } return [{ requiredScopes: requirement === undefined ? [] : [requirement] }] } /** * Resolves and validates replay safety against schema-validated action input. * * @param policy - Constant or input-dependent replay declaration. * @param input - Validated handler input. */ async function resolveActionReplaySafety( policy: ActionRetryPolicy["replaySafety"], input: TInput, ): Promise { return actionReplaySafetySchema.parse( typeof policy === "function" ? await policy(input) : policy, ) } /** * Separates an author-safe diagnostic from the handler's retry disposition. * * @param error - Failure thrown by an action handler. * @param replaySafety - Ambiguous replay policy for the validated input. */ function classifyActionFailure( error: unknown, replaySafety: ActionReplaySafety, ): Exclude { const failureFor = (status: "failed" | "indeterminate" | "retrying") => normalizeAutomationError( error, status === "indeterminate" ? "action_outcome_indeterminate" : "action_failed", ) const disposition = typeof error === "object" && error !== null ? ACTION_FAILURE_DISPOSITIONS.get(error) : undefined if (disposition?.status === "retrying") { return { failure: failureFor(disposition.status), ...(disposition.retryAt && { retryAt: disposition.retryAt }), status: disposition.status, } } if (disposition) { return { failure: failureFor(disposition.status), status: disposition.status, } } if (error instanceof TerminalActionError) { return { failure: failureFor("failed"), status: "failed" } } if (error instanceof IndeterminateActionError) { return { failure: failureFor("indeterminate"), status: "indeterminate", } } if (error instanceof RetryableActionError) { return { failure: failureFor("retrying"), ...(error.retryAt && { retryAt: error.retryAt }), status: "retrying", } } const status = replaySafety === "safe" ? "retrying" : "indeterminate" return { failure: failureFor(status), status, } } /** * Identifies omitted-account configuration passed to an account builder. * * @param value - Possible account configuration. */ function isActionAccountDefaultConfiguration( value: unknown, ): value is { default: unknown } { return typeof value === "object" && value !== null && "default" in value } /** * Registers an action invocation and returns a signal for its eventual output. * * @param options - Schemas and handler that define the action. * @param input - Literal and signal-backed values supplied by the caller. * @param accountInput - Optional account selection supplied separately. * @throws {AutomationCompatibilityError} When durable history does not match. */ function invokeAction< TInputSchema extends ObjectProducingSchema, TOutputSchema extends ProducingSchema, TAccountServiceId extends string, TDefaultAccount, >( options: ActionDefinition< TInputSchema, TOutputSchema, TAccountServiceId, TDefaultAccount >, input: unknown, accountInput: unknown, ): Signal> { const location = consumeHookLocation() const formattedLocation = formatHookLocation(location) if ( options.account && options.account.requirements.length > 1 && !isIntegrationAccountReference(accountInput) && !(accountInput === undefined && "default" in options.account) ) { throw new Error( "Actions with alternative account services require a typed integration account reference.", ) } if ( getAutomationPlanningContext() && options.account && !(accountInput === undefined && "default" in options.account) ) { planIntegrationAccountUse(accountInput, options.account.requirements) } const state = getAutomationRuntimeState() const prerequisites = getCurrentSignalPrerequisites() const contextPrerequisite = getCurrentContextPrerequisite() const valueDependencies = [ ...findActionInputSignals(input), ...(isSignal(accountInput) ? [accountInput] : []), ...(prerequisites ? [prerequisites] : []), ] const contextDependencies = [ ...valueDependencies, ...(contextPrerequisite ? [contextPrerequisite] : []), ] const planningContext = getAutomationPlanningContext() if (planningContext) { planActionContextBoundary( planningContext, location, options.meta.name, contextDependencies, ) } if (state) { const inputHash = createInvocationInputHash( input, accountInput, prerequisites, contextPrerequisite, ) const dependencyIds: SignalDependencySets = { actionDependencyIds: new Set(), eventDependencyIds: new Set(), signalDependencyIds: new Set(), } let dependencyOutcome = combineSignalDependencyOutcomes([ ...valueDependencies.map((value) => value[signalDependencyCollector](state, dependencyIds), ), ...(contextPrerequisite ? [ contextPrerequisite[signalContextDependencyCollector]( state, dependencyIds, ), ] : []), ]) const completed = state.context.actions.filter((output) => isSameHookLocation(output, location), ) if ( dependencyOutcome.status !== "pending" && dependencyIds.actionDependencyIds.size === 0 && dependencyIds.eventDependencyIds.size === 0 && dependencyIds.signalDependencyIds.size === 0 ) { const replayRootEventId = completed.find( (occurrence) => occurrence.actionDependencyIds.length === 0 && occurrence.eventDependencyIds.length === 1 && occurrence.signalDependencyIds.length === 0, )?.eventDependencyIds[0] const replayRootEvent = replayRootEventId ? state.context.events.find( (event) => event.automationEventId === replayRootEventId, ) : undefined if (replayRootEvent) { dependencyIds.eventDependencyIds.add(replayRootEvent.automationEventId) dependencyOutcome = { outcomeSeq: replayRootEvent.outcomeSeq, status: "succeeded", } } } if ( dependencyOutcome.status !== "pending" && dependencyIds.actionDependencyIds.size === 0 && dependencyIds.eventDependencyIds.size === 0 && dependencyIds.signalDependencyIds.size === 0 ) { const initiatingSubscriptionSignals = state.initiatingSubscriptionSignals if ( initiatingSubscriptionSignals && initiatingSubscriptionSignals.length > 1 ) { throw new AutomationCompatibilityError( `Automation compatibility error at action hook ${formattedLocation}: ${options.meta.name} could not resolve its inferred root boundary.`, ) } const rootSignal = initiatingSubscriptionSignals?.[0] if (rootSignal) { dependencyOutcome = combineSignalDependencyOutcomes([ dependencyOutcome, rootSignal[signalContextDependencyCollector](state, dependencyIds), ]) } else { const localEvents = state.context.events.filter( (event) => event.contextId === state.context.contextId && !state.nonInitiatingSubscriptionOrigins?.has( `event:${formatHookLocation(event)}`, ), ) if (localEvents.length > 1) { throw new AutomationCompatibilityError( `Automation compatibility error at action hook ${formattedLocation}: ${options.meta.name} could not resolve its inferred root boundary.`, ) } const localEvent = localEvents[0] if (localEvent) { dependencyIds.eventDependencyIds.add(localEvent.automationEventId) dependencyOutcome = { outcomeSeq: localEvent.outcomeSeq, status: "succeeded", } } } } const derivation = buildSignalDerivation( contextDependencies, state, dependencyIds, ) if (completed.length > 0) { for (const occurrence of completed) { if (occurrence.name !== options.meta.name) { throw new AutomationCompatibilityError( `Automation compatibility error at action hook ${formattedLocation}: expected ${occurrence.name}, received ${options.meta.name}.`, ) } } state.compatibilityChecks.push( inputHash.then((currentInputHash) => { if (dependencyOutcome.status === "pending") { throw new AutomationCompatibilityError( `Automation compatibility error at action hook ${formattedLocation}: ${options.meta.name} replay dependencies are pending, but the persisted action was already planned.`, ) } const matchingDependencies = completed.filter( (occurrence) => equalDependencyIds( occurrence.actionDependencyIds, dependencyIds.actionDependencyIds, ) && equalDependencyIds( occurrence.eventDependencyIds, dependencyIds.eventDependencyIds, ) && equalDependencyIds( occurrence.signalDependencyIds, dependencyIds.signalDependencyIds, ), ) if (matchingDependencies.length === 0) { throw new AutomationCompatibilityError( `Automation compatibility error at action hook ${formattedLocation}: ${options.meta.name} dependency IDs changed; persisted ${completed.map(formatActionDependencyIds).join(" or ")}, replay resolved ${formatActionDependencyIds(dependencyIds)}.`, ) } if ( matchingDependencies.some( (occurrence) => currentInputHash !== occurrence.inputHash, ) ) { throw new AutomationCompatibilityError( `Automation compatibility error at action hook ${formattedLocation}: ${options.meta.name} inputs changed.`, ) } if ( matchingDependencies.some( (occurrence) => !isDeepEqual(occurrence.derivation, derivation), ) ) { throw new AutomationCompatibilityError( `Automation compatibility error at action hook ${formattedLocation}: ${options.meta.name} signal derivation changed.`, ) } if ( matchingDependencies.some( (occurrence) => !isDeepEqual( occurrence.outputSensitivity ?? null, options.outputSensitivity ?? null, ), ) ) { throw new AutomationCompatibilityError( `Automation compatibility error at action hook ${formattedLocation}: ${options.meta.name} output sensitivity changed.`, ) } if ( !matchingDependencies.some((occurrence) => isCompatibleDependencyOutcome(occurrence, dependencyOutcome), ) ) { throw new AutomationCompatibilityError( `Automation compatibility error at action hook ${formattedLocation}: ${options.meta.name} replay state changed; persisted action outcome was ${[ ...new Set( matchingDependencies.map((occurrence) => occurrence.status === "skipped" ? `${occurrence.status} (${occurrence.reason})` : occurrence.status, ), ), ].join( " or ", )}, but replay resolved its dependencies as ${dependencyOutcome.status}.`, ) } }), ) } else { if (dependencyOutcome.status !== "pending") { const invocation = { actionDependencyIds: [...dependencyIds.actionDependencyIds], derivation, eventDependencyIds: [...dependencyIds.eventDependencyIds], inputHash, maxAttempts: options.retryPolicy.maxAttempts, name: options.meta.name, outputSensitivity: options.outputSensitivity ?? null, description: options.meta.description ?? null, scopePath: [...location.scopePath], signalDependencyIds: [...dependencyIds.signalDependencyIds], slot: location.slot, } state.actionInvocations.push( dependencyOutcome.status === "succeeded" ? { ...invocation, status: "pending" } : dependencyOutcome.status === "failed" ? { ...invocation, failure: dependencyOutcome.failure, reason: "failed-dependency", status: "skipped", } : { ...invocation, reason: "closed-dependency", status: "skipped", }, ) } } const targetAction = state.targetAction if (targetAction && isSameHookLocation(targetAction, location)) { state.actionExecution = async () => { let materialized: unknown let materializedAccount: unknown try { if (prerequisites) { materializeSignal(prerequisites, targetAction.context) } materialized = materializeActionInput(input, targetAction.context) materializedAccount = isSignal(accountInput) ? materializeSignal(accountInput, targetAction.context) : accountInput } catch (error) { if (error instanceof ClosedSignalError) { await state.completeAction({ reason: "closed-dependency", status: "skipped", }) return } if (error instanceof FailedSignalError) { await state.completeAction({ failure: error.failure, reason: "failed-dependency", status: "skipped", }) return } await state.completeAction({ failure: normalizeAutomationError(error, "expression_failed"), reason: "failed-dependency", status: "skipped", }) return } let validatedInput: StandardSchemaV1.InferOutput try { validatedInput = await validateStandardSchema( options.input, materialized, "Action input", ) } catch (error) { await state.completeAction({ failure: normalizeAutomationError(error, "validation_failed"), status: "failed", }) return } let account: unknown try { account = options.account === undefined ? undefined : await resolveActionAccount( materializedAccount, options.account, state.resolveAccountInput, ) } catch (error) { await state.completeAction({ failure: normalizeAutomationError(error, "account_failed"), status: "failed", }) return } let replaySafety: ActionReplaySafety try { replaySafety = await resolveActionReplaySafety( options.retryPolicy.replaySafety, validatedInput, ) } catch (error) { await state.completeAction({ failure: normalizeAutomationError(error, "retry_policy_failed"), status: "failed", }) return } if ( (await state.startActionAttempt(replaySafety)).status === "settled" ) { return } let handled: unknown try { // REVIEW: Account presence follows the builder's conditional handler type. // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Runtime branch and builder type share the same account declaration. handled = await options.handler({ ...(options.account && { account }), input: validatedInput, runtime: state.runtime, } as Parameters[0]) } catch (error) { const failure = classifyActionFailure(error, replaySafety) await state.completeActionAttempt(failure) state.actionDeferred = failure.status === "retrying" return } let output: Encodable try { output = await validateStandardSchema( options.output, handled, "Action output", ) } catch (error) { await state.completeActionAttempt({ failure: normalizeAutomationError(error, "validation_failed"), status: "failed", }) return } await state.completeActionAttempt({ output, status: "succeeded" }) } } } return createActionSignal>( location, contextDependencies, ) } /** * Resolves a materialized account selection through its current project * binding. * * @param value - Literal or signal-produced account selection. * @param requirement - Services declared by the action. * @param requirement.default - Handler value for an omitted account. * @param requirement.requirements - Allowed services and their provider scopes. * @param resolveAccountInput - Private runtime transport for credential * loading. * @throws When the value is not a compatible bound account for the action. */ async function resolveActionAccount( value: unknown, requirement: { default?: unknown requirements: IntegrationAccountRequirement[] }, resolveAccountInput: (input: { binding: string serviceId: string }) => Promise, ) { if (value === undefined && "default" in requirement) { return requirement.default } if ( requirement.requirements.length > 1 && !isIntegrationAccountReference(value) ) { throw new Error( "Actions with alternative account services require a typed integration account reference.", ) } if ( value !== undefined && typeof value !== "string" && !isIntegrationAccountReference(value) ) { throw new Error( "Action account input is not a named integration account binding.", ) } const accountDefinition = isIntegrationAccountReference(value) ? value[serializedIntegrationAccountDefinition] : undefined const selectedRequirement = accountDefinition ? requirement.requirements.find( ({ serviceId }) => serviceId === accountDefinition.serviceId, ) : requirement.requirements[0] if (accountDefinition && !selectedRequirement) { throw new Error( `Action requires an account for one of ${requirement.requirements.map(({ serviceId }) => serviceId).join(", ")}, but received a ${accountDefinition.serviceId} account.`, ) } return await resolveAccountInput({ binding: typeof value === "string" ? value : accountDefinition?.binding === undefined ? DEFAULT_ACCOUNT_BINDING : accountDefinition.binding, serviceId: selectedRequirement!.serviceId, }) } /** * Creates a string signal by interpolating in-memory values and signals. * * @param strings - Static portions of the tagged template. * @param values - Dynamic values interleaved between static portions. * @throws {TypeError} When no interpolated value is a signal. */ export function t( strings: TemplateStringsArray, ...values: unknown[] ): Signal { const [firstSignal, ...otherSignals] = values.filter(isSignal) if (!firstSignal) { throw new TypeError("t requires at least one signal.") } return transform([firstSignal, ...otherSignals], (...resolvedSignals) => { let signalIndex = 0 return ( values.reduce( (result, value, index) => result + (strings[index] ?? "") + String(isSignal(value) ? resolvedSignals[signalIndex++] : value), "", ) + (strings[strings.length - 1] ?? "") ) }) } /** * Hashes encodable literal action inputs while replacing symbolic values. * * @param input - Action inputs to reduce to a replay signature. * @param accountInput - Optional integration account selection. * @param prerequisites - Composite prerequisite dependency to fingerprint. * @param contextPrerequisite - Context-only placement dependency. */ async function createInvocationInputHash( input: unknown, accountInput: unknown, prerequisites: Signal | undefined, contextPrerequisite: Signal | undefined, ) { let bytes: Uint8Array try { bytes = await encode({ scope: { context: contextPrerequisite ? "[signal]" : null, value: prerequisites ? "[signal]" : null, }, account: await replaceActionInputSignals(accountInput), input: await replaceActionInputSignals(input), }) } catch { bytes = new TextEncoder().encode("[runtime-only-input]") } return Array.from( new Uint8Array( await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes)), ), ) .map((byte) => byte.toString(16).padStart(2, "0")) .join("") } /** * Finds every signal nested through arrays and plain objects. * * @param input - Value tree to inspect. */ function findActionInputSignals(input: unknown): Signal[] { const visited = new WeakSet() const visit = (value: unknown): Signal[] => { if (isSignal(value)) return [value] if (!Array.isArray(value) && !isPlainObject(value)) return [] if (visited.has(value)) return [] visited.add(value) return Object.values(value).flatMap(visit) } return visit(input) } /** * Reconstructs one action input with every nested signal materialized. * * @param input - Signal-capable value tree to reconstruct. * @param context - Runtime values used to resolve signals. */ function materializeActionInput( input: unknown, context: RuntimeValueContext, ): unknown { const materialized = new WeakMap() const visit = (value: unknown): unknown => { if (isSignal(value)) return materializeSignal(value, context) if (Array.isArray(value)) { const existing = materialized.get(value) if (existing) return existing const result: unknown[] = [] materialized.set(value, result) result.push(...value.map(visit)) return result } if (!isPlainObject(value)) return value const existing = materialized.get(value) if (existing) return existing const result: Record = Object.create( Object.getPrototypeOf(value), ) materialized.set(value, result) for (const [key, nested] of Object.entries(value)) { Object.defineProperty(result, key, { configurable: true, enumerable: true, value: visit(nested), writable: true, }) } return result } return visit(input) } /** * Replaces nested signals with replay markers before input hashing. * * @param input - Signal-capable value tree to normalize. * @param active - Containers on the current traversal path. */ async function replaceActionInputSignals( input: unknown, active = new WeakSet(), ): Promise { const value = input instanceof Promise ? await input : input if (isSignal(value)) return "[signal]" if (isAutomationDescriptor(value)) return normalizeAutomationSelector(value) if (!Array.isArray(value) && !isPlainObject(value)) return value if (active.has(value)) throw new TypeError("Cyclic action input") active.add(value) try { if (Array.isArray(value)) { const result: unknown[] = [] for (const item of value) { result.push(await replaceActionInputSignals(item, active)) } return result } const entries: [string, unknown][] = [] for (const [key, nested] of Object.entries(value)) { entries.push([key, await replaceActionInputSignals(nested, active)]) } return Object.fromEntries(entries) } finally { active.delete(value) } } /** * Checks whether replayed action history began from the same dependency state. * * @param completed - Persisted action outcome metadata. * @param completed.reason - Dependency reason for a skipped action. * @param completed.status - Persisted action status. * @param dependency - Current combined dependency state. */ function isCompatibleDependencyOutcome( completed: { reason?: "closed-dependency" | "failed-dependency" status: "failed" | "pending" | "skipped" | "succeeded" }, dependency: SignalDependencyResolution, ) { if (dependency.status === "pending") return false // Derived transforms and filters run during materialization, not dependency // collection. An otherwise matching persisted skip is the durable record of // a failure or closure that successful symbolic collection cannot reproduce. if (dependency.status === "succeeded") return true return ( completed.status === "skipped" && completed.reason === (dependency.status === "closed" ? "closed-dependency" : "failed-dependency") ) } /** * Formats one action's selected dependency identities for diagnostics. * * @param dependencies - Dependency identities selected by one traversal. * @param dependencies.actionDependencyIds - Selected action invocations. * @param dependencies.eventDependencyIds - Selected automation events. * @param dependencies.signalDependencyIds - Selected signal invocations. */ function formatActionDependencyIds(dependencies: { actionDependencyIds: Iterable eventDependencyIds: Iterable signalDependencyIds: Iterable }) { return `action IDs [${[...dependencies.actionDependencyIds].join(", ")}], event IDs [${[...dependencies.eventDependencyIds].join(", ")}], signal IDs [${[...dependencies.signalDependencyIds].join(", ")}]` } /** * Compares persisted IDs with deterministic traversal order. * * @param actual - Persisted ordered IDs. * @param expected - IDs collected during current traversal. */ function equalDependencyIds(actual: string[], expected: Set) { return ( actual.length === expected.size && [...expected].every((id, index) => id === actual[index]) ) }