diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 9e01a1c89..9bdb697c4 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -15,6 +15,8 @@ on: pull_request: branches: - main + # Integration branch for the human-in-the-loop work: feature PRs land there first. + - feat/human-in-the-loop permissions: contents: read diff --git a/apps/execution-worker/src/database.ts b/apps/execution-worker/src/database.ts index 925e2780c..7c88ea567 100644 --- a/apps/execution-worker/src/database.ts +++ b/apps/execution-worker/src/database.ts @@ -1,18 +1,27 @@ // Worker DB access — raw SQL to avoid coupling worker to backend's Drizzle schema. import postgres from 'postgres'; -import { TERMINAL_EXECUTION_STATUSES } from '@workflow-builder/types/workflow-execution/execution-events'; +import { + type ExecutionEventType, + type ExecutionStatus, + TERMINAL_EXECUTION_STATUSES, +} from '@workflow-builder/types/workflow-execution/execution-events'; import { env } from './env'; const sql = postgres(env.DATABASE_URL); -// Widened alias: `status` arrives as a plain string, and `.includes` on a -// literal-union tuple rejects it. -const TERMINAL_STATUSES: readonly string[] = TERMINAL_EXECUTION_STATUSES; +// Widened alias: `.includes` on the terminal-only tuple rejects the full status union. +const TERMINAL_STATUSES: readonly ExecutionStatus[] = TERMINAL_EXECUTION_STATUSES; export const database = { - async emitExecutionEvent(executionId: string, sequence: number, type: string, payload?: unknown, nodeId?: string) { + async emitExecutionEvent( + executionId: string, + sequence: number, + type: ExecutionEventType, + payload?: unknown, + nodeId?: string, + ) { await sql` INSERT INTO execution_events (id, execution_id, sequence, timestamp, type, node_id, path_id, payload_json, tenant_id, created_at) VALUES ( @@ -34,16 +43,19 @@ export const database = { await sql`SELECT pg_notify('execution_events', ${executionId})`; }, - async updateExecutionStatus(executionId: string, status: string, errorMessage?: string) { + async updateExecutionStatus(executionId: string, status: ExecutionStatus, errorMessage?: string) { const isTerminal = TERMINAL_STATUSES.includes(status); // Terminal statuses are immutable: a cancel cleanup landing after the run already // wrote `failed` must not flip it to `cancelled`. Matching 0 rows is a silent // no-op, which also makes a retried terminal write idempotent. + // started_at survives resumes: only the first 'running' stamps it, so a verdict + // un-parking a gate does not move the start. Writing 'running' at actual run + // start is a separate, still-open fix (follow-up: running-status-at-start). await sql` UPDATE executions SET status = ${status}, - started_at = CASE WHEN ${status} = 'running' THEN now() ELSE started_at END, + started_at = CASE WHEN ${status} = 'running' AND started_at IS NULL THEN now() ELSE started_at END, finished_at = CASE WHEN ${isTerminal} THEN now() ELSE finished_at END, error_message = ${errorMessage ?? null}, updated_at = now() diff --git a/packages/execution-core/src/graph-runner.test.ts b/packages/execution-core/src/graph-runner.test.ts index 765cd46ea..6fc7afa29 100644 --- a/packages/execution-core/src/graph-runner.test.ts +++ b/packages/execution-core/src/graph-runner.test.ts @@ -9,7 +9,7 @@ import type { import { NodeExecutionError } from './errors'; import { runGraph } from './graph-runner'; -import type { ActivityRunnerPort } from './ports/activity-runner.port'; +import type { ActivityRunnerPort, CompletedNodeExecution } from './ports/activity-runner.port'; import type { EventEmitterPort } from './ports/event-emitter.port'; import type { WorkflowExecutionInput } from './ports/workflow-engine.port'; import { reconstructNodeInputs } from './reconstruct-node-inputs'; @@ -25,6 +25,7 @@ type NodeBehavior = { output?: unknown; nextPort?: string; throws?: string; + waits?: true; }; function makeRunner(behaviors: Record = {}): { @@ -43,18 +44,73 @@ function makeRunner(behaviors: Record = {}): { contexts[node.id] = { ...context.nodeOutputs }; const b = behaviors[node.id]; if (b?.throws) throw new Error(b.throws); + if (b?.waits) return { waiting: true }; return { output: b?.output ?? `out-${node.id}`, nextPort: b?.nextPort }; }, }, }; } +// makeRunner plus an `awaitResolution` port: observe parks via `whenParked`, +// deliver verdicts via `resolveGate`. +function makeGatedRunner(behaviors: Record = {}): { + port: ActivityRunnerPort; + callOrder: string[]; + contexts: Record>; + parkedIds: () => string[]; + whenParked: (count: number) => Promise; + resolveGate: (nodeId: string, completion: CompletedNodeExecution) => void; +} { + const base = makeRunner(behaviors); + const pending = new Map void>(); + const watchers = new Set<{ count: number; notify: () => void }>(); + return { + callOrder: base.callOrder, + contexts: base.contexts, + port: { + executeNode: base.port.executeNode, + awaitResolution(nodeId) { + return new Promise((resolve) => { + pending.set(nodeId, resolve); + for (const watcher of watchers) { + if (pending.size >= watcher.count) { + watchers.delete(watcher); + watcher.notify(); + } + } + }); + }, + }, + parkedIds: () => [...pending.keys()], + whenParked(count) { + return new Promise((notify) => { + if (pending.size >= count) notify(); + else watchers.add({ count, notify }); + }); + }, + resolveGate(nodeId, completion) { + const resolve = pending.get(nodeId); + if (!resolve) throw new Error(`no parked gate for ${nodeId}`); + pending.delete(nodeId); + resolve(completion); + }, + }; +} + +// Lets everything already unblocked (verdict continuations, absorbed failures) settle. +function flush(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + type EventCall = { type: string; nodeId?: string; payload?: unknown }; type StatusCall = { status: string; errorMessage?: string }; type EmitFailure = { type: string; nodeId?: string; message: string }; -function makeEvents(failOn?: EmitFailure): { +function makeEvents( + failOn?: EmitFailure, + failStatus?: { status: string; message: string }, +): { port: EventEmitterPort; events: EventCall[]; statuses: StatusCall[]; @@ -73,6 +129,9 @@ function makeEvents(failOn?: EmitFailure): { }, async updateStatus(_executionId, status, errorMessage) { statuses.push({ status, errorMessage }); + if (failStatus && failStatus.status === status) { + throw new Error(failStatus.message); + } }, }, }; @@ -1520,3 +1579,173 @@ describe('runGraph — node_started payload', () => { expect(contexts.C).toEqual({ A: 'out-A', B: { authToken: 'tok-123', text: 'done' } }); }); }); + +describe('runGraph — waiting results', () => { + it('fails the run when the adapter has no awaitResolution, even with errorPolicy continue on the gate', async () => { + const runner = makeRunner({ A: { waits: true } }); + const events = makeEvents(); + + const outcome = await runGraph( + makeInput([start('A', 'continue'), trigger('B')], [edge('e1', 'A', 'B')]), + runner.port, + events.port, + ); + + const message = 'Node "A" returned a waiting result, but this engine adapter does not support gates'; + expect(outcome).toEqual({ status: 'failed', error: { message, code: 'waiting_unsupported' } }); + expect(runner.callOrder).toEqual(['A']); + // No node_failed for the gate, and B is never-reached rather than skipped. + expect(events.events.map((event) => event.type)).toEqual(['execution_started', 'node_started', 'execution_failed']); + expect(events.statuses).toEqual([{ status: 'failed', errorMessage: message }]); + }); + + it('parks a single gate, resumes it on the verdict, and downstream sees the verdict output', async () => { + const runner = makeGatedRunner({ A: { waits: true } }); + const events = makeEvents(); + + const run = runGraph(makeInput([start('A'), trigger('B')], [edge('e1', 'A', 'B')]), runner.port, events.port); + await runner.whenParked(1); + + expect(runner.parkedIds()).toEqual(['A']); + expect(events.events.map((event) => event.type)).toEqual(['execution_started', 'node_started', 'node_waiting']); + expect(events.statuses).toEqual([{ status: 'waiting' }]); + expect(runner.callOrder).toEqual(['A']); + + runner.resolveGate('A', { output: 'approved' }); + const outcome = await run; + + expect(outcome).toEqual({ status: 'completed' }); + expect(runner.callOrder).toEqual(['A', 'B']); + expect(runner.contexts.B).toEqual({ A: 'approved' }); + expect(events.events.map((event) => event.type)).toEqual([ + 'execution_started', + 'node_started', + 'node_waiting', + 'node_completed', + 'node_started', + 'node_completed', + 'execution_completed', + ]); + expect(events.statuses).toEqual([{ status: 'waiting' }, { status: 'running' }, { status: 'completed' }]); + }); + + it('routes the verdict through nextPort like any completion', async () => { + const runner = makeGatedRunner({ A: { waits: true } }); + const events = makeEvents(); + + const run = runGraph( + makeInput( + [start('A'), trigger('B'), trigger('C')], + [edge('e1', 'A', 'B', 'approved'), edge('e2', 'A', 'C', 'rejected')], + ), + runner.port, + events.port, + ); + await runner.whenParked(1); + runner.resolveGate('A', { output: 'no', nextPort: 'rejected' }); + const outcome = await run; + + expect(outcome).toEqual({ status: 'completed' }); + expect(runner.callOrder).toEqual(['A', 'C']); + expect(skipsFrom(events.events)).toEqual([{ nodeId: 'B', reason: 'branch_not_taken' }]); + }); + + it('two gates in one wave wait concurrently and take their verdicts independently', async () => { + const runner = makeGatedRunner({ B: { waits: true }, C: { waits: true } }); + const events = makeEvents(); + + const run = runGraph( + makeInput( + [start('A'), trigger('B'), trigger('C'), trigger('D')], + [edge('e1', 'A', 'B'), edge('e2', 'A', 'C'), edge('e3', 'B', 'D'), edge('e4', 'C', 'D')], + ), + runner.port, + events.port, + ); + await runner.whenParked(2); + + expect(runner.parkedIds().sort()).toEqual(['B', 'C']); + const waitingNodes = events.events.filter((event) => event.type === 'node_waiting').map((event) => event.nodeId); + expect(waitingNodes.sort()).toEqual(['B', 'C']); + expect(events.statuses).toEqual([{ status: 'waiting' }]); + + runner.resolveGate('C', { output: 'c-verdict' }); + await flush(); + expect(runner.callOrder).toEqual(['A', 'B', 'C']); + expect(events.statuses).toEqual([{ status: 'waiting' }]); + + runner.resolveGate('B', { output: 'b-verdict' }); + const outcome = await run; + + expect(outcome).toEqual({ status: 'completed' }); + expect(runner.callOrder.filter((id) => id === 'D')).toHaveLength(1); + expect(runner.contexts.D).toEqual({ A: 'out-A', B: 'b-verdict', C: 'c-verdict' }); + expect(events.statuses).toEqual([{ status: 'waiting' }, { status: 'running' }, { status: 'completed' }]); + }); + + it('parks and resumes even when the waiting status write fails', async () => { + const runner = makeGatedRunner({ A: { waits: true } }); + const events = makeEvents(undefined, { status: 'waiting', message: 'db down' }); + + const run = runGraph(makeInput([start('A'), trigger('B')], [edge('e1', 'A', 'B')]), runner.port, events.port); + await runner.whenParked(1); + runner.resolveGate('A', { output: 'approved' }); + const outcome = await run; + + expect(outcome).toEqual({ status: 'completed' }); + expect(runner.callOrder).toEqual(['A', 'B']); + // The failed write was attempted, absorbed, and the counter still reached zero. + expect(events.statuses.map((entry) => entry.status)).toEqual(['waiting', 'running', 'completed']); + }); + + it('a delivered verdict survives a failing running status write', async () => { + const runner = makeGatedRunner({ A: { waits: true } }); + const events = makeEvents(undefined, { status: 'running', message: 'db down' }); + + const run = runGraph(makeInput([start('A'), trigger('B')], [edge('e1', 'A', 'B')]), runner.port, events.port); + await runner.whenParked(1); + runner.resolveGate('A', { output: 'approved' }); + const outcome = await run; + + expect(outcome).toEqual({ status: 'completed' }); + expect(runner.contexts.B).toEqual({ A: 'approved' }); + expect(events.events.filter((event) => event.nodeId === 'A').map((event) => event.type)).toEqual([ + 'node_started', + 'node_waiting', + 'node_completed', + ]); + }); + + it('a fatal sibling in the same wave fails the run only after the parked gate resolves', async () => { + const runner = makeGatedRunner({ B: { waits: true }, C: { throws: 'boom' } }); + const events = makeEvents(); + + const run = runGraph( + makeInput( + [start('A'), trigger('B'), trigger('C'), trigger('D')], + [edge('e1', 'A', 'B'), edge('e2', 'A', 'C'), edge('e3', 'B', 'D')], + ), + runner.port, + events.port, + ); + await runner.whenParked(1); + await flush(); + + expect(events.events.map((event) => event.type)).toContain('node_failed'); + expect(events.statuses).toEqual([{ status: 'waiting' }]); + + runner.resolveGate('B', { output: 'approved' }); + const outcome = await run; + + expect(outcome).toEqual({ status: 'failed', error: { message: 'boom' } }); + expect(runner.callOrder.includes('D')).toBe(false); + const types = events.events.map((event) => event.type); + expect(types.at(-1)).toBe('execution_failed'); + expect(types.indexOf('node_completed')).toBeLessThan(types.indexOf('execution_failed')); + expect(events.statuses).toEqual([ + { status: 'waiting' }, + { status: 'running' }, + { status: 'failed', errorMessage: 'boom' }, + ]); + }); +}); diff --git a/packages/execution-core/src/graph-runner.ts b/packages/execution-core/src/graph-runner.ts index 7943ab920..8790339ed 100644 --- a/packages/execution-core/src/graph-runner.ts +++ b/packages/execution-core/src/graph-runner.ts @@ -11,7 +11,7 @@ import type { import { extractDeepestError } from './errors'; import type { ExecutionContext } from './execution-context'; -import type { ActivityRunnerPort } from './ports/activity-runner.port'; +import type { ActivityRunnerPort, CompletedNodeExecution } from './ports/activity-runner.port'; import type { EventEmitterPort } from './ports/event-emitter.port'; import type { WorkflowExecutionInput } from './ports/workflow-engine.port'; import { withRedactedPayloads } from './redact'; @@ -89,6 +89,7 @@ export async function runGraph( let ready: TNode[] = [startNode]; const nodeOutputs: Record = {}; const deadEnds: DeadEnd[] = []; + const parked = { count: 0 }; while (ready.length > 0) { const context: ExecutionContext = { @@ -100,14 +101,16 @@ export async function runGraph( global: input.global, }; - const results = await Promise.all(ready.map((node) => runNode(node, context, runner, events, input.executionId))); + const results = await Promise.all( + ready.map((node) => runNode(node, context, runner, events, input.executionId, parked)), + ); // Fatal failures (policy 'fail') abort the whole execution — pick the first // one in deterministic node order, just like the previous behavior. The abort // itself waits until the wave has propagated and emitted its skips: siblings // that resolved in this same wave still owe their `node_skipped` events, and // `execution_failed` has to stay the last event of the run. - const fatal = results.find((r) => r.failed && resolveErrorPolicy(r.node) === 'fail'); + const fatal = results.find((r) => r.failed && (r.abort === true || resolveErrorPolicy(r.node) === 'fail')); const newlyReady: TNode[] = []; const skipped: SkippedNode[] = []; @@ -117,7 +120,7 @@ export async function runGraph( // A fatal node resolves nothing — no output, no propagation — so its // successors keep their pending predecessor count and emit no event. // Never reached is a different state from deliberately skipped. - if (policy === 'fail') continue; + if (result.abort === true || policy === 'fail') continue; // 'continue' and 'errorRoute' absorb the error into nodeOutputs so downstream // nodes can inspect it via the standard `{{ nodes..output }}` path. const errorOutput = @@ -298,7 +301,8 @@ function skipReason(kind: LivePruneKind | undefined): NodeSkipReason { type NodeRunResult = | { node: TNode; output: unknown; nextPort?: string; failed: false } - | { node: TNode; message: string; code?: string; failed: true }; + // `abort` marks a runner-level abort that outranks the node's own errorPolicy. + | { node: TNode; message: string; code?: string; failed: true; abort?: true }; async function runNode( node: TNode, @@ -306,11 +310,29 @@ async function runNode( runner: ActivityRunnerPort, events: EventEmitterPort, executionId: string, + parked: { count: number }, ): Promise> { try { const visibleNodeIds = Object.keys(context.nodeOutputs); await events.emitEvent(executionId, 'node_started', { config: node.config, visibleNodeIds }, node.id); - const result = await runner.executeNode(node, context); + const executed = await runner.executeNode(node, context); + let result: CompletedNodeExecution; + if (executed.waiting) { + if (runner.awaitResolution === undefined) { + // A runner-level abort, not a node failure: routed around `errorPolicy`, where + // 'continue' would close the run as completed with the gate silently skipped. + return { + node, + message: `Node "${node.id}" returned a waiting result, but this engine adapter does not support gates`, + code: 'waiting_unsupported', + failed: true, + abort: true, + }; + } + result = await parkUntilResolved(runner.awaitResolution.bind(runner), node.id, events, executionId, parked); + } else { + result = executed; + } await events.emitEvent(executionId, 'node_completed', { output: result.output }, node.id); return { node, output: result.output, nextPort: result.nextPort, failed: false }; } catch (error) { @@ -323,6 +345,47 @@ async function runNode( } } +// `parked` is a counter, not a flag: with two gates parked, the first verdict +// must not flip the run back to 'running'. +async function parkUntilResolved( + awaitResolution: (nodeId: string) => Promise, + nodeId: string, + events: EventEmitterPort, + executionId: string, + parked: { count: number }, +): Promise { + await events.emitEvent(executionId, 'node_waiting', undefined, nodeId); + parked.count += 1; + try { + if (parked.count === 1) { + await setAdvisoryStatus(events, executionId, 'waiting'); + } + // Awaited here, so the caller's wave slot stays pending and the barrier holds + // itself: the wave completes only once every parked node has resolved. + return await awaitResolution(nodeId); + } finally { + // On rejection too: a failure absorbed by 'continue' must not strand the run in 'waiting'. + parked.count -= 1; + if (parked.count === 0) { + await setAdvisoryStatus(events, executionId, 'running'); + } + } +} + +// waiting/running are derived, advisory state: a failed write must not cost a park or +// a delivered verdict. Same rationale as the swallowed node_skipped emit above. +async function setAdvisoryStatus( + events: EventEmitterPort, + executionId: string, + status: 'waiting' | 'running', +): Promise { + try { + await events.updateStatus(executionId, status); + } catch { + // Swallowed on purpose — see above. No logger inside the workflow sandbox. + } +} + function resolveErrorPolicy(node: BaseNode): NodeErrorPolicy { return node.errorPolicy ?? 'fail'; } diff --git a/packages/execution-core/src/index.ts b/packages/execution-core/src/index.ts index 5a97656be..517e47641 100644 --- a/packages/execution-core/src/index.ts +++ b/packages/execution-core/src/index.ts @@ -14,7 +14,12 @@ export type { NodeErrorClassification, NodeErrorEnvelope } from './errors'; export type { ExecutionContext } from './execution-context'; export type { WorkflowEnginePort, WorkflowExecutionInput } from './ports/workflow-engine.port'; -export type { ActivityRunnerPort, NodeExecutionResult } from './ports/activity-runner.port'; +export type { + ActivityRunnerPort, + CompletedNodeExecution, + NodeExecutionResult, + WaitingNodeExecution, +} from './ports/activity-runner.port'; export type { EventEmitterPort } from './ports/event-emitter.port'; export type { LoggerPort, LogBindings } from './ports/logger.port'; diff --git a/packages/execution-core/src/ports/activity-runner.port.ts b/packages/execution-core/src/ports/activity-runner.port.ts index 1d363974e..02376c79a 100644 --- a/packages/execution-core/src/ports/activity-runner.port.ts +++ b/packages/execution-core/src/ports/activity-runner.port.ts @@ -2,16 +2,27 @@ import type { BaseNode } from '@workflow-builder/types/workflow-execution/execut import type { ExecutionContext } from '../execution-context'; -export type NodeExecutionResult = { +export type CompletedNodeExecution = { output: unknown; // Naming a port promises a live route: if no outgoing edge goes live for it, the run // ends incomplete with `{ nodeId, port }`. Falsy ('' or a smuggled null) means "no // port", mirroring the router. 'errorRoute' is reserved for the error policy. nextPort?: string; + // Never present — discriminates the union. + waiting?: never; }; +export type WaitingNodeExecution = { + waiting: true; +}; + +export type NodeExecutionResult = CompletedNodeExecution | WaitingNodeExecution; + // Graph runner calls this to execute a single node's activity. // Temporal adapter wraps proxyActivities; in-memory adapter calls the executor directly. export interface ActivityRunnerPort { executeNode(node: TNode, context: ExecutionContext): Promise; + // Resolves with the verdict's completion. Engines without gate support omit it; + // a waiting result then fails the run. + awaitResolution?(nodeId: string): Promise; } diff --git a/packages/execution-core/src/ports/event-emitter.port.ts b/packages/execution-core/src/ports/event-emitter.port.ts index 37b5108ee..2551a607d 100644 --- a/packages/execution-core/src/ports/event-emitter.port.ts +++ b/packages/execution-core/src/ports/event-emitter.port.ts @@ -1,6 +1,8 @@ +import type { ExecutionEventType, ExecutionStatus } from '@workflow-builder/types/workflow-execution/execution-events'; + // Graph runner calls this to emit execution events and update status. // Implementations persist to DB (direct write in-memory; via activity in Temporal). export interface EventEmitterPort { - emitEvent(executionId: string, type: string, payload?: unknown, nodeId?: string): Promise; - updateStatus(executionId: string, status: string, errorMessage?: string): Promise; + emitEvent(executionId: string, type: ExecutionEventType, payload?: unknown, nodeId?: string): Promise; + updateStatus(executionId: string, status: ExecutionStatus, errorMessage?: string): Promise; } diff --git a/packages/execution-core/src/workflow.ts b/packages/execution-core/src/workflow.ts index 89613b6d9..078c3c092 100644 --- a/packages/execution-core/src/workflow.ts +++ b/packages/execution-core/src/workflow.ts @@ -14,5 +14,10 @@ export { NodeExecutionError } from './errors'; export type { ExecutionContext } from './execution-context'; export type { WorkflowEnginePort, WorkflowExecutionInput } from './ports/workflow-engine.port'; -export type { ActivityRunnerPort, NodeExecutionResult } from './ports/activity-runner.port'; +export type { + ActivityRunnerPort, + CompletedNodeExecution, + NodeExecutionResult, + WaitingNodeExecution, +} from './ports/activity-runner.port'; export type { EventEmitterPort } from './ports/event-emitter.port'; diff --git a/packages/temporal/README.md b/packages/temporal/README.md index 0d94a4bad..8d7eea3cb 100644 --- a/packages/temporal/README.md +++ b/packages/temporal/README.md @@ -163,11 +163,11 @@ Three things are deliberately yours, and knowing which they are makes debugging ## Entry points -| Import | Use it for | -| ------------------------------------ | -------------------------------------------------------------------- | -| `@workflowbuilder/temporal` | Worker side: the plugin, `createActivities`, shared constants, types | -| `@workflowbuilder/temporal/client` | Starting and cancelling runs | -| `@workflowbuilder/temporal/workflow` | Sandbox-safe: `runWorkflow` to re-export, event emitter, profiles | +| Import | Use it for | +| ------------------------------------ | ------------------------------------------------------------------------------------------- | +| `@workflowbuilder/temporal` | Worker side: the plugin, `createActivities`, shared constants, types | +| `@workflowbuilder/temporal/client` | Starting and cancelling runs | +| `@workflowbuilder/temporal/workflow` | Sandbox-safe: `runWorkflow` to re-export, event emitter, profiles, the `resolveNode` update | `/workflow` is the only entry point that is safe inside Temporal's V8 sandbox. The split also means a backend that only starts runs never pulls in the worker package and its native binary. @@ -189,6 +189,48 @@ Filling in `node.label` belongs to whatever builds the `WorkflowExecutionInput`, The attempt count is an upper bound rather than a promise. An executor that throws `PermanentNodeExecutionError` — for a rejected API key, say — is not retried at all: the activity adapter marks the failure non-retryable, which Temporal honours regardless of the profile. `TransientNodeExecutionError` says the opposite, that another attempt is worth making, but it does not raise the limit; the profile still caps it. Anything thrown unclassified retries exactly as it always has. Both classes are re-exported from this package, and a classified failure also records its error code and the attempt it died on in the `node_failed` event — see [`execution-core`](../execution-core/README.md#transient-vs-permanent-failures) for when to throw which. +## Pausing a run for a human + +An executor that returns `{ waiting: true }` instead of a completion parks the run at that node. Nothing polls and no timer is set: the workflow stops producing commands, so a parked run costs nothing while it waits and survives worker restarts, redeploys and weeks of idleness. The wave containing the waiting node holds until every node in it has resolved; the rest of that wave keeps running. + +```ts +const plugin = new WorkflowBuilderPlugin({ + executors: { + 'my-app/approval': () => ({ waiting: true }), + // ...the rest of your executors + }, + store, +}); +``` + +While parked, the store sees a `node_waiting` event for the node and the run status moves to `waiting`. It returns to `running` once the last waiting node has resolved, so two nodes parked at once produce a single `waiting`/`running` transition. + +The verdict arrives as a Workflow Update, `resolveNodeUpdate`: + +```ts +import { executionWorkflowId } from '@workflowbuilder/temporal'; +import { resolveNodeUpdate } from '@workflowbuilder/temporal/workflow'; + +const handle = client.workflow.getHandle(executionWorkflowId(executionId)); +await handle.executeUpdate(resolveNodeUpdate, { + args: [{ nodeId: 'approval-1', resolution: { output: { decision: 'approved' }, nextPort: 'approved' } }], +}); +``` + +The `resolution` is the completion the node finishes with, exactly as if its executor had returned it: `output` becomes the node's output for everything downstream, and `nextPort` routes the graph. This package passes it through untouched. What a verdict contains, and who may deliver one, is your application's contract. + +Because this is an Update and not a signal, the caller gets a synchronous answer, and the update is validated before it is accepted, so a rejected verdict leaves no trace in the run. The rejections, each an `ApplicationFailure` with a stable type: a malformed envelope is `verdict_malformed` (the envelope is an object carrying at most `output` and `nextPort`; `nextPort` must not be the reserved `errorRoute`, and a missing `output` is read as `undefined`, which is what the default JSON payload converter turns `output: undefined` into), a node id that is not in the graph is `verdict_for_unknown_node`, a node that is not currently waiting is `node_not_waiting` (also possible for a verdict racing the parking moment, so treat it as retryable), and a second verdict for the same node is `verdict_already_delivered`: the first one wins. A verdict for a run that has already closed fails at the server. Cancelling a parked run closes it as `cancelled`, with `execution_cancelled` following the node's `node_waiting` and no `node_failed` recorded for the node that was waiting. + +### Wave-barrier limitations (deliberate) + +Graph traversal is wave-based with a barrier, and the pause does not restructure it. Three consequences are documented limitations, not bugs: + +- successors of an independent parallel branch wait for the wave that contains a waiting node, even when their own inputs are ready; +- a waiting node in a deeper wave becomes visible only once the earlier waves resolve; +- a fatal failure in the same wave as a parked node cannot close the run until the verdict arrives, so a person can approve a run that then immediately fails. + +Lifting the barrier later is an additive engine change: same events, same update, same statuses. + ## Versioning and replay This package carries two contracts, not one. The API is the ordinary semver surface. The second is replay compatibility: a workflow can sit in Event History for days, and a new version of this package has to be able to replay a history that an older version recorded. diff --git a/packages/temporal/src/core-contract.ts b/packages/temporal/src/core-contract.ts index 0bafc8fa0..a00a79e1b 100644 --- a/packages/temporal/src/core-contract.ts +++ b/packages/temporal/src/core-contract.ts @@ -27,7 +27,11 @@ export { export type { NodeErrorEnvelope } from '../../execution-core/src/index'; export type { ExecutionContext } from '../../execution-core/src/execution-context'; -export type { NodeExecutionResult } from '../../execution-core/src/ports/activity-runner.port'; +export type { + CompletedNodeExecution, + NodeExecutionResult, + WaitingNodeExecution, +} from '../../execution-core/src/ports/activity-runner.port'; export type { LogBindings, LoggerPort } from '../../execution-core/src/ports/logger.port'; export type { @@ -38,7 +42,12 @@ export type { } from '../../types/src/workflow-execution/execution-model'; // Defined on the sandbox-safe side so both halves of the package share one definition. -export type { WorkflowEnginePort, WorkflowExecutionInput } from './workflow/core-contract'; +export type { + ExecutionEventType, + ExecutionStatus, + WorkflowEnginePort, + WorkflowExecutionInput, +} from './workflow/core-contract'; // Restated for the same reason as WorkflowExecutionInput: the core's registry module // reaches for @workflow-builder/types by package name, and that name would survive diff --git a/packages/temporal/src/index.ts b/packages/temporal/src/index.ts index db120abd2..040502a2a 100644 --- a/packages/temporal/src/index.ts +++ b/packages/temporal/src/index.ts @@ -21,13 +21,17 @@ export type { ActivityProfile, NodeActivityProfiles } from './workflow/activity- export { NodeExecutionError, PermanentNodeExecutionError, TransientNodeExecutionError } from './core-contract'; export type { BaseNode, + CompletedNodeExecution, ExecutionContext, + ExecutionEventType, + ExecutionStatus, LogBindings, LoggerPort, NodeErrorPolicy, NodeExecutionResult, NodeExecutor, NodeExecutorRegistry, + WaitingNodeExecution, WorkflowDefinition, WorkflowEdgeDefinition, WorkflowEnginePort, diff --git a/packages/temporal/src/store.ts b/packages/temporal/src/store.ts index 8ac471980..50e3ebc24 100644 --- a/packages/temporal/src/store.ts +++ b/packages/temporal/src/store.ts @@ -5,13 +5,15 @@ // ascending per execution — a store that can enforce uniqueness on // (executionId, sequence) will reject a duplicate from an activity retry, which is // how at-least-once delivery stays idempotent. +import type { ExecutionEventType, ExecutionStatus } from './core-contract'; + export interface ExecutionStore { emitExecutionEvent( executionId: string, sequence: number, - type: string, + type: ExecutionEventType, payload?: unknown, nodeId?: string, ): Promise; - updateExecutionStatus(executionId: string, status: string, errorMessage?: string): Promise; + updateExecutionStatus(executionId: string, status: ExecutionStatus, errorMessage?: string): Promise; } diff --git a/packages/temporal/src/workflow/activities-interface.ts b/packages/temporal/src/workflow/activities-interface.ts index 9335d8600..712490240 100644 --- a/packages/temporal/src/workflow/activities-interface.ts +++ b/packages/temporal/src/workflow/activities-interface.ts @@ -1,9 +1,21 @@ // The activity contract, shared by both sides of the sandbox boundary: the workflow // proxies it, and `createActivities` on the worker side implements it. -import type { BaseNode, ExecutionContext, NodeExecutionResult } from './core-contract'; +import type { + BaseNode, + ExecutionContext, + ExecutionEventType, + ExecutionStatus, + NodeExecutionResult, +} from './core-contract'; export type Activities = { executeNode(node: TNode, context: ExecutionContext): Promise; - emitEvent(executionId: string, sequence: number, type: string, payload?: unknown, nodeId?: string): Promise; - updateStatus(executionId: string, status: string, errorMessage?: string): Promise; + emitEvent( + executionId: string, + sequence: number, + type: ExecutionEventType, + payload?: unknown, + nodeId?: string, + ): Promise; + updateStatus(executionId: string, status: ExecutionStatus, errorMessage?: string): Promise; }; diff --git a/packages/temporal/src/workflow/core-contract.ts b/packages/temporal/src/workflow/core-contract.ts index 0c6df939a..ba53b4d42 100644 --- a/packages/temporal/src/workflow/core-contract.ts +++ b/packages/temporal/src/workflow/core-contract.ts @@ -1,3 +1,4 @@ +import type { ExecutionEventType, ExecutionStatus } from '../../../types/src/workflow-execution/execution-events'; import type { BaseNode, WorkflowDefinition } from '../../../types/src/workflow-execution/execution-model'; // The sandbox-safe half of the seam described in ../core-contract.ts. @@ -10,7 +11,7 @@ export { runGraph } from '../../../execution-core/src/workflow'; export type { ActivityRunnerPort, - EventEmitterPort, + CompletedNodeExecution, ExecutionContext, NodeExecutionResult, RunGraphOutcome, @@ -18,6 +19,8 @@ export type { export type { BaseNode } from '../../../types/src/workflow-execution/execution-model'; +export type { ExecutionEventType, ExecutionStatus } from '../../../types/src/workflow-execution/execution-events'; + // Restated here rather than re-exported from execution-core's port module, which // reaches for @workflow-builder/types by package name — that name survives into the // emitted .d.ts and breaks types for consumers, since the package is not published. @@ -37,3 +40,10 @@ export interface WorkflowEnginePort { submit(input: WorkflowExecutionInput): Promise; cancel(executionId: string): Promise; } + +// Restated for the same reason as WorkflowExecutionInput: the core's port module +// reaches for @workflow-builder/types by package name. +export interface EventEmitterPort { + emitEvent(executionId: string, type: ExecutionEventType, payload?: unknown, nodeId?: string): Promise; + updateStatus(executionId: string, status: ExecutionStatus, errorMessage?: string): Promise; +} diff --git a/packages/temporal/src/workflow/durable-pause.decision-log.md b/packages/temporal/src/workflow/durable-pause.decision-log.md new file mode 100644 index 000000000..b4b739851 --- /dev/null +++ b/packages/temporal/src/workflow/durable-pause.decision-log.md @@ -0,0 +1,47 @@ +# Durable pause (HITL seam) — decision log + +Context: a node executor can return `{ waiting: true }`; the graph runner parks that +wave slot on `ActivityRunnerPort.awaitResolution` and resumes with the completion the +verdict carries. This file records the decisions behind the Temporal side of the seam +(`run-workflow.ts`), so the code stays comment-light. + +- **Workflow Update, not a signal.** A signal is fire-and-forget: a verdict delivered + after a deadline auto-reject would get a 202 and vanish silently. An Update answers + synchronously, so a late caller hears "already closed". It also lets the follow-ups + (the decision endpoint's conflict answer, the verdict-vs-deadline race, the claim) + sequence inside the workflow instead of keeping Postgres and Temporal consistent + with no transaction between them. +- **Handler and state live inside the workflow function, not the factory closure.** + `createRunWorkflow` runs once per module evaluation and returns one function. Per-run + state beside the port would appear to work only because the sandbox re-evaluates the + module per activation — an implementation detail, not a contract. +- **One wait-state map per node: idle (absent) → waiting → resolved.** The map is the wake-up + condition for `condition()`, the validator's source of truth, and the + first-write-wins record. A `resolved` entry never leaves: deleting it on consumption + would let a second verdict in, so a duplicate is rejected synchronously with + `verdict_already_delivered`. A `waiting` entry is removed when the wait rejects + (cancellation), so a verdict arriving then gets `node_not_waiting`, not a false + success. A node is scheduled at most once per run; if re-runnable nodes ever appear, + key the map by attempt, deliberately. +- **Registering the handler unconditionally is additive.** Handler registration writes + nothing to Event History, and a `condition()` awaited without a deadline creates no + timer command. The committed gateless history in `test/replay/` pins this. +- **The `ReturnType>` annotation.** `defineUpdate`'s return type + (`UpdateDefinition`) lives in `@temporalio/common`, which this package does not + declare as a dependency. Naming it in the emitted d.ts (TS2742) would break consumers + under pnpm's strict layout. Anchoring the annotation to `defineUpdate` keeps the + declaration inside `@temporalio/workflow`, which is declared. The alternative was + adding `@temporalio/common` to `dependencies`. +- **The update validator guards engine integrity; domain validation stays out.** A + non-`TemporalFailure` thrown from an update handler fails the workflow task, which + retries and redelivers forever: one malformed verdict would wedge a parked run. A + validator throw runs before acceptance instead — the update is rejected, the task is + safe, nothing reaches history, and replay skips validators. Three invariants only: + the update cannot kill the run, success means it landed on a waiting node, a verdict + cannot do what an executor could not. Consequence: a verdict racing the parking + activation is rejected `node_not_waiting` (updates are processed before workflow + code continues); truthful at validation time, and retryable. Verdict meaning and + authorship stay with the decision-endpoint and claim work. +- **Names.** Update `resolveNode`, input `{ nodeId, resolution }`. The verdict content + is opaque here: `resolution` is a `CompletedNodeExecution` passed to the parked node + untouched. Giving it a domain shape belongs to the decision-contract work. diff --git a/packages/temporal/src/workflow/index.ts b/packages/temporal/src/workflow/index.ts index 42da9790f..6c3a06c8e 100644 --- a/packages/temporal/src/workflow/index.ts +++ b/packages/temporal/src/workflow/index.ts @@ -7,8 +7,8 @@ // TypeScript SDK bundles workflow code from a single module, so a plugin cannot // register it on their behalf. -export { createRunWorkflow, runWorkflow } from './run-workflow'; -export type { RunWorkflowOptions } from './run-workflow'; +export { createRunWorkflow, resolveNodeUpdate, runWorkflow } from './run-workflow'; +export type { ResolveNodeUpdateInput, RunWorkflowOptions } from './run-workflow'; export { createSequencedEventEmitter } from './sequenced-event-emitter'; export type { EventPersistence } from './sequenced-event-emitter'; diff --git a/packages/temporal/src/workflow/run-workflow.ts b/packages/temporal/src/workflow/run-workflow.ts index 104a0ee03..2bace054a 100644 --- a/packages/temporal/src/workflow/run-workflow.ts +++ b/packages/temporal/src/workflow/run-workflow.ts @@ -5,13 +5,22 @@ // A plugin cannot register this itself: the TypeScript SDK builds the workflow // bundle from a single module, so the consumer re-exports it from their own // workflows file. See the package README. -import { ApplicationFailure, CancellationScope, isCancellation, proxyActivities } from '@temporalio/workflow'; +import { + ApplicationFailure, + CancellationScope, + condition, + defineUpdate, + isCancellation, + proxyActivities, + setHandler, +} from '@temporalio/workflow'; import type { Activities } from './activities-interface'; import { DEFAULT_DATABASE_ACTIVITY_PROFILE, type NodeActivityProfiles } from './activity-profiles'; import { type ActivityRunnerPort, type BaseNode, + type CompletedNodeExecution, type RunGraphOutcome, type WorkflowExecutionInput, runGraph, @@ -19,11 +28,21 @@ import { import { resolveFromValidatedProfiles } from './node-activity-options'; import { freezeNodeActivityProfiles } from './profile-validation'; import { createSequencedEventEmitter } from './sequenced-event-emitter'; +import { type NodeWaitState, validateVerdict } from './verdict-validation'; const databaseActivities = proxyActivities>( DEFAULT_DATABASE_ACTIVITY_PROFILE, ); +export type ResolveNodeUpdateInput = { + nodeId: string; + resolution: CompletedNodeExecution; +}; + +// Update-not-signal and the annotation shape: see durable-pause.decision-log.md. +export const resolveNodeUpdate: ReturnType> = + defineUpdate('resolveNode'); + export type RunWorkflowOptions = { nodeActivityProfiles?: NodeActivityProfiles; }; @@ -34,17 +53,41 @@ export type RunWorkflowOptions = { export function createRunWorkflow(options: RunWorkflowOptions = {}) { const profiles = freezeNodeActivityProfiles(options.nodeActivityProfiles ?? {}); - // Proxied per call, not once per module: the options depend on the node. - const runner: ActivityRunnerPort = { - executeNode: (node, context) => { - const nodeActivities = proxyActivities>( - resolveFromValidatedProfiles(node, profiles), - ); - return nodeActivities.executeNode(node, context); - }, - }; - return async function runWorkflow(input: WorkflowExecutionInput): Promise { + // Per-instance: must stay inside the workflow function (durable-pause.decision-log.md). + const waits = new Map(); + const knownNodes = new Set(input.definition.nodes.map((node) => node.id)); + + setHandler( + resolveNodeUpdate, + ({ nodeId, resolution }) => { + waits.set(nodeId, { status: 'resolved', resolution }); + }, + { validator: (verdict) => validateVerdict(verdict, knownNodes, waits) }, + ); + + const runner: ActivityRunnerPort = { + // Proxied per call, not once per module: the options depend on the node. + executeNode: (node, context) => { + const nodeActivities = proxyActivities>( + resolveFromValidatedProfiles(node, profiles), + ); + return nodeActivities.executeNode(node, context); + }, + awaitResolution: async (nodeId) => { + waits.set(nodeId, { status: 'waiting' }); + try { + await condition(() => waits.get(nodeId)?.status === 'resolved'); + // The condition above only unblocks on 'resolved'. + return (waits.get(nodeId) as Extract).resolution; + } finally { + if (waits.get(nodeId)?.status === 'waiting') { + waits.delete(nodeId); + } + } + }, + }; + return runGraphWith(runner, input); }; } diff --git a/packages/temporal/src/workflow/sequenced-event-emitter.test.ts b/packages/temporal/src/workflow/sequenced-event-emitter.test.ts index 7e9733d38..1849756ab 100644 --- a/packages/temporal/src/workflow/sequenced-event-emitter.test.ts +++ b/packages/temporal/src/workflow/sequenced-event-emitter.test.ts @@ -226,4 +226,27 @@ describe('createSequencedEventEmitter', () => { expect(second.recorded.map((entry) => entry.sequence)).toEqual(first.recorded.map((entry) => entry.sequence)); expect(second.recorded[0]?.sequence).toBe(1); }); + + it('chains status writes behind pending event writes', async () => { + const order: string[] = []; + const persistence: EventPersistence = { + async emitEvent(_executionId, sequence, type) { + // Slow emit: without chaining, the status write below would commit first. + for (let turn = 0; turn < 8; turn++) { + await Promise.resolve(); + } + order.push(`event:${type}:${sequence}`); + }, + async updateStatus(_executionId, status) { + order.push(`status:${status}`); + }, + }; + const events = createSequencedEventEmitter(persistence); + + const emit = events.emitEvent('exec-1', 'node_waiting', undefined, 'A'); + const status = events.updateStatus('exec-1', 'waiting'); + await Promise.all([emit, status]); + + expect(order).toEqual(['event:node_waiting:1', 'status:waiting']); + }); }); diff --git a/packages/temporal/src/workflow/sequenced-event-emitter.ts b/packages/temporal/src/workflow/sequenced-event-emitter.ts index 52bdaa93a..662d14e4d 100644 --- a/packages/temporal/src/workflow/sequenced-event-emitter.ts +++ b/packages/temporal/src/workflow/sequenced-event-emitter.ts @@ -53,8 +53,12 @@ export function createSequencedEventEmitter(persistence: EventPersistence): Even // and reaches the error policy. return write; }, + // Chained behind pending event writes: status lands in program order, so a + // 'waiting' from one gate can never race past a 'running' from another. updateStatus(executionId, status, errorMessage) { - return persistence.updateStatus(executionId, status, errorMessage); + const write = tail.then(() => persistence.updateStatus(executionId, status, errorMessage)); + tail = write.catch(() => {}); + return write; }, }; } diff --git a/packages/temporal/src/workflow/verdict-validation.test.ts b/packages/temporal/src/workflow/verdict-validation.test.ts new file mode 100644 index 000000000..207a443eb --- /dev/null +++ b/packages/temporal/src/workflow/verdict-validation.test.ts @@ -0,0 +1,79 @@ +import { defaultPayloadConverter } from '@temporalio/common'; +import { ApplicationFailure } from '@temporalio/workflow'; +import { describe, expect, it } from 'vitest'; + +import { type NodeWaitState, validateVerdict } from './verdict-validation'; + +const KNOWN_NODES = new Set(['start', 'gate', 'after']); +const GATE_WAITING = new Map([['gate', { status: 'waiting' }]]); + +function rejection(verdict?: unknown, waits: ReadonlyMap = GATE_WAITING): string | undefined { + try { + validateVerdict(verdict, KNOWN_NODES, waits); + return undefined; + } catch (error) { + expect(error).toBeInstanceOf(ApplicationFailure); + return (error as ApplicationFailure).type ?? undefined; + } +} + +describe('validateVerdict', () => { + it('accepts a well-formed verdict for a waiting node', () => { + expect(rejection({ nodeId: 'gate', resolution: { output: 'ok' } })).toBeUndefined(); + expect(rejection({ nodeId: 'gate', resolution: { output: null, nextPort: 'approved' } })).toBeUndefined(); + expect(rejection({ nodeId: 'gate', resolution: { output: undefined } })).toBeUndefined(); + }); + + it('accepts the envelope as the default payload converter delivers it, with output: undefined dropped', () => { + const sent = { nodeId: 'gate', resolution: { output: undefined, nextPort: 'approved' } }; + const delivered: unknown = defaultPayloadConverter.fromPayload(defaultPayloadConverter.toPayload(sent)); + + expect(delivered).toEqual({ nodeId: 'gate', resolution: { nextPort: 'approved' } }); + expect(rejection(delivered)).toBeUndefined(); + expect(rejection({ nodeId: 'gate', resolution: {} })).toBeUndefined(); + }); + + it('rejects a non-object input', () => { + expect(rejection()).toBe('verdict_malformed'); + expect(rejection(null)).toBe('verdict_malformed'); + expect(rejection('gate')).toBe('verdict_malformed'); + }); + + it('rejects a missing, empty or non-string nodeId', () => { + expect(rejection({ resolution: { output: 1 } })).toBe('verdict_malformed'); + expect(rejection({ nodeId: '', resolution: { output: 1 } })).toBe('verdict_malformed'); + expect(rejection({ nodeId: 7, resolution: { output: 1 } })).toBe('verdict_malformed'); + }); + + it('rejects a resolution that is missing, null, a primitive or an array', () => { + expect(rejection({ nodeId: 'gate' })).toBe('verdict_malformed'); + expect(rejection({ nodeId: 'gate', resolution: null })).toBe('verdict_malformed'); + expect(rejection({ nodeId: 'gate', resolution: 'approved' })).toBe('verdict_malformed'); + expect(rejection({ nodeId: 'gate', resolution: [] })).toBe('verdict_malformed'); + expect(rejection({ nodeId: 'gate', resolution: ['approved'] })).toBe('verdict_malformed'); + }); + + it('rejects envelope keys beyond output and nextPort', () => { + expect(rejection({ nodeId: 'gate', resolution: { output: 1, nexPort: 'typo' } })).toBe('verdict_malformed'); + expect(rejection({ nodeId: 'gate', resolution: { output: 1, waiting: true } })).toBe('verdict_malformed'); + }); + + it('rejects a nextPort that is empty, non-string or the reserved errorRoute', () => { + expect(rejection({ nodeId: 'gate', resolution: { output: 1, nextPort: '' } })).toBe('verdict_malformed'); + expect(rejection({ nodeId: 'gate', resolution: { output: 1, nextPort: 5 } })).toBe('verdict_malformed'); + expect(rejection({ nodeId: 'gate', resolution: { output: 1, nextPort: 'errorRoute' } })).toBe('verdict_malformed'); + }); + + it('rejects a node that is not in the definition', () => { + expect(rejection({ nodeId: 'ghost', resolution: { output: 1 } })).toBe('verdict_for_unknown_node'); + }); + + it('reads a resolved node as already delivered, not as no longer waiting', () => { + const waits = new Map([['gate', { status: 'resolved', resolution: { output: 'first' } }]]); + expect(rejection({ nodeId: 'gate', resolution: { output: 1 } }, waits)).toBe('verdict_already_delivered'); + }); + + it('rejects a known node that is not waiting', () => { + expect(rejection({ nodeId: 'after', resolution: { output: 1 } })).toBe('node_not_waiting'); + }); +}); diff --git a/packages/temporal/src/workflow/verdict-validation.ts b/packages/temporal/src/workflow/verdict-validation.ts new file mode 100644 index 000000000..59129c4ff --- /dev/null +++ b/packages/temporal/src/workflow/verdict-validation.ts @@ -0,0 +1,50 @@ +import { ApplicationFailure } from '@temporalio/workflow'; + +import type { CompletedNodeExecution } from './core-contract'; + +// A node's wait lifecycle; no entry means the node is not waiting. +export type NodeWaitState = { status: 'waiting' } | { status: 'resolved'; resolution: CompletedNodeExecution }; + +function malformed(message: string): ApplicationFailure { + return ApplicationFailure.nonRetryable(message, 'verdict_malformed'); +} + +// Runs before the update is accepted: a throw rejects it, writes nothing to history +// and cannot fail the workflow task. Engine integrity only (durable-pause.decision-log.md). +export function validateVerdict( + verdict: unknown, + knownNodes: ReadonlySet, + waits: ReadonlyMap, +): void { + if (typeof verdict !== 'object' || verdict === null) { + throw malformed('Update input must be a { nodeId, resolution } object'); + } + const { nodeId, resolution } = verdict as { nodeId?: unknown; resolution?: unknown }; + if (typeof nodeId !== 'string' || nodeId.length === 0) { + throw malformed('nodeId must be a non-empty string'); + } + // No `output` key is accepted as `output: undefined`: the default payload converter + // is JSON and drops undefined fields before the update reaches the workflow. + if (typeof resolution !== 'object' || resolution === null || Array.isArray(resolution)) { + throw malformed('resolution must be an object'); + } + for (const key of Object.keys(resolution)) { + if (key !== 'output' && key !== 'nextPort') { + throw malformed(`Unknown resolution key "${key}"`); + } + } + const { nextPort } = resolution as { nextPort?: unknown }; + if (nextPort !== undefined && (typeof nextPort !== 'string' || nextPort.length === 0 || nextPort === 'errorRoute')) { + throw malformed('nextPort must be a non-empty string other than the reserved errorRoute'); + } + if (!knownNodes.has(nodeId)) { + throw ApplicationFailure.nonRetryable(`No node "${nodeId}" in this run`, 'verdict_for_unknown_node'); + } + const state = waits.get(nodeId); + if (state?.status === 'resolved') { + throw ApplicationFailure.nonRetryable(`Node "${nodeId}" already has a verdict`, 'verdict_already_delivered'); + } + if (state === undefined) { + throw ApplicationFailure.nonRetryable(`Node "${nodeId}" is not waiting for a verdict`, 'node_not_waiting'); + } +} diff --git a/packages/temporal/test/api-surface.test.ts b/packages/temporal/test/api-surface.test.ts index 69c1ab2ef..d5c0634d9 100644 --- a/packages/temporal/test/api-surface.test.ts +++ b/packages/temporal/test/api-surface.test.ts @@ -39,6 +39,7 @@ describe('public API surface', () => { 'createRunWorkflow', 'createSequencedEventEmitter', 'resolveNodeActivityOptions', + 'resolveNodeUpdate', 'runWorkflow', ]); }); diff --git a/packages/temporal/test/core-contract.test.ts b/packages/temporal/test/core-contract.test.ts index 648d4fccf..abb06404a 100644 --- a/packages/temporal/test/core-contract.test.ts +++ b/packages/temporal/test/core-contract.test.ts @@ -6,6 +6,7 @@ // compile. import { describe, expect, it } from 'vitest'; +import type { EventEmitterPort as CoreEventEmitterPort } from '../../execution-core/src/ports/event-emitter.port'; import type { WorkflowEnginePort as CoreWorkflowEnginePort, WorkflowExecutionInput as CoreWorkflowExecutionInput, @@ -21,6 +22,7 @@ import type { WorkflowEnginePort, WorkflowExecutionInput, } from '../src/core-contract'; +import type { EventEmitterPort } from '../src/workflow/core-contract'; type MutuallyAssignable = [A] extends [B] ? ([B] extends [A] ? true : never) : never; @@ -40,11 +42,15 @@ const registryMatchesCore: MutuallyAssignable< CoreNodeExecutorRegistry > = true; +const emitterMatchesCore: MutuallyAssignable = true; + describe('published contract vs execution-core', () => { it('states the same input, engine port and executor registry as the core', () => { // The real assertions are the declarations above: if any type drifts, this file // stops compiling and `pnpm typecheck` fails. The runtime check just keeps the // constants referenced. - expect(inputMatchesCore && portMatchesCore && executorMatchesCore && registryMatchesCore).toBe(true); + expect( + inputMatchesCore && portMatchesCore && executorMatchesCore && registryMatchesCore && emitterMatchesCore, + ).toBe(true); }); }); diff --git a/packages/temporal/test/durable-pause.test.ts b/packages/temporal/test/durable-pause.test.ts new file mode 100644 index 000000000..771279fa8 --- /dev/null +++ b/packages/temporal/test/durable-pause.test.ts @@ -0,0 +1,243 @@ +// The task's verify-by on the harness: a run parks at a gate, survives a worker +// restart, and a resolveNode update resumes it with downstream running exactly once. +import { CancelledFailure, WorkflowFailedError, WorkflowUpdateFailedError } from '@temporalio/client'; +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { Worker, bundleWorkflowCode } from '@temporalio/worker'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + RUN_WORKFLOW_NAME, + WorkflowBuilderPlugin, + type WorkflowExecutionInput, + executionWorkflowId, +} from '../src/index'; +import { resolveNodeUpdate } from '../src/workflow/index'; +import { type RecordingStore, createRecordingStore } from './fixtures/graph'; +import { acceptedUpdateIds, executeVerdictWithRetry, waitUntil } from './fixtures/helpers'; +import { + PORT_ROUTED_GRAPH, + type PauseHarness, + type PauseTestNode, + SINGLE_GATE_GRAPH, + TWO_GATES_GRAPH, + createPauseExecutors, +} from './fixtures/pause-graph'; + +function eventTypes(store: RecordingStore, nodeId?: string): string[] { + return store.events.filter((event) => nodeId === undefined || event.nodeId === nodeId).map((event) => event.type); +} + +async function expectRejected(update: Promise, code: string): Promise { + const outcome: unknown = await update.then( + () => 'unexpectedly accepted', + (error: unknown) => error, + ); + expect(outcome).toBeInstanceOf(WorkflowUpdateFailedError); + expect((outcome as WorkflowUpdateFailedError).cause).toMatchObject({ type: code }); +} + +describe('durable pause', () => { + let env: TestWorkflowEnvironment; + let workflowBundle: { code: string }; + + beforeAll(async () => { + [workflowBundle, env] = await Promise.all([ + bundleWorkflowCode({ workflowsPath: fileURLToPath(new URL('fixtures/workflows.ts', import.meta.url)) }), + TestWorkflowEnvironment.createLocal(), + ]); + }, 300_000); + + afterAll(async () => { + await env?.teardown(); + }); + + function createWorker(taskQueue: string, store: RecordingStore, harness: PauseHarness): Promise { + const plugin = new WorkflowBuilderPlugin({ store, executors: harness.executors, taskQueue }); + return Worker.create({ + connection: env.nativeConnection, + namespace: env.namespace, + taskQueue: plugin.taskQueue, + workflowBundle, + plugins: [plugin], + }); + } + + function startRun(taskQueue: string, executionId: string, definition: typeof SINGLE_GATE_GRAPH) { + const input: WorkflowExecutionInput = { + workflowId: definition.workflowId, + executionId, + definition, + triggerPayload: {}, + variables: {}, + global: {}, + }; + return env.client.workflow.start(RUN_WORKFLOW_NAME, { + taskQueue, + workflowId: executionWorkflowId(executionId), + args: [input], + }); + } + + it('survives a worker restart while parked; the verdict resumes it and downstream runs exactly once', async () => { + const taskQueue = 'pause-restart'; + const store = createRecordingStore(); + const harness = createPauseExecutors(); + const handle = await startRun(taskQueue, 'pause-restart-execution', SINGLE_GATE_GRAPH); + + const worker1 = await createWorker(taskQueue, store, harness); + await worker1.runUntil( + waitUntil(() => store.statuses.some((entry) => entry.status === 'waiting'), 'the waiting status'), + ); + + // Worker 1 is gone; the run is parked in Event History, visible only as state. + expect(harness.executed).toEqual(['start', 'gate']); + expect(store.statuses).toEqual([{ status: 'waiting', errorMessage: undefined }]); + + const worker2 = await createWorker(taskQueue, store, harness); + await worker2.runUntil(async () => { + await executeVerdictWithRetry(() => + handle.executeUpdate(resolveNodeUpdate, { args: [{ nodeId: 'gate', resolution: { output: 'approved' } }] }), + ); + await handle.result(); + }); + + // Replay on worker 2 reconstructed the pause without re-running any activity. + expect(harness.executed).toEqual(['start', 'gate', 'after']); + expect(harness.inputsSeen.after.gate).toBe('approved'); + expect(eventTypes(store, 'gate')).toEqual(['node_started', 'node_waiting', 'node_completed']); + expect(store.events.at(-1)?.type).toBe('execution_completed'); + expect(store.statuses.map((entry) => entry.status)).toEqual(['waiting', 'running', 'completed']); + }, 120_000); + + it('two gates park concurrently, take verdicts independently, and a duplicate verdict is rejected', async () => { + const taskQueue = 'pause-two-gates'; + const store = createRecordingStore(); + const harness = createPauseExecutors(); + const handle = await startRun(taskQueue, 'pause-two-gates-execution', TWO_GATES_GRAPH); + + const worker = await createWorker(taskQueue, store, harness); + await worker.runUntil(async () => { + await waitUntil( + () => + eventTypes(store).filter((type) => type === 'node_waiting').length === 2 && + store.statuses.some((entry) => entry.status === 'waiting'), + 'both gates to park', + ); + + await executeVerdictWithRetry(() => + handle.executeUpdate(resolveNodeUpdate, { args: [{ nodeId: 'gate-a', resolution: { output: 'first' } }] }), + ); + const rejection: unknown = await handle + .executeUpdate(resolveNodeUpdate, { args: [{ nodeId: 'gate-a', resolution: { output: 'second' } }] }) + .catch((error: unknown) => error); + expect(rejection).toBeInstanceOf(WorkflowUpdateFailedError); + expect((rejection as WorkflowUpdateFailedError).cause).toMatchObject({ type: 'verdict_already_delivered' }); + await executeVerdictWithRetry(() => + handle.executeUpdate(resolveNodeUpdate, { args: [{ nodeId: 'gate-b', resolution: { output: 'b-verdict' } }] }), + ); + await handle.result(); + }); + + expect(harness.inputsSeen.join['gate-a']).toBe('first'); + expect(harness.inputsSeen.join['gate-b']).toBe('b-verdict'); + expect(harness.executed.filter((id) => id === 'join')).toHaveLength(1); + expect(store.statuses.map((entry) => entry.status)).toEqual(['waiting', 'running', 'completed']); + }, 120_000); + + it('rejects malformed and misaddressed verdicts before acceptance; the parked run stays resolvable', async () => { + const taskQueue = 'pause-validation'; + const store = createRecordingStore(); + const harness = createPauseExecutors(); + const handle = await startRun(taskQueue, 'pause-validation-execution', SINGLE_GATE_GRAPH); + + const worker = await createWorker(taskQueue, store, harness); + await worker.runUntil(async () => { + await waitUntil(() => store.statuses.some((entry) => entry.status === 'waiting'), 'the waiting status'); + + // Rejection classes live in verdict-validation.test.ts; this pins the + // end-to-end property: a rejected update leaves the parked run resolvable. + await expectRejected( + handle.executeUpdate('resolveNode', { args: [], updateId: 'malformed-verdict' }), + 'verdict_malformed', + ); + await expectRejected( + handle.executeUpdate('resolveNode', { + args: [{ nodeId: 'ghost', resolution: { output: 1 } }], + updateId: 'verdict-for-ghost', + }), + 'verdict_for_unknown_node', + ); + + await executeVerdictWithRetry(() => + handle.executeUpdate(resolveNodeUpdate, { args: [{ nodeId: 'gate', resolution: { output: 'approved' } }] }), + ); + await handle.result(); + }); + + expect(harness.executed).toEqual(['start', 'gate', 'after']); + expect(store.statuses.map((entry) => entry.status)).toEqual(['waiting', 'running', 'completed']); + + // The same error class would also come back from an accepted handler that threw + // later; only history shows the rejections happened before acceptance. + const accepted = acceptedUpdateIds(await handle.fetchHistory()); + expect(accepted).toHaveLength(1); + expect(accepted).not.toContain('malformed-verdict'); + expect(accepted).not.toContain('verdict-for-ghost'); + }, 120_000); + + it('a verdict with output: undefined resumes the node, even though the payload converter drops the field', async () => { + const taskQueue = 'pause-undefined-output'; + const store = createRecordingStore(); + const harness = createPauseExecutors(); + const handle = await startRun(taskQueue, 'pause-undefined-output-execution', PORT_ROUTED_GRAPH); + + const worker = await createWorker(taskQueue, store, harness); + await worker.runUntil(async () => { + await waitUntil(() => store.statuses.some((entry) => entry.status === 'waiting'), 'the waiting status'); + await executeVerdictWithRetry(() => + handle.executeUpdate(resolveNodeUpdate, { + args: [{ nodeId: 'gate', resolution: { output: undefined, nextPort: 'approved' } }], + }), + ); + await handle.result(); + }); + + expect(harness.executed).toEqual(['start', 'gate', 'after']); + // The activity context crosses the same converter, so downstream sees no `gate` key at all. + expect(harness.inputsSeen.after).toEqual({ start: { visited: 'start' } }); + expect(eventTypes(store, 'gate')).toEqual(['node_started', 'node_waiting', 'node_completed']); + expect(store.statuses.map((entry) => entry.status)).toEqual(['waiting', 'running', 'completed']); + }, 120_000); + + it('cancel while waiting closes the run as cancelled, with no node_failed for the gate', async () => { + const taskQueue = 'pause-cancel'; + const store = createRecordingStore(); + const harness = createPauseExecutors(); + const handle = await startRun(taskQueue, 'pause-cancel-execution', SINGLE_GATE_GRAPH); + + const worker = await createWorker(taskQueue, store, harness); + await worker.runUntil(async () => { + // The 'waiting' status lands after node_waiting; cancelling earlier would + // cancel that activity before it runs and make the trail racy. + await waitUntil(() => store.statuses.some((entry) => entry.status === 'waiting'), 'the waiting status'); + await handle.cancel(); + // WorkflowFailedError wraps a plain failure too; the cause is what says Cancelled. + const failure: unknown = await handle.result().then( + () => 'unexpectedly completed', + (error: unknown) => error, + ); + expect(failure).toBeInstanceOf(WorkflowFailedError); + expect((failure as WorkflowFailedError).cause).toBeInstanceOf(CancelledFailure); + }); + + const history = await handle.fetchHistory(); + expect(history.events?.at(-1)?.workflowExecutionCanceledEventAttributes).toBeDefined(); + + const types = eventTypes(store); + expect(types.at(-1)).toBe('execution_cancelled'); + expect(types.indexOf('node_waiting')).toBeLessThan(types.indexOf('execution_cancelled')); + expect(types).not.toContain('node_failed'); + expect(store.statuses.map((entry) => entry.status)).toEqual(['waiting', 'cancelled']); + }, 120_000); +}); diff --git a/packages/temporal/test/fixtures/helpers.ts b/packages/temporal/test/fixtures/helpers.ts new file mode 100644 index 000000000..cc8630108 --- /dev/null +++ b/packages/temporal/test/fixtures/helpers.ts @@ -0,0 +1,49 @@ +import { WorkflowUpdateFailedError } from '@temporalio/client'; +import type { History } from '@temporalio/common/lib/proto-utils'; + +export async function waitUntil(check: () => boolean, what: string, timeoutMs = 30_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!check()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +// node_not_waiting is retryable by contract: a verdict can race the parking +// activation (see the decision log). Tests deliver verdicts the way callers should. +export async function executeVerdictWithRetry(send: () => Promise): Promise { + for (let attempt = 1; ; attempt += 1) { + try { + await send(); + return; + } catch (error) { + const racingPark = + error instanceof WorkflowUpdateFailedError && + (error.cause as { type?: string } | undefined)?.type === 'node_not_waiting'; + if (!racingPark || attempt >= 40) throw error; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } +} + +// A rejected update writes nothing to history, so this lists exactly the updates that +// got past the validator. +export function acceptedUpdateIds(history: History): string[] { + return (history.events ?? []).flatMap((event) => { + const id = event.workflowExecutionUpdateAcceptedEventAttributes?.protocolInstanceId; + return typeof id === 'string' ? [id] : []; + }); +} + +export function countScheduledActivities(history: History): Record { + const counts: Record = {}; + + for (const event of history.events ?? []) { + const name = event.activityTaskScheduledEventAttributes?.activityType?.name; + if (name) { + counts[name] = (counts[name] ?? 0) + 1; + } + } + + return counts; +} diff --git a/packages/temporal/test/fixtures/pause-graph.ts b/packages/temporal/test/fixtures/pause-graph.ts new file mode 100644 index 000000000..d708af283 --- /dev/null +++ b/packages/temporal/test/fixtures/pause-graph.ts @@ -0,0 +1,81 @@ +// Gate fixtures for the durable-pause tests. Executors record every invocation so +// the restart scenario can assert "downstream runs exactly once" across workers. +import type { BaseNode, NodeExecutorRegistry, WorkflowDefinition } from '../../src/index'; + +export type PauseTestNode = (BaseNode & { type: 'test/step' }) | (BaseNode & { type: 'test/gate' }); + +// start ─▶ gate ─▶ after +export const SINGLE_GATE_GRAPH: WorkflowDefinition = { + workflowId: 'pause-test-workflow', + nodes: [ + { id: 'start', type: 'test/step', role: 'start', config: {} }, + { id: 'gate', type: 'test/gate', config: {} }, + { id: 'after', type: 'test/step', config: {} }, + ], + edges: [ + { id: 'e-start-gate', sourceNodeId: 'start', targetNodeId: 'gate' }, + { id: 'e-gate-after', sourceNodeId: 'gate', targetNodeId: 'after' }, + ], +}; + +// start ─▶ gate ─(approved)─▶ after +// +// The one edge is port-tagged, so a verdict has to name `nextPort: 'approved'` to reach +// `after`; the untagged SINGLE_GATE_GRAPH would route there on any verdict. +export const PORT_ROUTED_GRAPH: WorkflowDefinition = { + workflowId: 'port-routed-workflow', + nodes: [ + { id: 'start', type: 'test/step', role: 'start', config: {} }, + { id: 'gate', type: 'test/gate', config: {} }, + { id: 'after', type: 'test/step', config: {} }, + ], + edges: [ + { id: 'e-start-gate', sourceNodeId: 'start', targetNodeId: 'gate' }, + { id: 'e-gate-after', sourceNodeId: 'gate', targetNodeId: 'after', sourceHandle: 'approved' }, + ], +}; + +// start ─┬─▶ gate-a ──┬─▶ join +// └─▶ gate-b ──┘ +export const TWO_GATES_GRAPH: WorkflowDefinition = { + workflowId: 'two-gates-workflow', + nodes: [ + { id: 'start', type: 'test/step', role: 'start', config: {} }, + { id: 'gate-a', type: 'test/gate', config: {} }, + { id: 'gate-b', type: 'test/gate', config: {} }, + { id: 'join', type: 'test/step', config: {} }, + ], + edges: [ + { id: 'e-start-a', sourceNodeId: 'start', targetNodeId: 'gate-a' }, + { id: 'e-start-b', sourceNodeId: 'start', targetNodeId: 'gate-b' }, + { id: 'e-a-join', sourceNodeId: 'gate-a', targetNodeId: 'join' }, + { id: 'e-b-join', sourceNodeId: 'gate-b', targetNodeId: 'join' }, + ], +}; + +export type PauseHarness = { + executors: NodeExecutorRegistry; + executed: string[]; + inputsSeen: Record>; +}; + +export function createPauseExecutors(): PauseHarness { + const executed: string[] = []; + const inputsSeen: PauseHarness['inputsSeen'] = {}; + + return { + executed, + inputsSeen, + executors: { + 'test/step': (node, context) => { + executed.push(node.id); + inputsSeen[node.id] = { ...context.nodeOutputs }; + return { output: { visited: node.id } }; + }, + 'test/gate': (node) => { + executed.push(node.id); + return { waiting: true }; + }, + }, + }; +} diff --git a/packages/temporal/test/replay/README.md b/packages/temporal/test/replay/README.md index 14fcac91a..11a1b75b8 100644 --- a/packages/temporal/test/replay/README.md +++ b/packages/temporal/test/replay/README.md @@ -23,12 +23,21 @@ Only (3) survives a change to the runner, which is why (3) is the one that matte review time. (2) passes even on a broken change, because the history it checks was recorded by the same broken code. -## The graph the harness runs - -`start → (left, right) → join`, defined in `../fixtures/graph.ts`. The fan-out is the -point: it is the only shape that puts two commands in a single workflow task, which is -where the runner's `Promise.all` becomes visible to Temporal. A straight line replays -green while leaving that path untested. +## The graphs the harnesses run + +`replay.test.ts`: `start → (left, right) → join`, defined in `../fixtures/graph.ts`. The +fan-out is the point: it is the only shape that puts two commands in a single workflow +task, which is where the runner's `Promise.all` becomes visible to Temporal. A straight +line replays green while leaving that path untested. + +`parked-gate-replay.test.ts`: `start → gate → after`, defined in +`../fixtures/pause-graph.ts`, run to completion through a real `resolveNode` update. Its +committed history (`v0-parked-gate.json`) is the durable-pause pin: it carries the +accepted update, the `node_waiting` emit, the waiting/running status activities and the +resume, so a change that moves any command on the parked path fails here even when the +gateless baseline stays green. Since a determinism break surfaces at the first divergent +command, replaying the full history also stands in for every run still parked mid-history +when a deploy lands. ## Recording a history @@ -38,6 +47,10 @@ From the harness, which is what the committed files come from: UPDATE_REPLAY_HISTORIES=1 pnpm --filter @workflowbuilder/temporal test ``` +The flag rewrites every committed history at once. When adding one scenario, scope the +run to that harness file (`npx vitest run test/replay/`) so the other baselines +keep guarding the code that recorded them. + Or from a real run against a local stack, which is worth doing for a scenario the harness cannot stage. `historyToJSON` writes the same shape, so the two are interchangeable: @@ -45,8 +58,8 @@ cannot stage. `historyToJSON` writes the same shape, so the two are interchangea temporal workflow show --workflow-id execution- --output json > histories/-.json ``` -Scenarios still worth adding, one file each: a cancellation mid-run, and a node failure -that goes through the error policy. +Scenarios still worth adding, one file each: a cancellation mid-run, a node failure that +goes through the error policy, and two gates parked in one wave. ## Rules once files live here diff --git a/packages/temporal/test/replay/histories/v0-parked-gate.json b/packages/temporal/test/replay/histories/v0-parked-gate.json new file mode 100644 index 000000000..106c8d432 --- /dev/null +++ b/packages/temporal/test/replay/histories/v0-parked-gate.json @@ -0,0 +1,2275 @@ +{ + "events": [ + { + "eventId": "1", + "eventTime": "2026-09-04T18:20:30.960510Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_STARTED", + "taskId": "1048587", + "workflowExecutionStartedEventAttributes": { + "workflowType": { + "name": "runWorkflow" + }, + "taskQueue": { + "name": "parked-gate-replay", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJ3b3JrZmxvd0lkIjoicGF1c2UtdGVzdC13b3JrZmxvdyIsImV4ZWN1dGlvbklkIjoicGFya2VkLWdhdGUtcmVwbGF5LWV4ZWN1dGlvbiIsImRlZmluaXRpb24iOnsid29ya2Zsb3dJZCI6InBhdXNlLXRlc3Qtd29ya2Zsb3ciLCJub2RlcyI6W3siaWQiOiJzdGFydCIsInR5cGUiOiJ0ZXN0L3N0ZXAiLCJyb2xlIjoic3RhcnQiLCJjb25maWciOnt9fSx7ImlkIjoiZ2F0ZSIsInR5cGUiOiJ0ZXN0L2dhdGUiLCJjb25maWciOnt9fSx7ImlkIjoiYWZ0ZXIiLCJ0eXBlIjoidGVzdC9zdGVwIiwiY29uZmlnIjp7fX1dLCJlZGdlcyI6W3siaWQiOiJlLXN0YXJ0LWdhdGUiLCJzb3VyY2VOb2RlSWQiOiJzdGFydCIsInRhcmdldE5vZGVJZCI6ImdhdGUifSx7ImlkIjoiZS1nYXRlLWFmdGVyIiwic291cmNlTm9kZUlkIjoiZ2F0ZSIsInRhcmdldE5vZGVJZCI6ImFmdGVyIn1dfSwidHJpZ2dlclBheWxvYWQiOnt9LCJ2YXJpYWJsZXMiOnt9LCJnbG9iYWwiOnt9fQ==" + } + ] + }, + "workflowTaskTimeout": "10s", + "originalExecutionRunId": "01a06da6-9d70-77c8-8c95-b72d8746228b", + "identity": "47023@MacBook-Air.local", + "firstExecutionRunId": "01a06da6-9d70-77c8-8c95-b72d8746228b", + "attempt": 1, + "firstWorkflowTaskBackoff": "0s", + "header": {}, + "workflowId": "execution-parked-gate-replay-execution" + } + }, + { + "eventId": "2", + "eventTime": "2026-09-04T18:20:30.960560Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048588", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "parked-gate-replay", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "3", + "eventTime": "2026-09-04T18:20:30.969133Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048593", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "2", + "identity": "47023@MacBook-Air.local", + "requestId": "6d0ade9b-1472-4f99-a321-7913fc6b1cc6", + "historySizeBytes": "796", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "4", + "eventTime": "2026-09-04T18:20:30.999401Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048597", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "2", + "startedEventId": "3", + "identity": "47023@MacBook-Air.local", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + }, + "sdkMetadata": { + "coreUsedFlags": [ + 3, + 1, + 2 + ], + "sdkName": "temporal-typescript", + "sdkVersion": "1.23.0" + }, + "meteringMetadata": {} + } + }, + { + "eventId": "5", + "eventTime": "2026-09-04T18:20:30.999497Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1048598", + "activityTaskScheduledEventAttributes": { + "activityId": "1", + "activityType": { + "name": "emitEvent" + }, + "taskQueue": { + "name": "parked-gate-replay", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": {}, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InBhcmtlZC1nYXRlLXJlcGxheS1leGVjdXRpb24i" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "MQ==" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "ImV4ZWN1dGlvbl9zdGFydGVkIg==" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJ3b3JrZmxvd0lkIjoicGF1c2UtdGVzdC13b3JrZmxvdyJ9" + }, + { + "metadata": { + "encoding": "YmluYXJ5L251bGw=" + } + } + ] + }, + "scheduleToCloseTimeout": "0s", + "scheduleToStartTimeout": "0s", + "startToCloseTimeout": "30s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "4", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2, + "maximumInterval": "100s", + "maximumAttempts": 5 + }, + "useWorkflowBuildId": true + } + }, + { + "eventId": "6", + "eventTime": "2026-09-04T18:20:31.000546Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1048604", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "5", + "identity": "47023@MacBook-Air.local", + "requestId": "0fb6e253-f183-4f09-8689-539b7fa2487e", + "attempt": 1, + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "7", + "eventTime": "2026-09-04T18:20:31.006273Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1048605", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "YmluYXJ5L251bGw=" + } + } + ] + }, + "scheduledEventId": "5", + "startedEventId": "6", + "identity": "47023@MacBook-Air.local" + } + }, + { + "eventId": "8", + "eventTime": "2026-09-04T18:20:31.006279Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048606", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "47023@MacBook-Air.local-bac68cf45b314286afd50870852f3885", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "parked-gate-replay" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "9", + "eventTime": "2026-09-04T18:20:31.007113Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048610", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "8", + "identity": "47023@MacBook-Air.local", + "requestId": "c9d76ce9-59d9-497f-93b6-c145b15eaef9", + "historySizeBytes": "1893", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "10", + "eventTime": "2026-09-04T18:20:31.010857Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048614", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "8", + "startedEventId": "9", + "identity": "47023@MacBook-Air.local", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "11", + "eventTime": "2026-09-04T18:20:31.010876Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1048615", + "activityTaskScheduledEventAttributes": { + "activityId": "2", + "activityType": { + "name": "emitEvent" + }, + "taskQueue": { + "name": "parked-gate-replay", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": {}, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InBhcmtlZC1nYXRlLXJlcGxheS1leGVjdXRpb24i" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "Mg==" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "Im5vZGVfc3RhcnRlZCI=" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJjb25maWciOnt9LCJ2aXNpYmxlTm9kZUlkcyI6W119" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InN0YXJ0Ig==" + } + ] + }, + "scheduleToCloseTimeout": "0s", + "scheduleToStartTimeout": "0s", + "startToCloseTimeout": "30s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "10", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2, + "maximumInterval": "100s", + "maximumAttempts": 5 + }, + "useWorkflowBuildId": true + } + }, + { + "eventId": "12", + "eventTime": "2026-09-04T18:20:31.011437Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1048620", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "11", + "identity": "47023@MacBook-Air.local", + "requestId": "9d8fc3bc-7225-4152-b85b-ea28c28c77e7", + "attempt": 1, + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "13", + "eventTime": "2026-09-04T18:20:31.012763Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1048621", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "YmluYXJ5L251bGw=" + } + } + ] + }, + "scheduledEventId": "11", + "startedEventId": "12", + "identity": "47023@MacBook-Air.local" + } + }, + { + "eventId": "14", + "eventTime": "2026-09-04T18:20:31.012767Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048622", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "47023@MacBook-Air.local-bac68cf45b314286afd50870852f3885", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "parked-gate-replay" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "15", + "eventTime": "2026-09-04T18:20:31.013357Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048626", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "14", + "identity": "47023@MacBook-Air.local", + "requestId": "bbbfa7ca-2f85-4891-99ea-d7fa2b4bef18", + "historySizeBytes": "2953", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "16", + "eventTime": "2026-09-04T18:20:31.015333Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048630", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "14", + "startedEventId": "15", + "identity": "47023@MacBook-Air.local", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "17", + "eventTime": "2026-09-04T18:20:31.015358Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1048631", + "activityTaskScheduledEventAttributes": { + "activityId": "3", + "activityType": { + "name": "executeNode" + }, + "taskQueue": { + "name": "parked-gate-replay", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": {}, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJpZCI6InN0YXJ0IiwidHlwZSI6InRlc3Qvc3RlcCIsInJvbGUiOiJzdGFydCIsImNvbmZpZyI6e319" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJ3b3JrZmxvd0lkIjoicGF1c2UtdGVzdC13b3JrZmxvdyIsImV4ZWN1dGlvbklkIjoicGFya2VkLWdhdGUtcmVwbGF5LWV4ZWN1dGlvbiIsInRyaWdnZXJQYXlsb2FkIjp7fSwibm9kZU91dHB1dHMiOnt9LCJ2YXJpYWJsZXMiOnt9LCJnbG9iYWwiOnt9fQ==" + } + ] + }, + "scheduleToCloseTimeout": "0s", + "scheduleToStartTimeout": "0s", + "startToCloseTimeout": "600s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "16", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2, + "maximumInterval": "100s", + "maximumAttempts": 2 + }, + "useWorkflowBuildId": true + } + }, + { + "eventId": "18", + "eventTime": "2026-09-04T18:20:31.016019Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1048636", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "17", + "identity": "47023@MacBook-Air.local", + "requestId": "7886c9d6-6e23-434f-bb5c-0fcbc76fd5ba", + "attempt": 1, + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "19", + "eventTime": "2026-09-04T18:20:31.017410Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1048637", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJvdXRwdXQiOnsidmlzaXRlZCI6InN0YXJ0In19" + } + ] + }, + "scheduledEventId": "17", + "startedEventId": "18", + "identity": "47023@MacBook-Air.local" + } + }, + { + "eventId": "20", + "eventTime": "2026-09-04T18:20:31.017413Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048638", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "47023@MacBook-Air.local-bac68cf45b314286afd50870852f3885", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "parked-gate-replay" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "21", + "eventTime": "2026-09-04T18:20:31.017825Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048642", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "20", + "identity": "47023@MacBook-Air.local", + "requestId": "8e900a25-2194-4dd7-9222-35b57f977d23", + "historySizeBytes": "4085", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "22", + "eventTime": "2026-09-04T18:20:31.019654Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048646", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "20", + "startedEventId": "21", + "identity": "47023@MacBook-Air.local", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "23", + "eventTime": "2026-09-04T18:20:31.019670Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1048647", + "activityTaskScheduledEventAttributes": { + "activityId": "4", + "activityType": { + "name": "emitEvent" + }, + "taskQueue": { + "name": "parked-gate-replay", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": {}, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InBhcmtlZC1nYXRlLXJlcGxheS1leGVjdXRpb24i" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "Mw==" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "Im5vZGVfY29tcGxldGVkIg==" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJvdXRwdXQiOnsidmlzaXRlZCI6InN0YXJ0In19" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InN0YXJ0Ig==" + } + ] + }, + "scheduleToCloseTimeout": "0s", + "scheduleToStartTimeout": "0s", + "startToCloseTimeout": "30s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "22", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2, + "maximumInterval": "100s", + "maximumAttempts": 5 + }, + "useWorkflowBuildId": true + } + }, + { + "eventId": "24", + "eventTime": "2026-09-04T18:20:31.020092Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1048652", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "23", + "identity": "47023@MacBook-Air.local", + "requestId": "351c237b-d67f-4e13-a874-6bcfb114b9d3", + "attempt": 1, + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "25", + "eventTime": "2026-09-04T18:20:31.021212Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1048653", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "YmluYXJ5L251bGw=" + } + } + ] + }, + "scheduledEventId": "23", + "startedEventId": "24", + "identity": "47023@MacBook-Air.local" + } + }, + { + "eventId": "26", + "eventTime": "2026-09-04T18:20:31.021214Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048654", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "47023@MacBook-Air.local-bac68cf45b314286afd50870852f3885", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "parked-gate-replay" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "27", + "eventTime": "2026-09-04T18:20:31.021827Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048658", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "26", + "identity": "47023@MacBook-Air.local", + "requestId": "d2b8678e-2ced-4bee-b135-0bb10fd34bdf", + "historySizeBytes": "5144", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "28", + "eventTime": "2026-09-04T18:20:31.024279Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048662", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "26", + "startedEventId": "27", + "identity": "47023@MacBook-Air.local", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "29", + "eventTime": "2026-09-04T18:20:31.024295Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1048663", + "activityTaskScheduledEventAttributes": { + "activityId": "5", + "activityType": { + "name": "emitEvent" + }, + "taskQueue": { + "name": "parked-gate-replay", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": {}, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InBhcmtlZC1nYXRlLXJlcGxheS1leGVjdXRpb24i" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "NA==" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "Im5vZGVfc3RhcnRlZCI=" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJjb25maWciOnt9LCJ2aXNpYmxlTm9kZUlkcyI6WyJzdGFydCJdfQ==" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "ImdhdGUi" + } + ] + }, + "scheduleToCloseTimeout": "0s", + "scheduleToStartTimeout": "0s", + "startToCloseTimeout": "30s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "28", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2, + "maximumInterval": "100s", + "maximumAttempts": 5 + }, + "useWorkflowBuildId": true + } + }, + { + "eventId": "30", + "eventTime": "2026-09-04T18:20:31.024934Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1048668", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "29", + "identity": "47023@MacBook-Air.local", + "requestId": "c59eeb7a-8d71-4ddf-a6f6-bdedd43bf896", + "attempt": 1, + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "31", + "eventTime": "2026-09-04T18:20:31.026374Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1048669", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "YmluYXJ5L251bGw=" + } + } + ] + }, + "scheduledEventId": "29", + "startedEventId": "30", + "identity": "47023@MacBook-Air.local" + } + }, + { + "eventId": "32", + "eventTime": "2026-09-04T18:20:31.026378Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048670", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "47023@MacBook-Air.local-bac68cf45b314286afd50870852f3885", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "parked-gate-replay" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "33", + "eventTime": "2026-09-04T18:20:31.027003Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048674", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "32", + "identity": "47023@MacBook-Air.local", + "requestId": "f7578448-2fc2-41fe-a75f-73fdb21d71a6", + "historySizeBytes": "6210", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "34", + "eventTime": "2026-09-04T18:20:31.029321Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048678", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "32", + "startedEventId": "33", + "identity": "47023@MacBook-Air.local", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "35", + "eventTime": "2026-09-04T18:20:31.029339Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1048679", + "activityTaskScheduledEventAttributes": { + "activityId": "6", + "activityType": { + "name": "executeNode" + }, + "taskQueue": { + "name": "parked-gate-replay", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": {}, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJpZCI6ImdhdGUiLCJ0eXBlIjoidGVzdC9nYXRlIiwiY29uZmlnIjp7fX0=" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJ3b3JrZmxvd0lkIjoicGF1c2UtdGVzdC13b3JrZmxvdyIsImV4ZWN1dGlvbklkIjoicGFya2VkLWdhdGUtcmVwbGF5LWV4ZWN1dGlvbiIsInRyaWdnZXJQYXlsb2FkIjp7fSwibm9kZU91dHB1dHMiOnsic3RhcnQiOnsidmlzaXRlZCI6InN0YXJ0In19LCJ2YXJpYWJsZXMiOnt9LCJnbG9iYWwiOnt9fQ==" + } + ] + }, + "scheduleToCloseTimeout": "0s", + "scheduleToStartTimeout": "0s", + "startToCloseTimeout": "600s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "34", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2, + "maximumInterval": "100s", + "maximumAttempts": 2 + }, + "useWorkflowBuildId": true + } + }, + { + "eventId": "36", + "eventTime": "2026-09-04T18:20:31.029966Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1048684", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "35", + "identity": "47023@MacBook-Air.local", + "requestId": "ec31001f-a8c1-4c6b-b313-15e367892d17", + "attempt": 1, + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "37", + "eventTime": "2026-09-04T18:20:31.031059Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1048685", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJ3YWl0aW5nIjp0cnVlfQ==" + } + ] + }, + "scheduledEventId": "35", + "startedEventId": "36", + "identity": "47023@MacBook-Air.local" + } + }, + { + "eventId": "38", + "eventTime": "2026-09-04T18:20:31.031063Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048686", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "47023@MacBook-Air.local-bac68cf45b314286afd50870852f3885", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "parked-gate-replay" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "39", + "eventTime": "2026-09-04T18:20:31.031886Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048690", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "38", + "identity": "47023@MacBook-Air.local", + "requestId": "e096f006-c10b-41da-a957-1f2eb92c70ce", + "historySizeBytes": "7339", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "40", + "eventTime": "2026-09-04T18:20:31.035330Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048694", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "38", + "startedEventId": "39", + "identity": "47023@MacBook-Air.local", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "41", + "eventTime": "2026-09-04T18:20:31.035398Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1048695", + "activityTaskScheduledEventAttributes": { + "activityId": "7", + "activityType": { + "name": "emitEvent" + }, + "taskQueue": { + "name": "parked-gate-replay", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": {}, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InBhcmtlZC1nYXRlLXJlcGxheS1leGVjdXRpb24i" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "NQ==" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "Im5vZGVfd2FpdGluZyI=" + }, + { + "metadata": { + "encoding": "YmluYXJ5L251bGw=" + } + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "ImdhdGUi" + } + ] + }, + "scheduleToCloseTimeout": "0s", + "scheduleToStartTimeout": "0s", + "startToCloseTimeout": "30s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "40", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2, + "maximumInterval": "100s", + "maximumAttempts": 5 + }, + "useWorkflowBuildId": true + } + }, + { + "eventId": "42", + "eventTime": "2026-09-04T18:20:31.036306Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1048700", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "41", + "identity": "47023@MacBook-Air.local", + "requestId": "eb4d077a-e211-451d-adb6-8bff02590e61", + "attempt": 1, + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "43", + "eventTime": "2026-09-04T18:20:31.037550Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1048701", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "YmluYXJ5L251bGw=" + } + } + ] + }, + "scheduledEventId": "41", + "startedEventId": "42", + "identity": "47023@MacBook-Air.local" + } + }, + { + "eventId": "44", + "eventTime": "2026-09-04T18:20:31.037555Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048702", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "47023@MacBook-Air.local-bac68cf45b314286afd50870852f3885", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "parked-gate-replay" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "45", + "eventTime": "2026-09-04T18:20:31.038062Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048706", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "44", + "identity": "47023@MacBook-Air.local", + "requestId": "74a590d4-bb6c-4dc4-b70e-f8a920239970", + "historySizeBytes": "8364", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "46", + "eventTime": "2026-09-04T18:20:31.040354Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048710", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "44", + "startedEventId": "45", + "identity": "47023@MacBook-Air.local", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "47", + "eventTime": "2026-09-04T18:20:31.040372Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1048711", + "activityTaskScheduledEventAttributes": { + "activityId": "8", + "activityType": { + "name": "updateStatus" + }, + "taskQueue": { + "name": "parked-gate-replay", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": {}, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InBhcmtlZC1nYXRlLXJlcGxheS1leGVjdXRpb24i" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "IndhaXRpbmci" + }, + { + "metadata": { + "encoding": "YmluYXJ5L251bGw=" + } + } + ] + }, + "scheduleToCloseTimeout": "0s", + "scheduleToStartTimeout": "0s", + "startToCloseTimeout": "30s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "46", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2, + "maximumInterval": "100s", + "maximumAttempts": 5 + }, + "useWorkflowBuildId": true + } + }, + { + "eventId": "48", + "eventTime": "2026-09-04T18:20:31.041001Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1048716", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "47", + "identity": "47023@MacBook-Air.local", + "requestId": "add0353e-78a5-4e49-9bf2-30ad95dbabf9", + "attempt": 1, + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "49", + "eventTime": "2026-09-04T18:20:31.042397Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1048717", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "YmluYXJ5L251bGw=" + } + } + ] + }, + "scheduledEventId": "47", + "startedEventId": "48", + "identity": "47023@MacBook-Air.local" + } + }, + { + "eventId": "50", + "eventTime": "2026-09-04T18:20:31.042400Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048718", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "47023@MacBook-Air.local-bac68cf45b314286afd50870852f3885", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "parked-gate-replay" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "51", + "eventTime": "2026-09-04T18:20:31.042983Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048722", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "50", + "identity": "47023@MacBook-Air.local", + "requestId": "b20b7d7e-0805-4a90-b969-ab71568678f3", + "historySizeBytes": "9323", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "52", + "eventTime": "2026-09-04T18:20:31.045377Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048726", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "50", + "startedEventId": "51", + "identity": "47023@MacBook-Air.local", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "53", + "eventTime": "2026-09-04T18:20:31.068140Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048732", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "47023@MacBook-Air.local-bac68cf45b314286afd50870852f3885", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "parked-gate-replay" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "54", + "eventTime": "2026-09-04T18:20:31.068346Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048733", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "53", + "identity": "47023@MacBook-Air.local", + "requestId": "cb84a6e6-0b4f-4eea-b689-371a9f272fb1", + "historySizeBytes": "9665", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "55", + "eventTime": "2026-09-04T18:20:31.073832Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048734", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "53", + "startedEventId": "54", + "identity": "47023@MacBook-Air.local", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "56", + "eventTime": "2026-09-04T18:20:31.073887Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_ACCEPTED", + "taskId": "1048735", + "workflowExecutionUpdateAcceptedEventAttributes": { + "protocolInstanceId": "c5b83922-a95f-492e-84d2-20b1db3eeafa", + "acceptedRequestMessageId": "c5b83922-a95f-492e-84d2-20b1db3eeafa/request", + "acceptedRequestSequencingEventId": "53", + "acceptedRequest": { + "meta": { + "updateId": "c5b83922-a95f-492e-84d2-20b1db3eeafa", + "identity": "47023@MacBook-Air.local" + }, + "input": { + "header": {}, + "name": "resolveNode", + "args": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJub2RlSWQiOiJnYXRlIiwicmVzb2x1dGlvbiI6eyJvdXRwdXQiOiJhcHByb3ZlZCJ9fQ==" + } + ] + } + } + } + } + }, + { + "eventId": "57", + "eventTime": "2026-09-04T18:20:31.073942Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_COMPLETED", + "taskId": "1048736", + "workflowExecutionUpdateCompletedEventAttributes": { + "meta": { + "updateId": "c5b83922-a95f-492e-84d2-20b1db3eeafa", + "identity": "47023@MacBook-Air.local" + }, + "acceptedEventId": "56", + "outcome": { + "success": { + "payloads": [ + { + "metadata": { + "encoding": "YmluYXJ5L251bGw=" + } + } + ] + } + } + } + }, + { + "eventId": "58", + "eventTime": "2026-09-04T18:20:31.073973Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1048737", + "activityTaskScheduledEventAttributes": { + "activityId": "9", + "activityType": { + "name": "updateStatus" + }, + "taskQueue": { + "name": "parked-gate-replay", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": {}, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InBhcmtlZC1nYXRlLXJlcGxheS1leGVjdXRpb24i" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InJ1bm5pbmci" + }, + { + "metadata": { + "encoding": "YmluYXJ5L251bGw=" + } + } + ] + }, + "scheduleToCloseTimeout": "0s", + "scheduleToStartTimeout": "0s", + "startToCloseTimeout": "30s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "55", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2, + "maximumInterval": "100s", + "maximumAttempts": 5 + }, + "useWorkflowBuildId": true + } + }, + { + "eventId": "59", + "eventTime": "2026-09-04T18:20:31.074733Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1048743", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "58", + "identity": "47023@MacBook-Air.local", + "requestId": "749dc6af-e22f-4ef5-a0a8-b1f1c9c817ae", + "attempt": 1, + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "60", + "eventTime": "2026-09-04T18:20:31.077459Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1048744", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "YmluYXJ5L251bGw=" + } + } + ] + }, + "scheduledEventId": "58", + "startedEventId": "59", + "identity": "47023@MacBook-Air.local" + } + }, + { + "eventId": "61", + "eventTime": "2026-09-04T18:20:31.077463Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048745", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "47023@MacBook-Air.local-bac68cf45b314286afd50870852f3885", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "parked-gate-replay" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "62", + "eventTime": "2026-09-04T18:20:31.077937Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048749", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "61", + "identity": "47023@MacBook-Air.local", + "requestId": "58c5144d-52cc-4b0c-936f-68f2fa01597d", + "historySizeBytes": "11142", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "63", + "eventTime": "2026-09-04T18:20:31.080540Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048753", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "61", + "startedEventId": "62", + "identity": "47023@MacBook-Air.local", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "64", + "eventTime": "2026-09-04T18:20:31.080555Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1048754", + "activityTaskScheduledEventAttributes": { + "activityId": "10", + "activityType": { + "name": "emitEvent" + }, + "taskQueue": { + "name": "parked-gate-replay", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": {}, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InBhcmtlZC1nYXRlLXJlcGxheS1leGVjdXRpb24i" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "Ng==" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "Im5vZGVfY29tcGxldGVkIg==" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJvdXRwdXQiOiJhcHByb3ZlZCJ9" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "ImdhdGUi" + } + ] + }, + "scheduleToCloseTimeout": "0s", + "scheduleToStartTimeout": "0s", + "startToCloseTimeout": "30s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "63", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2, + "maximumInterval": "100s", + "maximumAttempts": 5 + }, + "useWorkflowBuildId": true + } + }, + { + "eventId": "65", + "eventTime": "2026-09-04T18:20:31.081059Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1048759", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "64", + "identity": "47023@MacBook-Air.local", + "requestId": "10e29ce9-9282-435e-b306-7ce1bc037b5a", + "attempt": 1, + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "66", + "eventTime": "2026-09-04T18:20:31.082208Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1048760", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "YmluYXJ5L251bGw=" + } + } + ] + }, + "scheduledEventId": "64", + "startedEventId": "65", + "identity": "47023@MacBook-Air.local" + } + }, + { + "eventId": "67", + "eventTime": "2026-09-04T18:20:31.082212Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048761", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "47023@MacBook-Air.local-bac68cf45b314286afd50870852f3885", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "parked-gate-replay" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "68", + "eventTime": "2026-09-04T18:20:31.082806Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048765", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "67", + "identity": "47023@MacBook-Air.local", + "requestId": "46ac1ad1-1c42-4509-a458-ef6fd779d185", + "historySizeBytes": "12192", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "69", + "eventTime": "2026-09-04T18:20:31.084708Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048769", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "67", + "startedEventId": "68", + "identity": "47023@MacBook-Air.local", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "70", + "eventTime": "2026-09-04T18:20:31.084720Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1048770", + "activityTaskScheduledEventAttributes": { + "activityId": "11", + "activityType": { + "name": "emitEvent" + }, + "taskQueue": { + "name": "parked-gate-replay", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": {}, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InBhcmtlZC1nYXRlLXJlcGxheS1leGVjdXRpb24i" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "Nw==" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "Im5vZGVfc3RhcnRlZCI=" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJjb25maWciOnt9LCJ2aXNpYmxlTm9kZUlkcyI6WyJzdGFydCIsImdhdGUiXX0=" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "ImFmdGVyIg==" + } + ] + }, + "scheduleToCloseTimeout": "0s", + "scheduleToStartTimeout": "0s", + "startToCloseTimeout": "30s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "69", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2, + "maximumInterval": "100s", + "maximumAttempts": 5 + }, + "useWorkflowBuildId": true + } + }, + { + "eventId": "71", + "eventTime": "2026-09-04T18:20:31.085288Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1048775", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "70", + "identity": "47023@MacBook-Air.local", + "requestId": "7efb6b09-5e85-475d-9822-7ba2b8d09489", + "attempt": 1, + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "72", + "eventTime": "2026-09-04T18:20:31.086609Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1048776", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "YmluYXJ5L251bGw=" + } + } + ] + }, + "scheduledEventId": "70", + "startedEventId": "71", + "identity": "47023@MacBook-Air.local" + } + }, + { + "eventId": "73", + "eventTime": "2026-09-04T18:20:31.086612Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048777", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "47023@MacBook-Air.local-bac68cf45b314286afd50870852f3885", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "parked-gate-replay" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "74", + "eventTime": "2026-09-04T18:20:31.112655Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048781", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "73", + "identity": "47023@MacBook-Air.local", + "requestId": "5cd558a4-7e84-4426-a1a5-33c049dc7b33", + "historySizeBytes": "13267", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "75", + "eventTime": "2026-09-04T18:20:31.115478Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048785", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "73", + "startedEventId": "74", + "identity": "47023@MacBook-Air.local", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "76", + "eventTime": "2026-09-04T18:20:31.115499Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1048786", + "activityTaskScheduledEventAttributes": { + "activityId": "12", + "activityType": { + "name": "executeNode" + }, + "taskQueue": { + "name": "parked-gate-replay", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": {}, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJpZCI6ImFmdGVyIiwidHlwZSI6InRlc3Qvc3RlcCIsImNvbmZpZyI6e319" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJ3b3JrZmxvd0lkIjoicGF1c2UtdGVzdC13b3JrZmxvdyIsImV4ZWN1dGlvbklkIjoicGFya2VkLWdhdGUtcmVwbGF5LWV4ZWN1dGlvbiIsInRyaWdnZXJQYXlsb2FkIjp7fSwibm9kZU91dHB1dHMiOnsic3RhcnQiOnsidmlzaXRlZCI6InN0YXJ0In0sImdhdGUiOiJhcHByb3ZlZCJ9LCJ2YXJpYWJsZXMiOnt9LCJnbG9iYWwiOnt9fQ==" + } + ] + }, + "scheduleToCloseTimeout": "0s", + "scheduleToStartTimeout": "0s", + "startToCloseTimeout": "600s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "75", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2, + "maximumInterval": "100s", + "maximumAttempts": 2 + }, + "useWorkflowBuildId": true + } + }, + { + "eventId": "77", + "eventTime": "2026-09-04T18:20:31.162848Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1048791", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "76", + "identity": "47023@MacBook-Air.local", + "requestId": "fb302e84-62dd-4e8a-b681-2ed9be34980a", + "attempt": 1, + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "78", + "eventTime": "2026-09-04T18:20:31.164818Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1048792", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJvdXRwdXQiOnsidmlzaXRlZCI6ImFmdGVyIn19" + } + ] + }, + "scheduledEventId": "76", + "startedEventId": "77", + "identity": "47023@MacBook-Air.local" + } + }, + { + "eventId": "79", + "eventTime": "2026-09-04T18:20:31.164823Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048793", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "47023@MacBook-Air.local-bac68cf45b314286afd50870852f3885", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "parked-gate-replay" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "80", + "eventTime": "2026-09-04T18:20:31.212779Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048797", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "79", + "identity": "47023@MacBook-Air.local", + "requestId": "a7ff1b5d-66b7-4492-9dfe-ed1ab17dc04e", + "historySizeBytes": "14430", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "81", + "eventTime": "2026-09-04T18:20:31.216139Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048801", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "79", + "startedEventId": "80", + "identity": "47023@MacBook-Air.local", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "82", + "eventTime": "2026-09-04T18:20:31.216166Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1048802", + "activityTaskScheduledEventAttributes": { + "activityId": "13", + "activityType": { + "name": "emitEvent" + }, + "taskQueue": { + "name": "parked-gate-replay", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": {}, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InBhcmtlZC1nYXRlLXJlcGxheS1leGVjdXRpb24i" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "OA==" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "Im5vZGVfY29tcGxldGVkIg==" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "eyJvdXRwdXQiOnsidmlzaXRlZCI6ImFmdGVyIn19" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "ImFmdGVyIg==" + } + ] + }, + "scheduleToCloseTimeout": "0s", + "scheduleToStartTimeout": "0s", + "startToCloseTimeout": "30s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "81", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2, + "maximumInterval": "100s", + "maximumAttempts": 5 + }, + "useWorkflowBuildId": true + } + }, + { + "eventId": "83", + "eventTime": "2026-09-04T18:20:31.262945Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1048807", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "82", + "identity": "47023@MacBook-Air.local", + "requestId": "51061e8b-2c91-46cc-9f35-3c27e9d304cf", + "attempt": 1, + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "84", + "eventTime": "2026-09-04T18:20:31.265332Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1048808", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "YmluYXJ5L251bGw=" + } + } + ] + }, + "scheduledEventId": "82", + "startedEventId": "83", + "identity": "47023@MacBook-Air.local" + } + }, + { + "eventId": "85", + "eventTime": "2026-09-04T18:20:31.265340Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048809", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "47023@MacBook-Air.local-bac68cf45b314286afd50870852f3885", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "parked-gate-replay" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "86", + "eventTime": "2026-09-04T18:20:31.313712Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048813", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "85", + "identity": "47023@MacBook-Air.local", + "requestId": "bc19e5f9-59e5-4ebb-a869-94790cdce02d", + "historySizeBytes": "15490", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "87", + "eventTime": "2026-09-04T18:20:31.319415Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048817", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "85", + "startedEventId": "86", + "identity": "47023@MacBook-Air.local", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "88", + "eventTime": "2026-09-04T18:20:31.319478Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1048818", + "activityTaskScheduledEventAttributes": { + "activityId": "14", + "activityType": { + "name": "emitEvent" + }, + "taskQueue": { + "name": "parked-gate-replay", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": {}, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InBhcmtlZC1nYXRlLXJlcGxheS1leGVjdXRpb24i" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "OQ==" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "ImV4ZWN1dGlvbl9jb21wbGV0ZWQi" + }, + { + "metadata": { + "encoding": "YmluYXJ5L251bGw=" + } + }, + { + "metadata": { + "encoding": "YmluYXJ5L251bGw=" + } + } + ] + }, + "scheduleToCloseTimeout": "0s", + "scheduleToStartTimeout": "0s", + "startToCloseTimeout": "30s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "87", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2, + "maximumInterval": "100s", + "maximumAttempts": 5 + }, + "useWorkflowBuildId": true + } + }, + { + "eventId": "89", + "eventTime": "2026-09-04T18:20:31.363278Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1048823", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "88", + "identity": "47023@MacBook-Air.local", + "requestId": "551147fe-35ad-46c6-9bac-598bf0ac7870", + "attempt": 1, + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "90", + "eventTime": "2026-09-04T18:20:31.366120Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1048824", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "YmluYXJ5L251bGw=" + } + } + ] + }, + "scheduledEventId": "88", + "startedEventId": "89", + "identity": "47023@MacBook-Air.local" + } + }, + { + "eventId": "91", + "eventTime": "2026-09-04T18:20:31.366130Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048825", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "47023@MacBook-Air.local-bac68cf45b314286afd50870852f3885", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "parked-gate-replay" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "92", + "eventTime": "2026-09-04T18:20:31.413370Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048829", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "91", + "identity": "47023@MacBook-Air.local", + "requestId": "68707231-a959-404c-8229-3285dae57e44", + "historySizeBytes": "16522", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "93", + "eventTime": "2026-09-04T18:20:31.418202Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048833", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "91", + "startedEventId": "92", + "identity": "47023@MacBook-Air.local", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "94", + "eventTime": "2026-09-04T18:20:31.418252Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED", + "taskId": "1048834", + "activityTaskScheduledEventAttributes": { + "activityId": "15", + "activityType": { + "name": "updateStatus" + }, + "taskQueue": { + "name": "parked-gate-replay", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "header": {}, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InBhcmtlZC1nYXRlLXJlcGxheS1leGVjdXRpb24i" + }, + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "ImNvbXBsZXRlZCI=" + }, + { + "metadata": { + "encoding": "YmluYXJ5L251bGw=" + } + } + ] + }, + "scheduleToCloseTimeout": "0s", + "scheduleToStartTimeout": "0s", + "startToCloseTimeout": "30s", + "heartbeatTimeout": "0s", + "workflowTaskCompletedEventId": "93", + "retryPolicy": { + "initialInterval": "1s", + "backoffCoefficient": 2, + "maximumInterval": "100s", + "maximumAttempts": 5 + }, + "useWorkflowBuildId": true + } + }, + { + "eventId": "95", + "eventTime": "2026-09-04T18:20:31.463600Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED", + "taskId": "1048839", + "activityTaskStartedEventAttributes": { + "scheduledEventId": "94", + "identity": "47023@MacBook-Air.local", + "requestId": "0cb30d51-588e-474b-ada0-e1f51d57acc7", + "attempt": 1, + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "96", + "eventTime": "2026-09-04T18:20:31.467173Z", + "eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED", + "taskId": "1048840", + "activityTaskCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "YmluYXJ5L251bGw=" + } + } + ] + }, + "scheduledEventId": "94", + "startedEventId": "95", + "identity": "47023@MacBook-Air.local" + } + }, + { + "eventId": "97", + "eventTime": "2026-09-04T18:20:31.467183Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "taskId": "1048841", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "47023@MacBook-Air.local-bac68cf45b314286afd50870852f3885", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "parked-gate-replay" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "98", + "eventTime": "2026-09-04T18:20:31.514118Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "taskId": "1048845", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "97", + "identity": "47023@MacBook-Air.local", + "requestId": "4ece6335-cb10-46d7-934e-4f391f5ec9f2", + "historySizeBytes": "17491", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + } + } + }, + { + "eventId": "99", + "eventTime": "2026-09-04T18:20:31.521621Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "taskId": "1048849", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "97", + "startedEventId": "98", + "identity": "47023@MacBook-Air.local", + "workerVersion": { + "buildId": "@temporalio/worker@1.23.0+7e2186099e40330d399b7afe4f755a1397273f85f7f1b5165183b77e474f3fa2" + }, + "sdkMetadata": {}, + "meteringMetadata": {} + } + }, + { + "eventId": "100", + "eventTime": "2026-09-04T18:20:31.521734Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED", + "taskId": "1048850", + "workflowExecutionCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "YmluYXJ5L251bGw=" + } + } + ] + }, + "workflowTaskCompletedEventId": "99" + } + } + ] +} diff --git a/packages/temporal/test/replay/parked-gate-replay.test.ts b/packages/temporal/test/replay/parked-gate-replay.test.ts new file mode 100644 index 000000000..af9050cb0 --- /dev/null +++ b/packages/temporal/test/replay/parked-gate-replay.test.ts @@ -0,0 +1,119 @@ +// The durable-pause replay pin: a history with a parked gate and a delivered verdict, +// recorded by an older build, must replay under the current code — see ./README.md. +import { type History, historyToJSON } from '@temporalio/common/lib/proto-utils'; +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { Worker, bundleWorkflowCode } from '@temporalio/worker'; +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + RUN_WORKFLOW_NAME, + WorkflowBuilderPlugin, + type WorkflowExecutionInput, + executionWorkflowId, +} from '../../src/index'; +import { resolveNodeUpdate } from '../../src/workflow/index'; +import { type RecordingStore, createRecordingStore } from '../fixtures/graph'; +import { countScheduledActivities, executeVerdictWithRetry, waitUntil } from '../fixtures/helpers'; +import { type PauseTestNode, SINGLE_GATE_GRAPH, createPauseExecutors } from '../fixtures/pause-graph'; + +const EXECUTION_ID = 'parked-gate-replay-execution'; +const TASK_QUEUE = 'parked-gate-replay'; + +const COMMITTED_HISTORY = new URL('histories/v0-parked-gate.json', import.meta.url); + +// start + gate + after: the usual started/completed pair per node, execution brackets, +// one node_waiting, and the waiting → running → completed status transitions. +const EXPECTED_ACTIVITY_COUNTS = { + executeNode: 3, + emitEvent: 2 + 3 * 2 + 1, + updateStatus: 3, +}; + +describe('replay — parked gate', () => { + let env: TestWorkflowEnvironment; + let workflowBundle: { code: string }; + let history: History; + let store: RecordingStore; + + beforeAll(async () => { + [workflowBundle, env] = await Promise.all([ + bundleWorkflowCode({ workflowsPath: fileURLToPath(new URL('../fixtures/workflows.ts', import.meta.url)) }), + TestWorkflowEnvironment.createLocal(), + ]); + + store = createRecordingStore(); + const harness = createPauseExecutors(); + + const plugin = new WorkflowBuilderPlugin({ + store, + executors: harness.executors, + taskQueue: TASK_QUEUE, + }); + + const worker = await Worker.create({ + connection: env.nativeConnection, + namespace: env.namespace, + taskQueue: plugin.taskQueue, + workflowBundle, + plugins: [plugin], + }); + + const input: WorkflowExecutionInput = { + workflowId: SINGLE_GATE_GRAPH.workflowId, + executionId: EXECUTION_ID, + definition: SINGLE_GATE_GRAPH, + triggerPayload: {}, + variables: {}, + global: {}, + }; + + const handle = await env.client.workflow.start(RUN_WORKFLOW_NAME, { + taskQueue: plugin.taskQueue, + workflowId: executionWorkflowId(EXECUTION_ID), + args: [input], + }); + + await worker.runUntil(async () => { + await waitUntil(() => store.statuses.some((entry) => entry.status === 'waiting'), 'the waiting status'); + await executeVerdictWithRetry(() => + handle.executeUpdate(resolveNodeUpdate, { args: [{ nodeId: 'gate', resolution: { output: 'approved' } }] }), + ); + await handle.result(); + }); + + history = await handle.fetchHistory(); + + if (process.env.UPDATE_REPLAY_HISTORIES) { + await writeFile(COMMITTED_HISTORY, `${historyToJSON(history)}\n`); + } + }, 300_000); + + afterAll(async () => { + await env?.teardown(); + }); + + it('parks, takes the verdict and completes through the plugin', () => { + expect(store.statuses.map((entry) => entry.status)).toEqual(['waiting', 'running', 'completed']); + expect(store.events.at(-1)?.type).toBe('execution_completed'); + + const gateEvents = store.events.filter((event) => event.nodeId === 'gate').map((event) => event.type); + expect(gateEvents).toEqual(['node_started', 'node_waiting', 'node_completed']); + }); + + it('schedules one activity per node, per event and per status transition', () => { + expect(countScheduledActivities(history)).toEqual(EXPECTED_ACTIVITY_COUNTS); + }); + + it('replays the history it just recorded', async () => { + await expect(Worker.runReplayHistory({ workflowBundle }, history)).resolves.toBeUndefined(); + }); + + it('replays a parked-and-resumed history recorded before the current code', async () => { + // The cross-version guard for runs waiting on a verdict across a deploy. + const recorded: unknown = JSON.parse(await readFile(COMMITTED_HISTORY, 'utf8')); + + await expect(Worker.runReplayHistory({ workflowBundle }, recorded)).resolves.toBeUndefined(); + }, 60_000); +}); diff --git a/packages/temporal/test/replay/replay.test.ts b/packages/temporal/test/replay/replay.test.ts index 5e6cf6ec3..c0c612551 100644 --- a/packages/temporal/test/replay/replay.test.ts +++ b/packages/temporal/test/replay/replay.test.ts @@ -22,6 +22,7 @@ import { createRecordingStore, replayTestExecutors, } from '../fixtures/graph'; +import { countScheduledActivities } from '../fixtures/helpers'; const EXECUTION_ID = 'replay-test-execution'; const TASK_QUEUE = 'replay-test'; @@ -39,19 +40,6 @@ const EXPECTED_ACTIVITY_COUNTS = { updateStatus: 1, }; -function countScheduledActivities(history: History): Record { - const counts: Record = {}; - - for (const event of history.events ?? []) { - const name = event.activityTaskScheduledEventAttributes?.activityType?.name; - if (name) { - counts[name] = (counts[name] ?? 0) + 1; - } - } - - return counts; -} - describe('replay', () => { let env: TestWorkflowEnvironment; let workflowBundle: { code: string }; diff --git a/packages/types/src/workflow-execution/execution-events.ts b/packages/types/src/workflow-execution/execution-events.ts index 91fde324f..d3bc10c5d 100644 --- a/packages/types/src/workflow-execution/execution-events.ts +++ b/packages/types/src/workflow-execution/execution-events.ts @@ -188,4 +188,4 @@ export type ExecutionSnapshot = { events: ExecutionEvent[]; }; -export type ExecutionStatus = 'pending' | 'running' | 'cancelling' | TerminalExecutionStatus; +export type ExecutionStatus = 'pending' | 'running' | 'waiting' | 'cancelling' | TerminalExecutionStatus;