import { TelemetryService } from '../telemetry'; import { HookActivity } from '../../types/activity'; import { HookRule } from '../../types/hook'; import { JobStatus } from '../../types/job'; import { ProviderTransaction } from '../../types/provider'; import { StreamCode, StreamStatus } from '../../types/stream'; import { Activity } from './activity'; /** * The most flexible activity type in the HotMesh YAML DAG. Depending on * its configuration it operates as one of four distinct flavors: * * | Flavor | Key field | Behavior | * |---|---|---| * | **Time** | `sleep` | Pauses for a duration, then resumes | * | **Signal** | `hook.topic` | Pauses until an external signal arrives | * | **Cycle** | `cycle: true` | Passthrough that also accepts re-entry from a `cycle` activity | * | **Passthrough** | _(none)_ | Maps data and transitions immediately | * * --- * * ## Flavor 1 — Time Hook (sleep) * * Pauses the flow for `sleep` seconds. The value can be a literal number * or a `@pipe` expression (e.g., for exponential backoff). * * ```yaml * activities: * t1: * type: trigger * * wait_30s: * type: hook * sleep: 30 # pause for 30 seconds * job: * maps: * paused_at: '{$self.output.metadata.ac}' * * next_step: * type: hook * * transitions: * t1: * - to: wait_30s * wait_30s: * - to: next_step * ``` * * Dynamic delay with `@pipe` (exponential backoff on retry): * * ```yaml * wait_retry: * type: hook * sleep: * '@pipe': * - ['{$self.output.data.attempt}', 2] * - ['{@math.pow}'] # 1, 2, 4, 8, 16 … * ``` * * --- * * ## Flavor 2 — Signal Hook (webhook) * * Registers a listener on a named topic. The flow pauses until an * external caller delivers a signal. Signal data is available as * `$self.hook.data`. The graph-level `hooks` section routes incoming * signals to the waiting activity via a `conditions.match` rule. * * **Send the signal** from any process: * * ```typescript * await hotMesh.signal('order.approval', { id: jobId, approved: true }); * ``` * * **Claim and delete** a pending signal via the collator key: * * ```typescript * // Ack/delete — deliver a signal and clear the hook registration * await hotMesh.signal('order.approval', { id: jobId, approved: false }); * ``` * * **YAML configuration:** * * ```yaml * app: * id: myapp * version: '1' * graphs: * - subscribes: order.placed * expire: 3600 * * activities: * t1: * type: trigger * * wait_for_approval: * type: hook * hook: * type: object * properties: * approved: { type: boolean } * job: * maps: * approved: '{$self.hook.data.approved}' * * done: * type: hook * * transitions: * t1: * - to: wait_for_approval * wait_for_approval: * - to: done * * hooks: * order.approval: # topic delivering the signal * - to: wait_for_approval * conditions: * match: * - expected: '{t1.output.data.id}' # job ID * actual: '{$self.hook.data.id}' # signal payload ID * ``` * * ### Signal Hook with Escalation * * Adding an `escalation:` block causes the hook activity to write one row * to `public.hmsh_escalations` atomically inside its Leg 1 transaction — * the same database commit that checkpoints job state. The row is * immediately queryable and claimable by any external system. * * All field values support `@pipe` expressions so they can reference job * data computed by earlier activities (e.g., `'{t1.output.data.region}'`). * * ```yaml * wait_for_approval: * type: hook * hook: * type: object * properties: * approved: { type: boolean } * escalation: * role: manager # RBAC role that should act * type: order-approval * subtype: regional * priority: 2 # lower = higher priority * description: Approve or reject the order * entity: '{t1.output.data.entityType}' * metadata: * orderId: '{t1.output.data.orderId}' * region: '{t1.output.data.region}' * envelope: * instructions: Review the attached order and approve or reject * expiresAt: '{t1.output.data.dueDate}' * job: * maps: * approved: '{$self.hook.data.approved}' * ``` * * **Claim and resolve the escalation** (resumes the waiting workflow): * * ```typescript * // Find pending approvals for the manager role * const [item] = await client.escalations.list({ role: 'manager', status: 'pending' }); * * // Claim it (sets assigned_to + claim_expires_at) * const claim = await client.escalations.claim({ * id: item.id, * assignee: 'alice@company.com', * durationMinutes: 30, * }); * * // Resolve atomically delivers the signal and resumes the workflow * await client.escalations.resolve({ * id: item.id, * resolverPayload: { approved: true }, * }); * ``` * * --- * * ## Flavor 3 — Cycle Pivot * * A passthrough hook with `cycle: true` acts as the named re-entry point * for a `cycle` activity. On first entry it behaves identically to a * passthrough (maps data, transitions forward). When a `cycle` activity * downstream names it as its `ancestor`, the engine routes execution back * to it, allowing a controlled loop without spawning a new job. * * ```yaml * app: * id: myapp * version: '1' * graphs: * - subscribes: job.start * expire: 120 * * activities: * t1: * type: trigger * * pivot: * type: hook * cycle: true # marks this as a loop re-entry point * * do_work: * type: worker * topic: work.process * output: * schema: * type: object * properties: * counter: { type: number } * job: * maps: * counter: '{$self.output.data.counter}' * * loop_back: * type: cycle * ancestor: pivot # jumps back to `pivot` when condition holds * * transitions: * t1: * - to: pivot * pivot: * - to: do_work * do_work: * - to: loop_back * conditions: * match: * - expected: true * actual: * '@pipe': * - ['{do_work.output.data.counter}', 5] * - ['{@conditional.less_than}'] * ``` * * --- * * ## Flavor 4 — Passthrough * * When none of `sleep`, `hook`, or `cycle` is set, the hook activity * immediately maps data and transitions to its children. Useful as a * data transformation node or fan-in convergence point. * * ```yaml * merge: * type: hook * output: * maps: * total: '{a1.output.data.subtotal}' # copy field into activity output * job: * maps: * total: '{$self.output.data.total}' # promote to job-level data * ``` * * --- * * ## Execution Model * * - **Time and Signal flavors** — Category A (duplex). Leg 1 registers the * hook (timer or webhook), saves state, and commits. Leg 2 fires when the * timer fires or the external signal arrives. * - **Cycle and Passthrough flavors** — Category B. Uses the crash-safe * `executeLeg1StepProtocol` (GUID ledger backed) to map data and * immediately transition to adjacent activities. * * @see {@link HookActivity} for the TypeScript interface */ declare class Hook extends Activity { config: HookActivity; process(): Promise; isConfiguredAsHook(): boolean; doesHook(): boolean; doHook(telemetry: TelemetryService): Promise; private addEscalationToTransaction; /** * Derive signal_key from the hook rule's expected condition — the same * value registerWebHook stores as the signal lookup key. Used by the Leg1 * escalation INSERT and by the timeout path's expiry UPDATE, so both sides * address the identical row. */ private deriveEscalationSignalKey; /** * Re-publishes a pending signal as a WEBHOOK stream message so the * normal leg2 dispatch path processes it. Called when leg1's * setHookSignal atomically detected and consumed a pending signal. */ private redeliverPendingSignal; doPassThrough(telemetry: TelemetryService): Promise; getHookRule(topic: string): Promise; /** * Register the time hook (sleep) inside the Leg1 transaction. * Time hooks don't participate in the signal race — they're * purely internal timeout registrations. */ registerTimeHook(transaction: ProviderTransaction): Promise; /** * Register the web hook signal AFTER the Leg1 transaction commits. * This ensures the hook signal is never visible before Leg1 * completion, eliminating the FORBIDDEN window where Leg2 could * find the hook but fail on the collation check. * * If a pending signal was stored by an early-arriving Leg2, * setHookSignal atomically detects and returns it. */ registerWebHookSignal(): Promise<{ pending?: string; } | void>; /** * @deprecated Use registerTimeHook + registerWebHookSignal instead. * Kept for backward compatibility with tests that monkey-patch this method. */ registerHook(transaction?: ProviderTransaction): Promise<{ jobId?: string; pending?: string; } | void>; processWebHookEvent(status?: StreamStatus, code?: StreamCode): Promise; processTimeHookEvent(jobId: string): Promise; /** * The timeout won the race: transition the wait's escalation row * `pending → expired` so the worklist stops offering work whose workflow * has already resumed, and a late resolve fails as already-expired * instead of delivering a payload into the void. The UPDATE is guarded * by status='pending'. Returns what the row delivers to the waiter (an * accumulator's collection, set as `resolver_payload` in the same * UPDATE), and `settledElsewhere` when a row exists but a resolve or * cancel already moved it off `pending`, so the caller skips Leg2. With * no row (the wait carried no escalation) the timeout proceeds as usual. */ private expireEscalationOnTimeout; } export { Hook };