Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ of coordination. There is no separate orchestrator server.
| |
| - workflow_runs |
| - step_attempts |
| - workflow_signals |
+------------------------------+
```

Expand All @@ -122,9 +123,9 @@ of coordination. There is no separate orchestrator server.
`npx @openworkflow/cli worker start` with
auto-discovery of workflow files.
- **Backend**: The source of truth. It stores workflow runs and step attempts.
The `workflow_runs` table serves as the job queue for the workers, while the
`step_attempts` table serves as a record of started and completed work,
enabling memoization.
The `workflow_runs` table serves as the job queue for the workers, the
`step_attempts` table serves as a record of started and completed work, and
the `workflow_signals` buffers signals until a waiting step consumes them.

### 2.3. Basic Execution Flow

Expand All @@ -149,7 +150,7 @@ of coordination. There is no separate orchestrator server.
`step_attempt` record with status `running`, executes the step function, and
then updates the `step_attempt` to `completed` upon completion. The Worker
continues executing inline until the workflow code completes or encounters a
sleep.
durable wait such as sleep, child-workflow waiting, or signal waiting.
6. **State Update**: The Worker updates the Backend with each `step_attempt` as
it is created and completed, and updates the status of the `workflow_run`
(e.g., `completed`, `running` for parked waits).
Expand Down Expand Up @@ -234,10 +235,23 @@ target workflow name in `spec`) and `options.timeout` controls the wait timeout
(default 1y). When the timeout is reached, the parent step fails but the child
workflow continues running independently.

All step APIs (`step.run`, `step.sleep`, and `step.runWorkflow`) share the same
collision logic for durable keys. If duplicate base names are encountered in one
execution pass, OpenWorkflow auto-indexes them as `name`, `name:1`, `name:2`,
and so on so each step call maps to a distinct step attempt.
**`step.sendSignal(workflowRunId, signal, options?)`**: Sends signal to another
workflow run as a durable step. The signal is stored in `workflow_signals` and
retried safely using a deterministic idempotency key derived from the logical
step name.

**`step.waitForSignal(signal, options?)`**: Waits durably for the next buffered
signal with that name for the current workflow run. The common-case API uses
the signal string itself as the durable step name. When needed,
`step.waitForSignal({ name, signal, timeout, schema })` lets callers override
the durable step name explicitly. Waits return the parsed payload or `null` on
timeout.

All step APIs (`step.run`, `step.sleep`, `step.runWorkflow`,
`step.sendSignal`, and `step.waitForSignal`) share the same collision logic for
durable keys. If duplicate base names are encountered in one execution pass,
OpenWorkflow auto-indexes them as `name`, `name:1`, `name:2`, and so on so each
step call maps to a distinct step attempt.

## 4. Error Handling & Retries

Expand Down
21 changes: 19 additions & 2 deletions packages/dashboard/src/routes/runs/$runId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -352,8 +352,7 @@ function RunDetailsPage() {
const config = STEP_STATUS_CONFIG[step.status];
const StatusIcon = config.icon;
const iconColor = config.color;
const stepTypeLabel =
step.kind === "function" ? "function" : step.kind;
const stepTypeLabel = formatStepKindLabel(step.kind);
const stepDuration = computeDuration(
step.startedAt,
step.finishedAt,
Expand Down Expand Up @@ -740,6 +739,10 @@ function StepInspectorPanel({
value={attemptCount.toString()}
mono
/>
<MetadataField
label="Step Kind"
value={formatStepKindLabel(step.kind)}
/>
<MetadataTimestampField
label="Started At"
value={step.startedAt}
Expand Down Expand Up @@ -1095,6 +1098,20 @@ function normalizeDebugValue(value: unknown): unknown {
return normalizeValue(value, new WeakSet());
}

function formatStepKindLabel(kind: StepAttempt["kind"]): string {
switch (kind) {
case "signal-send": {
return "signal send";
}
case "signal-wait": {
return "signal wait";
}
default: {
return kind;
}
}
}

function normalizeValue(value: unknown, seen: WeakSet<object>): unknown {
if (value instanceof Error) {
return {
Expand Down
1 change: 1 addition & 0 deletions packages/docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"docs/parallel-steps",
"docs/dynamic-steps",
"docs/child-workflows",
"docs/signals",
"docs/retries",
"docs/type-safety",
"docs/versioning",
Expand Down
10 changes: 5 additions & 5 deletions packages/docs/docs/openworkflow-vs-temporal.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ Temporal exposes multiple timeout classes and retry options per workflow and
activity. That flexibility is useful when you need it.

OpenWorkflow keeps the default surface smaller (`step.run`, `step.sleep`,
retries with backoff, optional `deadlineAt`) and is often faster to adopt for
TypeScript teams that want fewer moving parts.
`step.runWorkflow`, `step.waitForSignal`, retries with backoff, optional
`deadlineAt`) and is often faster to adopt for TypeScript teams that want fewer
moving parts.

### Tradeoff: happy-path overhead vs resume-path replay CPU

Expand All @@ -67,8 +68,7 @@ resume.

- **Polyglot platform**: You need one orchestration platform across multiple
languages.
- **Advanced runtime interaction**: You need Signals, Queries, Updates, or
Activity heartbeats. These are coming to OpenWorkflow but are available right
now in Temporal.
- **Advanced runtime interaction**: You need the full Temporal surface such as
Queries, Updates, or Activity heartbeats.
- **Dedicated platform ownership**: Your team can operate Temporal Server as
shared infrastructure.
5 changes: 4 additions & 1 deletion packages/docs/docs/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ input)`), creating a `pending` run in the database
3. The worker replays workflow code from the beginning
4. Completed steps return cached outputs; new steps execute and persist results
5. The run transitions to `completed`, `failed`, or `canceled` (or stays
`running` while durably parked for sleep)
`running` while durably parked for sleep, child-workflow waiting, or
signal waiting)

Because replay is deterministic, crash recovery is reliable. A replacement
worker can continue from the last durable checkpoint without redoing completed
Expand All @@ -37,6 +38,8 @@ work.

- **Memoized steps** prevent duplicate side effects on retries
- **Durable sleep** (`step.sleep`) pauses runs without holding worker capacity
- **Durable signals** (`step.waitForSignal`) buffer signals until a waiting step
consumes them
- **Heartbeats + leases** (`availableAt`) allow automatic crash recovery
- **Database as source of truth** avoids a separate orchestration service

Expand Down
2 changes: 1 addition & 1 deletion packages/docs/docs/roadmap.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ description: What's coming next for OpenWorkflow
- ✅ Idempotency keys
- ✅ Prometheus `/metrics` endpoint
- ✅ Child workflows (`step.runWorkflow`)
- ✅ Signals (`step.waitForSignal`, `step.sendSignal`, `ow.sendSignal`)

## Coming Soon

- Signals
- Cron / scheduling
- Rollback / compensation functions
- Priority and concurrency controls
Expand Down
157 changes: 157 additions & 0 deletions packages/docs/docs/signals.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
---
title: Signals
description: Pause a workflow until your app or another workflow sends a named signal
---

Use signals when a workflow needs to wait for an event that does not happen at
a fixed time, like a human approval, a webhook callback, or a result from
another workflow.

A signal is addressed to one specific workflow run by run ID. OpenWorkflow
buffers signals durably, so they can arrive before or after the workflow reaches
`step.waitForSignal()`.

## Basic Usage

Wait for a signal inside a workflow:

```ts
import { defineWorkflow } from "openworkflow";

export const reviewOrder = defineWorkflow(
{ name: "review-order" },
async ({ input, step }) => {
await step.run({ name: "save-order" }, async () => {
await db.orders.insert(input);
});

const approval = await step.waitForSignal<{
approved: boolean;
reviewerId: string;
}>("approval", {
timeout: "7d",
});

if (!approval?.approved) {
throw new Error("Order was not approved");
}

await step.run({ name: "submit-order" }, async () => {
await orders.submit(input.orderId, approval.reviewerId);
});
},
);
```

Send the signal from your application code:

```ts
const handle = await reviewOrder.run({ orderId: "order_123" });

await ow.sendSignal(handle.workflowRun.id, "approval", {
data: {
approved: true,
reviewerId: "manager_42",
},
});
```

## Send from Your App

Use `ow.sendSignal()` when the event comes from outside workflow code, such as
an HTTP route, webhook handler, or admin action:

```ts
await ow.sendSignal(runId, "approval", {
data: {
approved: true,
reviewerId: "manager_42",
},
idempotencyKey: `approval:${event.id}`,
});
```

Use `idempotencyKey` when the sender might retry. If the same
`workflowRunId` + `idempotencyKey` is sent again, OpenWorkflow stores it only
once.

## Send from Another Workflow

Use `step.sendSignal()` when one workflow should unblock another and you want
the send itself to be durable:

```ts
await step.sendSignal(targetRunId, "approval", {
data: { approved: true, reviewerId: "manager_42" },
});
```

This is useful when workflows coordinate as peers rather than as
parent-and-child runs.

## Timeout and Validation

`step.waitForSignal()` returns the payload when a matching signal arrives, or
`null` if the timeout is reached first:

```ts
const approval = await step.waitForSignal("approval", {
timeout: "3d",
schema: z.object({
approved: z.boolean(),
reviewerId: z.string(),
}),
});

if (approval === null) {
await step.run({ name: "mark-expired" }, async () => {
await db.requests.update(requestId, { status: "expired" });
});
}
```

If the signal payload fails schema validation, the wait step fails immediately.

## Step Names

By default, the signal string is also the durable step name:

```ts
await step.waitForSignal("approval");
await step.sendSignal(targetRunId, "approval");
```

Step names share one namespace across all step types. If the same name appears
again during one execution, OpenWorkflow applies its normal `:1`, `:2`, and so
on disambiguation.

If you want a more explicit durable step name, use the object form:

```ts
await step.waitForSignal({
name: "manager-approval",
signal: "approval",
timeout: "7d",
});
```

## Important Behavior

- Signals are buffered per `workflowRunId` and signal name until consumed.
- If multiple buffered signals match, the oldest pending one is consumed first.
- A run can have only one active waiter for a given signal name at a time.
- Signals target a specific run ID, not every run of a workflow.

## When to Use Signals

Signals are a good fit for:

- Human approvals and manual review steps
- Webhook or callback-style resumptions
- Cross-workflow coordination when you do not want a child workflow

<Note>
If you need to wait for another workflow's final result, prefer
[`step.runWorkflow()`](/docs/child-workflows). If you need to wait until a
specific time, prefer [`step.sleep()`](/docs/sleeping).
</Note>
38 changes: 37 additions & 1 deletion packages/docs/docs/steps.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ await step.run({ name: "log-event" }, async () => {

## Step Types

OpenWorkflow provides three step types:
OpenWorkflow provides five step types:

### `step.run()`

Expand Down Expand Up @@ -148,6 +148,42 @@ const childOutput = await step.runWorkflow(
);
```

### `step.sendSignal()`

Sends signal to another workflow run as a durable step:

```ts
await step.sendSignal(targetRunId, "approval", {
data: { approved: true },
});
```

See [Signals](/docs/signals) for buffered delivery, external sends with
`ow.sendSignal()`, and coordination patterns.

### `step.waitForSignal()`

Waits durably for the next matching signal for the current workflow run:

```ts
const approval = await step.waitForSignal("approval", {
timeout: "7d",
});
```

If you need to override the durable step name explicitly, use the object form:

```ts
const approval = await step.waitForSignal({
name: "manager-approval",
signal: "approval",
timeout: "7d",
});
```

See [Signals](/docs/signals) for end-to-end examples, timeout behavior, and
delivery semantics.

## Retry Policy (Optional)

Control backoff and retry limits for an individual step:
Expand Down
Loading
Loading