Skip to content
Merged
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
33 changes: 33 additions & 0 deletions content/docs/guides/metadata/flow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,39 @@ POST /api/v1/automation/{flow}/runs/{runId}/resume
| `wait` (timer) | an ISO-8601 duration elapses | **automatically** — a one-shot job resumes the run; after a cold boot the engine re-arms pending timers from the durable store (overdue timers resume immediately) |
| `wait` (signal) | a named external event | any caller invoking `resume(runId)` |

### Parallel approvals — one aggregating node, not two pauses

"Finance **and** legal must both sign off, concurrently" is **one `approval`
node** with two approver groups and `behavior: 'unanimous'` — not two parallel
pauses. On entry the node opens a single `sys_approval_request` whose
`pending_approvers` holds **both** groups (notified concurrently); it stays
suspended until **every** group has approved (the aggregation / AND), then
resumes down `approve`. Any one rejection finalizes immediately down `reject`.

```typescript
{
id: 'dual_signoff',
type: 'approval',
label: 'Finance + Legal Sign-off',
config: {
approvers: [
{ type: 'role', value: 'finance' },
{ type: 'role', value: 'legal' },
],
behavior: 'unanimous', // 'first_response' = any one decides
lockRecord: false,
},
}
```

This is the **aggregating-node** pattern (Camunda multi-instance / Step Functions
`Map`): the run keeps a single program counter and pauses once, so it needs no
concurrent-token machinery. Durable pause **inside** a hand-drawn `parallel`
branch or `loop` iteration — where two unrelated positions pause independently —
is a separate, larger capability ([ADR-0037](https://github.com/objectstack-ai/framework/blob/main/docs/adr/0037-token-scope-tree-execution.md)
Track B); the aggregating node covers the common parallel/batch-approval demand
without it. Worked example: `showcase_invoice_signoff` in the showcase app.

### Nested pause — pausing inside a subflow

A pausing node inside a `subflow` suspends the **whole chain as linked runs**:
Expand Down
86 changes: 86 additions & 0 deletions examples/app-showcase/src/flows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -832,10 +832,96 @@ export const ResilientSyncFlow = defineFlow({
],
});

/**
* Invoice Dual Sign-off — the worked **parallel-approval** example (ADR-0037
* Track A: aggregating approval node, no engine-core change).
*
* "Finance AND legal must both sign off before an invoice is sent" is expressed
* as a **single `approval` node** with two approver groups and
* `behavior: 'unanimous'`. On entry the node opens ONE `sys_approval_request`
* whose `pending_approvers` holds *both* groups — they are notified
* concurrently (parallel). The node stays suspended until **every** group has
* approved (the aggregation / AND), then resumes down the `approve` edge; any
* rejection resumes down `reject`. One node, one suspend/resume, no token tree —
* the multi-instance pattern Camunda and Step Functions use for exactly this.
*
* Decide via the approvals API (never a raw engine `resume`):
* POST /api/v1/automation/showcase_invoice_signoff/runs/{runId}/... ← no
* POST /api/v1/approvals/requests/{id}/approve { actorId: 'role:finance' }
* POST /api/v1/approvals/requests/{id}/approve { actorId: 'role:legal' } ← now it continues
*/
export const InvoiceDualSignoffFlow = defineFlow({
name: 'showcase_invoice_signoff',
label: 'Invoice Dual Sign-off (parallel approval)',
description: 'On send, requires finance AND legal to both approve via one aggregating approval node — demonstrates parallel approvals without a token tree (ADR-0037 Track A).',
type: 'autolaunched',
nodes: [
{
id: 'start',
type: 'start',
label: 'On Invoice Sent',
config: {
objectName: 'showcase_invoice',
triggerType: 'record-after-update',
condition: 'status == "sent" && previous.status != "sent"',
},
},
{
id: 'dual_signoff',
type: 'approval',
label: 'Finance + Legal Sign-off',
config: {
// Two approver groups, notified in parallel; `unanimous` waits for both.
approvers: [
{ type: 'role', value: 'finance' },
{ type: 'role', value: 'legal' },
],
behavior: 'unanimous',
// The invoice keeps flowing through other automations while it waits.
lockRecord: false,
},
},
{
id: 'notify_cleared',
type: 'notify',
label: 'Notify: Cleared',
config: {
topic: 'invoice.signoff',
recipients: ['{record.account.owner}'],
channels: ['inbox'],
severity: 'info',
title: 'Invoice cleared: {record.name}',
message: 'Invoice "{record.name}" passed finance + legal sign-off and is on its way.',
actionUrl: '/showcase_invoice/{record.id}',
},
},
{
id: 'flag_held',
type: 'update_record',
label: 'Flag: Held',
config: {
objectName: 'showcase_invoice',
filter: { id: '{record.id}' },
fields: { status: 'draft' },
},
},
{ id: 'end_ok', type: 'end', label: 'Sent' },
{ id: 'end_held', type: 'end', label: 'Held' },
],
edges: [
{ id: 'e1', source: 'start', target: 'dual_signoff' },
{ id: 'e2', source: 'dual_signoff', target: 'notify_cleared', label: 'approve' },
{ id: 'e3', source: 'dual_signoff', target: 'flag_held', label: 'reject' },
{ id: 'e4', source: 'notify_cleared', target: 'end_ok' },
{ id: 'e5', source: 'flag_held', target: 'end_held' },
],
});

export const allFlows = [
TaskCompletedFlow,
ReassignWizardFlow,
BudgetApprovalFlow,
InvoiceDualSignoffFlow,
TaskCompletedSlackFlow,
TaskAssignedNotifyFlow,
ScheduledDigestFlow,
Expand Down
Loading