diff --git a/docs/adr/0019-approval-as-flow-node.md b/docs/adr/0019-approval-as-flow-node.md new file mode 100644 index 0000000000..a197313673 --- /dev/null +++ b/docs/adr/0019-approval-as-flow-node.md @@ -0,0 +1,166 @@ +# ADR-0019: Collapse Approval into Flow — one engine, approval as a durable-pause node + +**Status**: Proposed (2026-05-31) +**Deciders**: ObjectStack Protocol Architects +**Builds on**: [ADR-0018](./0018-unified-node-action-registry.md) (open action registry — approval becomes a consumer), [ADR-0009](./0009-execution-pinned-metadata.md) (execution pinning — reconcile to one mechanism), [ADR-0012](./0012-notification-platform.md) (outbox / `notify`), [ADR-0010](./0010-nl-to-flow-authoring.md) + [ADR-0011](./0011-actions-as-ai-tools.md) (AI authoring — the design center) +**Revises**: ADR-0018's premise that Approval stays a separate paradigm with its own closed `ApprovalActionType` enum, and the "Workflow-Rule → Flow compiler" (M5) — both dropped here (greenfield, no legacy). +**Consumers**: `@objectstack/spec` (`automation/approval.*`, `automation/flow.zod.ts`), `@objectstack/services/service-automation`, `@objectstack/plugins/plugin-approvals`, `@objectstack/platform-objects` (`audit/sys-approval-*`), `../objectui` (`plugin-workflow` designer) + +--- + +## TL;DR + +Approval is currently a **second execution engine** (`@objectstack/plugin-approvals`, ~1500 LOC) that runs *beside* Flow with its own action executor, its own execution pinning, and its own lifecycle. Its declarative `ApprovalProcessSchema` cannot express a graphical branch and cannot place an ordinary Flow node (e.g. an HTTP connector) between two approval steps, so admins must choose which tool to model in. + +ADR-0018 opened the node/action registry; this ADR uses it to **collapse approval onto the one Flow engine**. Approval becomes a **first-class durable-pause node** contributed through the open registry (like the existing `screen` node), not a parallel engine and not engine core. The standalone `ApprovalProcessSchema` is **deprecated as an authoring type** — its step-sequencing dissolves into Flow graph edges, its per-step actions into downstream Flow nodes (over the ADR-0018 registry), and its approver/escalation/lock model into **node config**. The approval *runtime state* (`sys_approval_request` / `sys_approval_action`, lock, status mirror, approver resolution) is **kept** — it just stops carrying a second execution loop. + +Because the future authoring path is **AI generates the Flow, the human previews the diagram and confirms**, a single composable IR (one Flow graph with rich nodes) beats a constrained side DSL: the human reviews one picture, and the AI targets one representation instead of choosing among DSLs with escape hatches. + +## Context + +### Greenfield — no migration constraint + +The platform is not launched; there is no production approval data and no legacy automation to translate. This removes the usual reason to keep a deprecated model alive for compatibility, and it removes the reason for a Workflow-Rule → Flow compiler entirely (Workflow Rules were already removed in #1398, and `workflow` was reclaimed for state machines). **Migration is a code refactor, not a data migration.** + +### Today: approval is a separate engine (the Salesforce pit, in our codebase) + +ADR-0018 §Context argued — correctly — that *multiple authoring paradigms are fine; multiple execution vocabularies are not*. Approval is where that line is currently crossed at the **engine** level, not just the vocabulary level: + +- `@objectstack/plugin-approvals` is ~1500 LOC of runtime: an 816-line `approval-service.ts` state machine, a **313-line parallel `action-executor.ts`**, 250-line lifecycle hooks, and a 128-line plugin. +- The contract is explicit that this is a separate engine: [`spec/contracts/approval-service.ts:11`](../../packages/spec/src/contracts/approval-service.ts#L11) — *"Sits on top of (but does not depend on) `IWorkflowService` … driven by humans rather than transition rules."* +- The parallel `action-executor.ts` re-implements `field_update` / `inbox_notify` / `webhook` and carries the **same** `connector_action` / `script` / `email_alert` "unimplemented, logged + skipped" stubs that ADR-0018 set out to retire. +- It has its **own** ADR-0009 execution pinning (`process_hash` → `getByHash`), parallel to Flow's. +- It registers its own lifecycle hooks: `afterInsert` auto-trigger, `beforeUpdate` record-lock ([`plugin-approvals/src/lifecycle-hooks.ts`](../../packages/plugins/plugin-approvals/src/lifecycle-hooks.ts)). + +This is precisely the failure mode Salesforce lived for years: Approval Process as a separate engine from Flow, never cleanly folded in, leaving admins to pick a tool and the platform to maintain two of everything. + +### Why the declarative `ApprovalProcessSchema` hits a wall + +[`automation/approval.zod.ts`](../../packages/spec/src/automation/approval.zod.ts) is a *linear* model: `steps[]`, per-step `approvers` / `behavior` / `rejectionBehavior`, and per-step `onApprove` / `onReject` actions drawn from a **closed** `ApprovalActionType` enum. Two concrete things it cannot do: + +1. **No graphical branch.** A reviewer cannot see, or author, an arbitrary branch — only a step list plus `rejectionBehavior: back_to_previous`. +2. **No mid-process Flow step.** You cannot place a connector / HTTP / decision node *between* approval step 2 and step 3; the only "between" available is the limited per-step action enum. Any real integration forces an escape out of the model. + +The result is exactly the tool-choice tax: simple approvals go in the approval model, anything composite has to be rebuilt as a Flow. + +### The design-center shift: AI generates, humans preview + +The intended authoring path (ADR-0010 / ADR-0011) is **AI generates the automation; the human previews the design diagram and confirms it matches intent**. This changes what the representation must optimize for: + +- The "fill a 30-second form vs. wire a 30-node graph" authoring-ergonomics argument for a constrained DSL **evaporates** — the human is not authoring either way; the AI is, and the human *reviews a diagram*. +- Reviewing **one** unified Flow graph is easier than reviewing "a linear approval config *and* a Flow graph" side by side. +- An AI targets **one composable IR** more reliably than it chooses among several constrained DSLs and their escape hatches. + +So the design center now favors a single expressive Flow representation with a rich Approval node, not a separate approval DSL. + +## Decision + +Collapse approval onto the one Flow engine. The four sub-decisions: + +### D1 — One execution engine; approval rides it as a durable-pause node + +There is **one** execution loop: the Flow engine. The engine core owns a generic **durable-pause-and-resume** primitive — a node may suspend the run and resume on an external signal (timer, event, or a human decision). The `screen` node already uses this (`supportsPause` / `isAsync`); we formalize "resume on external signal" as the shared mechanism. Approval is a node that uses it. The parallel approval execution loop (`approval-service.ts`'s stepping + `action-executor.ts`) is removed. + +> The "one engine" property — not "one state table" — is what avoids the Salesforce pit. The pit was two *execution loops*. Approval keeping its own state objects is correct and necessary (see "What must NOT be lost"); a second execution loop is not. + +### D2 — Approval is a plugin that contributes a node, not engine core + +The Approval node is registered through the **ADR-0018 open registry** (`registerNodeExecutor`), by a slimmed-down approval plugin — **not** baked into `service-automation` core. Rationale: + +- It is the ADR-0018 thesis applied to ourselves: the engine is the substrate, capabilities are contributed nodes. +- **Layering.** Approver resolution depends on the org / sharing model — `sys_team`, `sys_department` (recursive BFS), `sys_user.manager_id`, `sys_department_member` ([`plugin-approvals/src/approval-service.ts:175`](../../packages/plugins/plugin-approvals/src/approval-service.ts#L175)). The Flow engine core must **not** depend on the org model; the approval plugin may. So approval cannot live in core. +- `service-automation` stays lean; approval becomes a well-behaved node provider that rides the engine instead of a parallel engine. + +### D3 — Deprecate `ApprovalProcessSchema` as a top-level authoring type; re-home its concepts + +`ApprovalProcessSchema` / `approval.form.ts` are deprecated as a standalone *authoring* metadata type. Nothing is thrown away — each concept moves: + +| In `approval.zod.ts` today | Re-homed to | +|:---|:---| +| `steps[]` (sequence) | Multiple Approval nodes connected by Flow edges (sequence becomes a graph) | +| `rejectionBehavior: back_to_previous` | A back-edge in the Flow graph (the branch is now visible) | +| `onApprove` / `onReject` + `ApprovalActionType` enum | Downstream Flow nodes on the node's approve/reject outputs, over the ADR-0018 registry (**enum deleted**) | +| `ApproverType` (user/role/team/department/manager/field/queue), `behavior` (unanimous/first_response), `escalation`, `lockRecord`, `approvalStatusField` | **Approval node config schema** (`configSchemaRef` per the descriptor) | + +### D4 — Delete the parallel pieces + +- `plugin-approvals/src/action-executor.ts` (313 LOC) — replaced by downstream Flow nodes + the ADR-0018 action registry. +- `ApprovalActionType` enum and the dangling `connector_action` it still carries. +- The Workflow-Rule → Flow compiler (ADR-0018 M5) and the `connector_action` remnants in `flow.zod.ts` — no legacy to migrate. +- Reconcile to **one** ADR-0009 execution-pinning mechanism: the Flow definition is pinned; approval's separate `process_hash` pinning is retired. + +### What must NOT be lost + +The normalized **approval runtime state** is kept as first-class state owned by the approval plugin: + +- `sys_approval_request` (current step, current approver, status, history pointer) and `sys_approval_action` (immutable audit) — a Flow-run log **cannot** answer "approvals pending on Alice > 3 days", drive a "my approvals" inbox, or serve recall / delegate. These need the normalized shape. +- Record lock (`beforeUpdate` hook) + status mirror field; approver resolution (team / department BFS / manager / role / queue, ~200 LOC) — moved nearly verbatim under the node, not rewritten. +- Approve / reject / **recall**, `unanimous` / `first_response`, and SLA escalation remain required capabilities of the Approval node — enumerated here so a naive "just use a pause node" refactor cannot silently drop them. + +## Consequences + +**Positive** +- One execution engine — the Salesforce two-engine pit is closed in our own codebase. +- One authoring surface — no admin tool-choice; approvals and integrations live in the same Flow. +- Graphical branching and a connector node *between* approval steps both become trivial — the two concrete walls of `ApprovalProcessSchema` are gone. +- One IR for AI to emit and a human to review; one action vocabulary (ADR-0018 registry) instead of a parallel enum. +- Net deletion: the 313-LOC parallel `action-executor.ts`, `ApprovalActionType`, the M5 compiler, and the `connector_action` remnants. + +**Cost / risk** +- ~1500-LOC plugin refactor — but roughly half is *keep-and-re-home* (approver resolution ~200 LOC, state objects, lock hooks ~250 LOC), not rewrite. No data migration (greenfield). +- The engine core must generalize durable-pause into "resume on external human decision"; today only `screen` exercises the pause path. +- **Primary risk:** a refactor that degrades approval into a bare pause node and drops approver richness / escalation / recall / audit. Mitigated by enumerating these as required node capabilities (above) and by keeping the existing `approval-service.test.ts` / `phase-b.test.ts` behavioral suites green against the new node. + +## Phased plan + +Tracked separately from the ADR-0018 PR. The first three phases land **additively** — the +node path is built and proven green *beside* the standalone engine, so the destructive +removal (A4/A5) can be reviewed and sequenced on its own once consumers move over. + +1. **A1 — this ADR.** ✅ **Done.** Fix the boundary before code. +2. **A2 — engine durable pause + node config schema.** ✅ **Done.** Generalized the engine's + durable-pause into a real **suspend/resume** primitive (`AutomationResult.status: 'paused'` + + `runId`, `IAutomationService.resume` / `listSuspendedRuns`, in-memory `suspendedRuns`; + the `screen` node opts in via `config.waitForInput`). Added the canonical Approval **node** + config (`ApprovalNodeConfigSchema`, `APPROVAL_NODE_TYPE`, `ApprovalDecision`, + `APPROVAL_BRANCH_LABELS`) lowering `ApproverType` / `behavior` / `escalation` / + `lockRecord` / `approvalStatusField` to node config; deprecated `ApprovalProcessSchema` + (JSDoc) without removing it yet. +3. **A3 — node provider (additive).** ✅ **Done.** `plugin-approvals` now contributes the + `approval` node via the ADR-0018 registry (`approval-node.ts`): on entry it opens a + `sys_approval_request` (reusing approver resolution / audit / lock / status mirror verbatim) + and **suspends**; `decideApprovalNode` finalizes and **resumes** the run down the matching + `approve` / `reject` edge. New correlation fields on `sys_approval_request` + (`flow_run_id` / `flow_node_id` / `node_config_json`). The standalone process engine is left + intact for the migration window. +4. **A4 — delete parallel pieces.** ⏳ **Follow-up PR (destructive).** Remove + `action-executor.ts`, `ApprovalActionType`, `ApprovalProcessSchema` (top-level) + + `approval.form.ts`; route all actions through the ADR-0018 registry; retire `process_hash` + pinning in favor of Flow pinning. Gated on consumers (CRM examples, API routes, app seeders, + `metadata-type-schemas.ts` / `metadata-form-registry.ts`) migrating off the process model. +5. **A5 — cleanup.** ⏳ **Follow-up PR.** Remove the `workflow_rule` paradigm remnants (the M5 + compiler itself was already removed in #1398) and `connector_action` remnants in + `flow.zod.ts`; migrate `approval-service.test.ts` / `phase-b.test.ts` to drive the + Approval node. + +> **Landed in this PR:** A1–A3. The engine gained real durable suspend/resume (P1), spec gained +> the Approval node contract (P2), and `plugin-approvals` gained the working node bridge (P3) — +> all additive and green (spec 6605, service-automation 79, plugin-approvals 41). A4/A5 are the +> destructive removal of the now-superseded standalone engine and are deliberately a separate PR. + +## Migration map + +| Asset | Disposition | +|:---|:---| +| `plugin-approvals` execution loop + `action-executor.ts` | **Delete** (engine + actions now Flow's) | +| `ApprovalActionType`, `connector_action` remnants, M5 compiler | **Delete** | +| `ApprovalProcessSchema`, `approval.form.ts` (top-level authoring type) | **Deprecate / remove** — concepts → Approval node config + Flow graph | +| `ApproverType`, `behavior`, `escalation`, `lockRecord`, `approvalStatusField` | **Re-home** → Approval node config schema | +| Approver resolution (team/dept BFS/manager/role/queue) | **Keep** (move under node, ~verbatim) | +| `sys_approval_request` / `sys_approval_action`, lock hook, status mirror | **Keep** (first-class approval state, owned by the plugin) | +| `approval-service.test.ts` / `phase-b.test.ts` | **Migrate** to drive the Approval node | + +## Tiering (open-source vs enterprise) + +The open-source / enterprise split is **not** an architectural concern and is **out of scope for this ADR** — the open registry (ADR-0018) plus the node-config shape make the tier line a *packaging* decision (which approver types / orchestration features ship in which package), not an engine boundary. The split is maintained privately in `cloud/docs/design/approval-tiering.md`. This ADR keeps the engine and the node contract tier-neutral. + diff --git a/packages/platform-objects/src/audit/sys-approval-request.object.ts b/packages/platform-objects/src/audit/sys-approval-request.object.ts index 4cbc3d2ace..66174fa38b 100644 --- a/packages/platform-objects/src/audit/sys-approval-request.object.ts +++ b/packages/platform-objects/src/audit/sys-approval-request.object.ts @@ -175,6 +175,36 @@ export const SysApprovalRequest = ObjectSchema.create({ group: 'State', }), + // ── ADR-0019: approval-as-flow-node correlation ────────────────── + // When a request is opened by an Approval *node* (rather than a standalone + // process), these tie it back to the suspended flow run so a decision can + // resume it. Null for legacy process-driven requests. + flow_run_id: Field.text({ + label: 'Flow Run', + required: false, + maxLength: 100, + readonly: true, + description: 'Suspended automation run id this request gates (ADR-0019). The decision resumes it.', + group: 'State', + }), + + flow_node_id: Field.text({ + label: 'Flow Node', + required: false, + maxLength: 100, + readonly: true, + description: 'Approval node id within the flow that opened this request (ADR-0019).', + group: 'State', + }), + + node_config_json: Field.textarea({ + label: 'Node Config', + required: false, + readonly: true, + description: 'Snapshot of the Approval node config (approvers/behavior) for node-driven requests (ADR-0019).', + group: 'State', + }), + completed_at: Field.datetime({ label: 'Completed At', required: false, diff --git a/packages/plugins/plugin-approvals/package.json b/packages/plugins/plugin-approvals/package.json index 8cb2bbf3a1..a5db510926 100644 --- a/packages/plugins/plugin-approvals/package.json +++ b/packages/plugins/plugin-approvals/package.json @@ -24,6 +24,7 @@ "@objectstack/spec": "workspace:*" }, "devDependencies": { + "@objectstack/service-automation": "workspace:*", "@types/node": "^25.9.1", "typescript": "^6.0.3", "vitest": "^4.1.7" diff --git a/packages/plugins/plugin-approvals/src/approval-node.test.ts b/packages/plugins/plugin-approvals/src/approval-node.test.ts new file mode 100644 index 0000000000..6d2a0f07bf --- /dev/null +++ b/packages/plugins/plugin-approvals/src/approval-node.test.ts @@ -0,0 +1,184 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { AutomationEngine } from '@objectstack/service-automation'; +import { ApprovalService } from './approval-service.js'; +import { registerApprovalNode, decideApprovalNode } from './approval-node.js'; + +const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as any; + +const noopLogger = { + info() {}, warn() {}, error() {}, debug() {}, +}; + +/** + * Tiny in-memory ObjectQL stand-in — supports the `where`-equality + `$in` + * queries the approval service issues, enough to drive the node bridge. + */ +function makeFakeEngine() { + const tables = new Map(); + const rows = (o: string) => (tables.get(o) ?? (tables.set(o, []), tables.get(o)!)); + const matches = (row: any, where: any) => Object.entries(where ?? {}).every(([k, v]) => { + if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]); + if (v && typeof v === 'object' && '$ne' in (v as any)) return row[k] !== (v as any).$ne; + return row[k] === v; + }); + return { + tables, + async find(object: string, opts: any = {}) { + const where = opts.where ?? opts.filter ?? {}; + let out = rows(object).filter(r => matches(r, where)); + if (opts.limit) out = out.slice(0, opts.limit); + return out.map(r => ({ ...r })); + }, + async insert(object: string, data: any) { + rows(object).push({ ...data }); + return { ...data }; + }, + async update(object: string, idOrData: any) { + const id = idOrData.id; + const row = rows(object).find(r => r.id === id); + if (row) Object.assign(row, idOrData); + return row ? { ...row } : null; + }, + async delete(object: string, opts: any = {}) { + const where = opts.where ?? {}; + const list = rows(object); + for (let i = list.length - 1; i >= 0; i--) if (matches(list[i], where)) list.splice(i, 1); + return { affected: 1 }; + }, + }; +} + +function registerDecisionFlow(engine: AutomationEngine, approvers: Array<{ type: string; value?: string }>, behavior?: 'first_response' | 'unanimous') { + engine.registerFlow('deal_approval', { + name: 'deal_approval', + label: 'Deal Approval', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'approve_step', type: 'approval', label: 'Manager Approval', config: { approvers, behavior } }, + { id: 'on_approved', type: 'mark', label: 'Approved' }, + { id: 'on_rejected', type: 'mark', label: 'Rejected' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'approve_step' }, + { id: 'e2', source: 'approve_step', target: 'on_approved', label: 'approve' }, + { id: 'e3', source: 'approve_step', target: 'on_rejected', label: 'reject' }, + { id: 'e4', source: 'on_approved', target: 'end' }, + { id: 'e5', source: 'on_rejected', target: 'end' }, + ], + }); +} + +describe('Approval node bridge (ADR-0019)', () => { + let automation: AutomationEngine; + let service: ApprovalService; + let fake: ReturnType; + const marks: string[] = []; + + beforeEach(() => { + marks.length = 0; + automation = new AutomationEngine(noopLogger as any); + fake = makeFakeEngine(); + service = new ApprovalService({ engine: fake as any, logger: noopLogger }); + registerApprovalNode(automation, service, noopLogger); + // A terminal "mark" node records which branch ran. + automation.registerNodeExecutor({ + type: 'mark', + async execute(node: any) { marks.push(node.id); return { success: true }; }, + }); + }); + + it('publishes an approval action descriptor that supports pause', () => { + const descriptors = automation.getActionDescriptors(); + const approval = descriptors.find(d => d.type === 'approval'); + expect(approval).toBeDefined(); + expect(approval!.supportsPause).toBe(true); + expect(approval!.category).toBe('human'); + }); + + it('suspends the run on entry and opens a pending request', async () => { + registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]); + const result = await automation.execute('deal_approval', { + object: 'crm_deal', + record: { id: 'd1', amount: 100 }, + userId: 'submitter', + }); + expect(result.status).toBe('paused'); + expect(result.runId).toBeDefined(); + expect(marks).toHaveLength(0); + + const requests = await fake.find('sys_approval_request', { where: { status: 'pending' } }); + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ + object_name: 'crm_deal', record_id: 'd1', flow_run_id: result.runId, flow_node_id: 'approve_step', + }); + // Surfaced as a suspended run with the request id as correlation. + const suspended = automation.listSuspendedRuns(); + expect(suspended[0]).toMatchObject({ nodeId: 'approve_step', correlation: requests[0].id }); + }); + + it('resumes down the approve branch on approval', async () => { + registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]); + const paused = await automation.execute('deal_approval', { + object: 'crm_deal', record: { id: 'd1' }, userId: 'submitter', + }); + const request = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0]; + + const out = await decideApprovalNode(automation, service, request.id, + { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX, noopLogger); + + expect(out).toMatchObject({ finalized: true, decision: 'approve', resumed: true }); + expect(marks).toEqual(['on_approved']); + expect(automation.listSuspendedRuns()).toHaveLength(0); + + const finalReq = (await fake.find('sys_approval_request', { where: { id: request.id } }))[0]; + expect(finalReq.status).toBe('approved'); + expect(paused.runId).toBeDefined(); + }); + + it('resumes down the reject branch on rejection', async () => { + registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]); + await automation.execute('deal_approval', { object: 'crm_deal', record: { id: 'd1' } }); + const request = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0]; + + const out = await decideApprovalNode(automation, service, request.id, + { decision: 'reject', actorId: 'u1' }, SYSTEM_CTX, noopLogger); + + expect(out).toMatchObject({ finalized: true, decision: 'reject', resumed: true }); + expect(marks).toEqual(['on_rejected']); + }); + + it('holds a unanimous step until every approver acts, then resumes', async () => { + registerDecisionFlow(automation, [ + { type: 'user', value: 'u1' }, + { type: 'user', value: 'u2' }, + ], 'unanimous'); + await automation.execute('deal_approval', { object: 'crm_deal', record: { id: 'd1' } }); + const request = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0]; + + const first = await decideApprovalNode(automation, service, request.id, + { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX, noopLogger); + expect(first.finalized).toBe(false); + expect(first.resumed).toBe(false); + expect(marks).toHaveLength(0); + + const second = await decideApprovalNode(automation, service, request.id, + { decision: 'approve', actorId: 'u2' }, SYSTEM_CTX, noopLogger); + expect(second.finalized).toBe(true); + expect(second.resumed).toBe(true); + expect(marks).toEqual(['on_approved']); + }); + + it('rejects a decision from a non-approver', async () => { + registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]); + await automation.execute('deal_approval', { object: 'crm_deal', record: { id: 'd1' } }); + const request = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0]; + + await expect( + service.decideNode(request.id, { decision: 'approve', actorId: 'intruder' }, { isSystem: false, roles: [], permissions: [] } as any), + ).rejects.toThrow(/FORBIDDEN/); + }); +}); diff --git a/packages/plugins/plugin-approvals/src/approval-node.ts b/packages/plugins/plugin-approvals/src/approval-node.ts new file mode 100644 index 0000000000..7ad0ce4b1c --- /dev/null +++ b/packages/plugins/plugin-approvals/src/approval-node.ts @@ -0,0 +1,163 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Approval-as-flow-node provider (ADR-0019). + * + * Registers an `approval` node executor on the automation engine so an approval + * rides the one flow engine as a durable-pause node: + * + * 1. On entry the node opens a `sys_approval_request` (reusing the mature + * approver-resolution / audit / lock / status-mirror machinery) and returns + * `{ suspend: true }` — the engine persists the run and stops traversal. + * 2. A decision (`decideApprovalNode`) finalizes the request and resumes the + * run down the matching `approve` / `reject` out-edge. + * + * The approval *state* (request/action rows) stays first-class and owned by this + * plugin — a flow-run log can't drive an inbox / recall / audit. Only the + * orchestration (when to pause, which branch to take) moves onto the engine. + */ + +import { + defineActionDescriptor, + ApprovalNodeConfigSchema, + APPROVAL_NODE_TYPE, + APPROVAL_BRANCH_LABELS, + type ApprovalNodeConfig, +} from '@objectstack/spec/automation'; +import type { SharingExecutionContext } from '@objectstack/spec/contracts'; +import type { ApprovalService } from './approval-service.js'; + +/** Minimal surface of the automation engine this provider depends on. */ +export interface ApprovalAutomationSurface { + registerNodeExecutor(executor: { + type: string; + descriptor?: unknown; + execute(node: any, variables: Map, context: any): Promise<{ + success: boolean; + output?: Record; + error?: string; + suspend?: boolean; + correlation?: string; + }>; + }): void; + resume?(runId: string, signal?: { output?: Record; branchLabel?: string }): Promise; +} + +interface MinimalLogger { + info?: (msg: any, ...rest: any[]) => void; + warn?: (msg: any, ...rest: any[]) => void; +} + +const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; + +/** + * Register the `approval` node executor on the automation engine. Idempotent at + * the engine level (re-registering replaces). Safe to skip when no automation + * service is present. + */ +export function registerApprovalNode( + automation: ApprovalAutomationSurface, + service: ApprovalService, + logger?: MinimalLogger, +): void { + automation.registerNodeExecutor({ + type: APPROVAL_NODE_TYPE, + descriptor: defineActionDescriptor({ + type: APPROVAL_NODE_TYPE, + version: '1.0.0', + name: 'Approval', + description: 'Route a record for human approval; suspends the flow until a decision, ' + + 'then continues down the approve / reject branch.', + icon: 'check-circle', + category: 'human', + paradigms: ['flow'], + source: 'plugin', + // Human decision: the run suspends here awaiting an external reply. + supportsPause: true, + isAsync: true, + }), + async execute(node, variables, context) { + const parsed = ApprovalNodeConfigSchema.safeParse(node.config ?? {}); + if (!parsed.success) { + const msg = parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; '); + return { success: false, error: `Approval node '${node.id}' has invalid config: ${msg}` }; + } + const config = parsed.data as ApprovalNodeConfig; + + const runId = variables.get('$runId'); + const record = (variables.get('$record') ?? context?.record ?? {}) as Record; + const object = (context?.object ?? (record as any)?.object_name) as string | undefined; + const recordId = (record as any)?.id as string | undefined; + + if (!runId) return { success: false, error: `Approval node '${node.id}': missing $runId` }; + if (!object) return { success: false, error: `Approval node '${node.id}': no target object in context` }; + if (!recordId) return { success: false, error: `Approval node '${node.id}': no record id in $record` }; + + try { + const request = await service.openNodeRequest({ + object, + recordId: String(recordId), + runId: String(runId), + nodeId: node.id, + config, + flowName: context?.flowName, + submitterId: context?.userId ?? null, + record, + organizationId: context?.organizationId ?? context?.tenantId ?? null, + }, { + ...SYSTEM_CTX, + userId: context?.userId, + organizationId: context?.organizationId, + tenantId: context?.tenantId, + } as unknown as SharingExecutionContext); + + logger?.info?.('[approvals] approval node suspended run', { + node: node.id, request: request.id, run: String(runId), + }); + // Suspend the run; the request id is the correlation key surfaced on + // the suspended-run record for lookup. + return { success: true, suspend: true, correlation: request.id }; + } catch (err: any) { + return { success: false, error: `Approval node '${node.id}': ${err?.message ?? String(err)}` }; + } + }, + }); + + logger?.info?.('[approvals] approval node executor registered'); +} + +/** + * Record a decision on a node-driven approval request and, when it finalizes, + * resume the suspended flow run down the matching branch. Returns the service + * decision result so callers (API routes) can surface request state. + */ +export async function decideApprovalNode( + automation: ApprovalAutomationSurface, + service: ApprovalService, + requestId: string, + input: { decision: 'approve' | 'reject'; actorId: string; comment?: string }, + context: SharingExecutionContext, + logger?: MinimalLogger, +): Promise<{ requestId: string; finalized: boolean; decision: 'approve' | 'reject'; resumed: boolean }> { + const result = await service.decideNode(requestId, input, context); + + let resumed = false; + if (result.finalized && result.runId && typeof automation.resume === 'function') { + const branchLabel = result.decision === 'approve' + ? APPROVAL_BRANCH_LABELS.approve + : APPROVAL_BRANCH_LABELS.reject; + try { + await automation.resume(result.runId, { + branchLabel, + output: { decision: result.decision, requestId }, + }); + resumed = true; + } catch (err: any) { + logger?.warn?.('[approvals] resume after decision failed', { + request: requestId, run: result.runId, error: err?.message ?? String(err), + }); + } + } + + return { requestId, finalized: result.finalized, decision: result.decision, resumed }; +} diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index 3b5af0c3fe..e1acbbd75f 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { ApprovalProcessSchema } from '@objectstack/spec/automation'; +import { ApprovalProcessSchema, type ApprovalNodeConfig } from '@objectstack/spec/automation'; import type { IApprovalService, ApprovalProcessRow, @@ -798,6 +798,173 @@ export class ApprovalService implements IApprovalService { return { request: fresh!, finalized: true }; } + // ── ADR-0019: Approval-as-flow-node ────────────────────────── + // + // A flow's Approval node opens a request via `openNodeRequest` (carrying its + // own approvers/behavior config and the suspended run id), then suspends. A + // later `decideNode` finalizes it; the node provider resumes the flow run + // down the matching `approve`/`reject` edge. These reuse the same approver + // expansion, audit rows, lock (the beforeUpdate hook keys on a *pending* + // request, so finalizing auto-releases it), and status mirror as the + // process-driven path — only the trigger and continuation differ. + + /** Mirror a request status onto a business-object field, if configured. */ + private async mirrorStatusField(object: string, recordId: string, field: string, status: string): Promise { + try { + await this.engine.update(object, { id: recordId, [field]: status }, { context: SYSTEM_CTX }); + } catch (err: any) { + this.logger?.warn?.(`[approvals] mirrorStatusField failed: ${err?.message ?? err}`); + } + } + + /** + * Open a pending approval request on behalf of a flow's Approval node + * (ADR-0019). Self-contained: the node config (approvers/behavior/status + * field) is snapshotted on the row, since a node-driven request has no + * `sys_approval_process` to resolve against. + */ + async openNodeRequest( + input: { + object: string; + recordId: string; + runId: string; + nodeId: string; + config: ApprovalNodeConfig; + flowName?: string; + submitterId?: string | null; + record?: any; + organizationId?: string | null; + }, + context: SharingExecutionContext, + ): Promise { + if (!input.object) throw new Error('VALIDATION_FAILED: object is required'); + if (!input.recordId) throw new Error('VALIDATION_FAILED: recordId is required'); + if (!input.runId) throw new Error('VALIDATION_FAILED: runId is required'); + + // One pending request per (object, record) — same guard as submit(). + const existing = await this.engine.find('sys_approval_request', { + where: { object_name: input.object, record_id: input.recordId, status: 'pending' }, + limit: 1, context: SYSTEM_CTX, + }); + if (Array.isArray(existing) && existing[0]) { + throw new Error(`DUPLICATE_REQUEST: a pending approval already exists for ${input.object}/${input.recordId}`); + } + + const ctxOrg = (context as any)?.organizationId ?? (context as any)?.tenantId ?? input.organizationId ?? null; + const approvers = await this.expandApprovers({ approvers: input.config.approvers }, input.record, ctxOrg); + + const now = this.clock.now().toISOString(); + const id = uid('areq'); + const processName = `flow:${input.flowName ?? input.nodeId}`; + const row: any = { + id, + process_name: processName, + object_name: input.object, + record_id: input.recordId, + submitter_id: input.submitterId ?? context.userId ?? null, + status: 'pending', + current_step: input.nodeId, + current_step_index: 0, + pending_approvers: approvers.join(','), + payload_json: input.record != null ? JSON.stringify(input.record) : null, + flow_run_id: input.runId, + flow_node_id: input.nodeId, + node_config_json: JSON.stringify(input.config), + organization_id: ctxOrg, + created_at: now, + updated_at: now, + }; + await this.engine.insert('sys_approval_request', row, { context: SYSTEM_CTX }); + await this.engine.insert('sys_approval_action', { + id: uid('aact'), request_id: id, organization_id: ctxOrg, + step_name: input.nodeId, step_index: 0, action: 'submit', + actor_id: input.submitterId ?? context.userId ?? null, comment: null, created_at: now, + }, { context: SYSTEM_CTX }); + + if (input.config.lockRecord !== false) { + // Lock is enforced by the existing beforeUpdate hook keyed on a pending + // request; no extra write needed here. + } + if (input.config.approvalStatusField) { + await this.mirrorStatusField(input.object, input.recordId, input.config.approvalStatusField, 'pending'); + } + + return rowFromRequest(row); + } + + /** + * Record a decision on a node-driven request (ADR-0019). Honours the node's + * `unanimous` behavior (holds until every approver has approved). When the + * request finalizes, returns the suspended run id + node id so the node + * provider can resume the flow down the matching branch. + */ + async decideNode( + requestId: string, + input: { decision: 'approve' | 'reject'; actorId: string; comment?: string }, + context: SharingExecutionContext, + ): Promise<{ request: ApprovalRequestRow; runId: string | null; nodeId: string | null; finalized: boolean; decision: 'approve' | 'reject' }> { + if (!requestId) throw new Error('VALIDATION_FAILED: requestId is required'); + if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required'); + if (input.decision !== 'approve' && input.decision !== 'reject') { + throw new Error('VALIDATION_FAILED: decision must be approve|reject'); + } + + // Read the raw row to reach flow_* correlation + the node config snapshot. + const rawRows = await this.engine.find('sys_approval_request', { + where: { id: requestId }, limit: 1, context: SYSTEM_CTX, + }); + const raw: any = Array.isArray(rawRows) ? rawRows[0] : null; + if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`); + if (raw.status !== 'pending') throw new Error(`INVALID_STATE: request is ${raw.status}`); + + const pendingApprovers = csvSplit(raw.pending_approvers); + if (!context.isSystem && !pendingApprovers.includes(input.actorId)) { + throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`); + } + + const config = parseJson(raw.node_config_json, { approvers: [], behavior: 'first_response' } as any); + const org = raw.organization_id ?? null; + const nodeId: string | null = raw.flow_node_id ?? raw.current_step ?? null; + const runId: string | null = raw.flow_run_id ?? null; + const now = this.clock.now().toISOString(); + + // Audit the decision first so the unanimous tally below sees it. + await this.engine.insert('sys_approval_action', { + id: uid('aact'), request_id: requestId, organization_id: org, + step_name: nodeId, step_index: 0, action: input.decision, + actor_id: input.actorId, comment: input.comment ?? null, created_at: now, + }, { context: SYSTEM_CTX }); + + // Unanimous approve: advance only once every approver has approved. + if (input.decision === 'approve' && config.behavior === 'unanimous') { + const original = await this.expandApprovers( + { approvers: config.approvers }, parseJson(raw.payload_json, undefined), org, + ); + const acts = await this.engine.find('sys_approval_action', { + where: { request_id: requestId, step_index: 0, action: 'approve' }, limit: 500, context: SYSTEM_CTX, + }); + const approved = new Set((acts ?? []).map((a: any) => String(a.actor_id ?? '')).filter(Boolean)); + const stillPending = original.filter(a => !approved.has(a)); + if (stillPending.length > 0) { + await this.engine.update('sys_approval_request', { + id: requestId, pending_approvers: stillPending.join(','), updated_at: now, + }, { context: SYSTEM_CTX }); + const fresh = await this.getRequest(requestId, context); + return { request: fresh!, runId, nodeId, finalized: false, decision: input.decision }; + } + } + + const finalStatus = input.decision === 'approve' ? 'approved' : 'rejected'; + await this.engine.update('sys_approval_request', { + id: requestId, status: finalStatus, pending_approvers: null, completed_at: now, updated_at: now, + }, { context: SYSTEM_CTX }); + if (config.approvalStatusField) { + await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, finalStatus); + } + const fresh = await this.getRequest(requestId, context); + return { request: fresh!, runId, nodeId, finalized: true, decision: input.decision }; + } + async listActions(requestId: string, context: SharingExecutionContext): Promise { if (!requestId) return []; // Tenant gate: ensure the caller can see the parent request before diff --git a/packages/plugins/plugin-approvals/src/approvals-plugin.ts b/packages/plugins/plugin-approvals/src/approvals-plugin.ts index c70e6dc1be..f9d46d2918 100644 --- a/packages/plugins/plugin-approvals/src/approvals-plugin.ts +++ b/packages/plugins/plugin-approvals/src/approvals-plugin.ts @@ -8,6 +8,7 @@ import { } from '@objectstack/platform-objects/audit'; import { ApprovalService, type ApprovalEngine } from './approval-service.js'; import { bindProcessHooks, unbindAllHooks } from './lifecycle-hooks.js'; +import { registerApprovalNode, type ApprovalAutomationSurface } from './approval-node.js'; export interface ApprovalsPluginOptions { /** Disable runtime registration (schemas still register). */ @@ -106,6 +107,18 @@ export class ApprovalsServicePlugin implements Plugin { ctx.registerService('approvals', this.service); ctx.logger.info('ApprovalsServicePlugin: service registered'); + + // ADR-0019: contribute the `approval` node to the flow engine when one is + // present. Optional — the manual approval API works without it; this is the + // bridge that lets a flow suspend on an Approval node and resume on decision. + try { + const automation = ctx.getService('automation'); + if (automation && typeof automation.registerNodeExecutor === 'function') { + registerApprovalNode(automation, this.service, ctx.logger); + } + } catch { + ctx.logger.info('ApprovalsServicePlugin: no automation engine — approval node not registered'); + } } private async rebindHooks(): Promise { diff --git a/packages/plugins/plugin-approvals/src/index.ts b/packages/plugins/plugin-approvals/src/index.ts index d4d355bfb1..d40cac652d 100644 --- a/packages/plugins/plugin-approvals/src/index.ts +++ b/packages/plugins/plugin-approvals/src/index.ts @@ -23,6 +23,11 @@ export { ApprovalsServicePlugin, type ApprovalsPluginOptions, } from './approvals-plugin.js'; +export { + registerApprovalNode, + decideApprovalNode, + type ApprovalAutomationSurface, +} from './approval-node.js'; export type { IApprovalService, ApprovalProcessRow, diff --git a/packages/services/service-automation/src/builtin/screen-nodes.ts b/packages/services/service-automation/src/builtin/screen-nodes.ts index 309a37c954..6303d3fa72 100644 --- a/packages/services/service-automation/src/builtin/screen-nodes.ts +++ b/packages/services/service-automation/src/builtin/screen-nodes.ts @@ -9,10 +9,12 @@ import type { AutomationEngine } from '../engine.js'; * Part of the core flow capability, so the {@link AutomationServicePlugin} * seeds them directly (ADR-0018) rather than shipping a separate plugin. * - * - 'screen' nodes are pass-through on the server. The engine already injects - * `isInput: true` flow variables from `context.params` into the top-level - * variables map before execution begins, so screen nodes have no remaining - * server-side work. + * - 'screen' nodes are pass-through on the server by default. The engine already + * injects `isInput: true` flow variables from `context.params` into the + * top-level variables map before execution begins, so a plain screen node has + * no remaining server-side work. A screen with `config.waitForInput === true` + * instead opts into the engine's durable pause (ADR-0019): it suspends the run + * on entry and continues via `resume()` once the input arrives. * - 'script' nodes dispatch by `config.actionType`. Currently only 'email' * has a (logger-backed) implementation; unknown action types still succeed * so flows can continue and downstream nodes can react. @@ -28,7 +30,12 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext // Human-input nodes suspend the flow awaiting input. supportsPause: true, isAsync: true, }), - async execute(_node, _variables, _context) { + async execute(node, _variables, _context) { + const cfg = (node.config ?? {}) as Record; + // Opt-in durable pause: suspend the run awaiting the screen's input. + if (cfg.waitForInput === true) { + return { success: true, suspend: true }; + } return { success: true }; }, }); diff --git a/packages/services/service-automation/src/engine.test.ts b/packages/services/service-automation/src/engine.test.ts index 432cb911aa..5d8aa00d1a 100644 --- a/packages/services/service-automation/src/engine.test.ts +++ b/packages/services/service-automation/src/engine.test.ts @@ -343,6 +343,170 @@ describe('AutomationEngine', () => { }); }); + describe('Durable Suspend / Resume (ADR-0019)', () => { + // A node that pauses the run on entry, exposing the run id it captured. + function registerPausingNode(captured: { runId?: unknown }) { + engine.registerNodeExecutor({ + type: 'pause_node', + async execute(_node, variables) { + captured.runId = variables.get('$runId'); + return { success: true, suspend: true, correlation: 'req_1' }; + }, + }); + } + + it('should suspend at a pausing node and return { status: "paused", runId }', async () => { + const captured: { runId?: unknown } = {}; + registerPausingNode(captured); + engine.registerNodeExecutor({ + type: 'after', + async execute() { return { success: true }; }, + }); + + engine.registerFlow('pause_flow', { + name: 'pause_flow', + label: 'Pause Flow', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'pause', type: 'pause_node', label: 'Pause' }, + { id: 'after', type: 'after', label: 'After' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'pause' }, + { id: 'e2', source: 'pause', target: 'after' }, + { id: 'e3', source: 'after', target: 'end' }, + ], + }); + + const result = await engine.execute('pause_flow'); + expect(result.success).toBe(true); + expect(result.status).toBe('paused'); + expect(result.runId).toBeDefined(); + // Run id was injected into the variable context for the node. + expect(captured.runId).toBe(result.runId); + + // It appears in the suspended-runs listing with its correlation. + const suspended = engine.listSuspendedRuns(); + expect(suspended).toHaveLength(1); + expect(suspended[0]).toMatchObject({ + runId: result.runId, + flowName: 'pause_flow', + nodeId: 'pause', + correlation: 'req_1', + }); + }); + + it('should continue downstream nodes on resume', async () => { + const executed: string[] = []; + const captured: { runId?: unknown } = {}; + registerPausingNode(captured); + engine.registerNodeExecutor({ + type: 'after', + async execute(node) { executed.push(node.id); return { success: true }; }, + }); + + engine.registerFlow('resume_flow', { + name: 'resume_flow', + label: 'Resume Flow', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'pause', type: 'pause_node', label: 'Pause' }, + { id: 'after', type: 'after', label: 'After' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'pause' }, + { id: 'e2', source: 'pause', target: 'after' }, + { id: 'e3', source: 'after', target: 'end' }, + ], + }); + + const paused = await engine.execute('resume_flow'); + expect(executed).not.toContain('after'); + + const resumed = await engine.resume(paused.runId!); + expect(resumed.success).toBe(true); + expect(resumed.status).toBeUndefined(); + expect(executed).toContain('after'); + // The suspension is consumed exactly once. + expect(engine.listSuspendedRuns()).toHaveLength(0); + }); + + it('should select the branch named by the resume signal label', async () => { + const executed: string[] = []; + const captured: { runId?: unknown } = {}; + registerPausingNode(captured); + engine.registerNodeExecutor({ + type: 'branch_node', + async execute(node) { executed.push(node.id); return { success: true }; }, + }); + + engine.registerFlow('decision_flow', { + name: 'decision_flow', + label: 'Decision Flow', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'pause', type: 'pause_node', label: 'Approval' }, + { id: 'approved', type: 'branch_node', label: 'Approved' }, + { id: 'rejected', type: 'branch_node', label: 'Rejected' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'pause' }, + { id: 'e2', source: 'pause', target: 'approved', label: 'approve' }, + { id: 'e3', source: 'pause', target: 'rejected', label: 'reject' }, + { id: 'e4', source: 'approved', target: 'end' }, + { id: 'e5', source: 'rejected', target: 'end' }, + ], + }); + + const paused = await engine.execute('decision_flow'); + await engine.resume(paused.runId!, { branchLabel: 'approve', output: { decision: 'approved' } }); + + expect(executed).toContain('approved'); + expect(executed).not.toContain('rejected'); + }); + + it('should fail to resume an unknown run', async () => { + const result = await engine.resume('run_does_not_exist'); + expect(result.success).toBe(false); + expect(result.error).toContain('No suspended run'); + }); + + it('should opt-in screen node into suspend via config.waitForInput', async () => { + const kernel = new LiteKernel(); + kernel.use(new AutomationServicePlugin()); + await kernel.bootstrap(); + const e = kernel.getService('automation'); + + e.registerFlow('screen_wait', { + name: 'screen_wait', + label: 'Screen Wait', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'screen', type: 'screen', label: 'Screen', config: { waitForInput: true } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'screen' }, + { id: 'e2', source: 'screen', target: 'end' }, + ], + }); + + const paused = await e.execute('screen_wait'); + expect(paused.status).toBe('paused'); + + const resumed = await e.resume(paused.runId!); + expect(resumed.success).toBe(true); + expect(resumed.status).toBeUndefined(); + }); + }); + describe('IAutomationService Contract', () => { it('should satisfy IAutomationService interface', () => { const service: IAutomationService = engine; diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 4c37cc83fa..1d377dbaf4 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -2,7 +2,7 @@ import type { FlowParsed, FlowNodeParsed, FlowEdgeParsed } from '@objectstack/spec/automation'; import type { ExecutionLog, ActionDescriptor } from '@objectstack/spec/automation'; -import type { AutomationContext, AutomationResult, IAutomationService } from '@objectstack/spec/contracts'; +import type { AutomationContext, AutomationResult, ResumeSignal, IAutomationService } from '@objectstack/spec/contracts'; import type { Logger } from '@objectstack/spec/contracts'; import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES } from '@objectstack/spec/automation'; @@ -45,6 +45,21 @@ export interface NodeExecutionResult { error?: string; /** Used by decision nodes — returns the selected branch label */ branchLabel?: string; + /** + * ADR-0019 durable pause. When `true`, the node has done its on-entry work + * (e.g. opened an approval request) and the run should **suspend** here: the + * engine persists a continuation, stops traversal, and `execute()` returns + * `{ status: 'paused', runId }`. The run is continued later via + * {@link AutomationEngine.resume}. Any `output` is written to variables + * before suspending. The node reads its own run id from the `$runId` + * flow variable so it can map the run to external state. + */ + suspend?: boolean; + /** + * Optional correlation key surfaced on the suspended-run record (e.g. an + * approval request id). For observability / lookup; not required to resume. + */ + correlation?: string; } // ─── Trigger Interface (Plugin Extension Point) ───────────────────── @@ -92,6 +107,47 @@ interface ExecutionLogEntry { error?: string; } +/** + * Internal sentinel thrown by {@link AutomationEngine.executeNode} when a node + * signals `suspend`. It unwinds the synchronous DAG recursion up to + * `execute()` / `resume()`, which converts it into a persisted continuation + * rather than a failed run. (Not exported — callers see `status: 'paused'`.) + * + * NOTE: suspend is supported on the serial / main execution path. A node that + * suspends inside a `Promise.all` parallel branch will unwind that branch, but + * sibling parallel branches already in flight are not cancelled — durable + * pause across parallel gateways is out of scope for ADR-0019 M1. + */ +class FlowSuspendSignal { + readonly __flowSuspend = true as const; + constructor(readonly nodeId: string, readonly correlation?: string) {} +} + +function isSuspendSignal(err: unknown): err is FlowSuspendSignal { + return typeof err === 'object' && err !== null && (err as FlowSuspendSignal).__flowSuspend === true; +} + +/** + * A run paused at a node, awaiting {@link AutomationEngine.resume}. Held + * in-memory, matching the engine's existing in-memory run model — durable + * persistence of suspended runs across process restart is a follow-up tracked + * with run-state persistence generally (ADR-0019 §Consequences). + */ +interface SuspendedRun { + runId: string; + flowName: string; + flowVersion?: number; + /** The node the run paused at; resume continues from its out-edges. */ + nodeId: string; + /** Snapshot of the flow variable map at suspend time. */ + variables: Record; + steps: StepLogEntry[]; + context: AutomationContext; + startedAt: string; + startTime: number; + correlation?: string; +} + export class AutomationEngine implements IAutomationService { private flows = new Map(); private flowEnabled = new Map(); @@ -103,6 +159,8 @@ export class AutomationEngine implements IAutomationService { private maxLogSize = 1000; private logger: Logger; private runCounter = 0; + /** Runs paused at a node, keyed by runId (ADR-0019). In-memory, see {@link SuspendedRun}. */ + private suspendedRuns = new Map(); constructor(logger: Logger) { this.logger = logger; @@ -295,6 +353,9 @@ export class AutomationEngine implements IAutomationService { } const runId = `run_${++this.runCounter}`; + // Expose the run id to executors (ADR-0019): a pausing node (e.g. Approval) + // reads `$runId` to map its external state back to this run for resume. + variables.set('$runId', runId); const startedAt = new Date().toISOString(); const steps: StepLogEntry[] = []; @@ -347,6 +408,45 @@ export class AutomationEngine implements IAutomationService { durationMs, }; } catch (err: unknown) { + // A node asked to suspend the run (ADR-0019 durable pause). Snapshot + // the live state, record a `paused` log, and return the run id so the + // caller can later `resume()` it. This is NOT a failure. + if (isSuspendSignal(err)) { + const durationMs = Date.now() - startTime; + this.suspendedRuns.set(runId, { + runId, + flowName, + flowVersion: flow.version, + nodeId: err.nodeId, + variables: Object.fromEntries(variables), + steps, + context: context ?? {}, + startedAt, + startTime, + correlation: err.correlation, + }); + this.recordLog({ + id: runId, + flowName, + flowVersion: flow.version, + status: 'paused', + startedAt, + durationMs, + trigger: { + type: context?.event ?? 'manual', + userId: context?.userId, + object: context?.object, + }, + steps, + }); + return { + success: true, + status: 'paused', + runId, + durationMs, + }; + } + const errorMessage = err instanceof Error ? err.message : String(err); // Record failed execution log @@ -380,6 +480,133 @@ export class AutomationEngine implements IAutomationService { } } + /** + * Resume a run suspended at a node (ADR-0019 durable pause). Restores the + * snapshotted variables, merges `signal.output` under the suspended node's + * id, and continues traversal from that node's out-edges — optionally + * restricted to the edge labelled `signal.branchLabel` (e.g. the approval + * decision). The continuation may itself suspend again, in which case this + * returns `{ status: 'paused', runId }` afresh. + */ + async resume(runId: string, signal?: ResumeSignal): Promise { + const run = this.suspendedRuns.get(runId); + if (!run) { + return { success: false, error: `No suspended run '${runId}'` }; + } + const flow = this.flows.get(run.flowName); + if (!flow) { + return { success: false, error: `Flow '${run.flowName}' not found for run '${runId}'` }; + } + const node = flow.nodes.find(n => n.id === run.nodeId); + if (!node) { + return { success: false, error: `Suspended node '${run.nodeId}' no longer exists in flow '${run.flowName}'` }; + } + // Consume the suspension — a run resumes exactly once per pause. + this.suspendedRuns.delete(runId); + + // Restore variable context and apply the resume signal's output as if it + // were the node's output, so downstream edges branch on it. + const variables = new Map(Object.entries(run.variables)); + if (signal?.output) { + for (const [key, value] of Object.entries(signal.output)) { + variables.set(`${run.nodeId}.${key}`, value); + } + } + + const steps = run.steps; + const context = run.context; + + try { + await this.traverseNext(node, flow, variables, context, steps, signal?.branchLabel); + + // Collect output variables + const output: Record = {}; + if (flow.variables) { + for (const v of flow.variables) { + if (v.isOutput) output[v.name] = variables.get(v.name); + } + } + const durationMs = Date.now() - run.startTime; + this.recordLog({ + id: runId, + flowName: run.flowName, + flowVersion: run.flowVersion, + status: 'completed', + startedAt: run.startedAt, + completedAt: new Date().toISOString(), + durationMs, + trigger: { + type: context.event ?? 'manual', + userId: context.userId, + object: context.object, + }, + steps, + output, + }); + return { success: true, output, durationMs }; + } catch (err: unknown) { + // Re-suspended at a downstream node: persist a fresh continuation. + if (isSuspendSignal(err)) { + const durationMs = Date.now() - run.startTime; + this.suspendedRuns.set(runId, { + ...run, + nodeId: err.nodeId, + variables: Object.fromEntries(variables), + steps, + correlation: err.correlation, + }); + this.recordLog({ + id: runId, + flowName: run.flowName, + flowVersion: run.flowVersion, + status: 'paused', + startedAt: run.startedAt, + durationMs, + trigger: { + type: context.event ?? 'manual', + userId: context.userId, + object: context.object, + }, + steps, + }); + return { success: true, status: 'paused', runId, durationMs }; + } + + const errorMessage = err instanceof Error ? err.message : String(err); + const durationMs = Date.now() - run.startTime; + this.recordLog({ + id: runId, + flowName: run.flowName, + flowVersion: run.flowVersion, + status: 'failed', + startedAt: run.startedAt, + completedAt: new Date().toISOString(), + durationMs, + trigger: { + type: context.event ?? 'manual', + userId: context.userId, + object: context.object, + }, + steps, + error: errorMessage, + }); + return { success: false, error: errorMessage, durationMs }; + } + } + + /** + * List the runs currently suspended awaiting {@link resume} (ADR-0019). + * Backs operability surfaces such as a "pending approvals" view. + */ + listSuspendedRuns(): Array<{ runId: string; flowName: string; nodeId: string; correlation?: string }> { + return [...this.suspendedRuns.values()].map(r => ({ + runId: r.runId, + flowName: r.flowName, + nodeId: r.nodeId, + correlation: r.correlation, + })); + } + // ── DAG Traversal Core ────────────────────────────────── private recordLog(entry: ExecutionLogEntry): void { @@ -628,13 +855,49 @@ export class AutomationEngine implements IAutomationService { variables.set(`${node.id}.${key}`, value); } } + + // ADR-0019 durable pause: the node did its on-entry work and asked to + // suspend here. Output is already written above; unwind the recursion + // up to execute()/resume(), which persists a continuation. Traversal + // of this node's out-edges happens on resume, not now. + if (result.suspend) { + throw new FlowSuspendSignal(node.id, result.correlation); + } } + // Continue to the node's successors. + await this.traverseNext(node, flow, variables, context, steps); + } + + /** + * Traverse a node's out-edges and execute its successors. Split out of + * {@link executeNode} so {@link resume} can re-enter traversal from a + * suspended node without re-running the node body. + * + * @param branchLabel - When set (e.g. from a resume signal), restrict + * traversal to out-edges whose `label` matches — this is how an Approval + * node's `approve`/`reject` decision selects its downstream branch. When + * no edge carries the label, traversal falls back to the normal edge set. + */ + private async traverseNext( + node: FlowNodeParsed, + flow: FlowParsed, + variables: Map, + context: AutomationContext, + steps: StepLogEntry[], + branchLabel?: string, + ): Promise { // Find next nodes — separate conditional and unconditional edges - const outEdges = flow.edges.filter( + let outEdges = flow.edges.filter( e => e.source === node.id && e.type !== 'fault', ); + // Branch selection (resume): prefer edges tagged with the decision label. + if (branchLabel) { + const labeled = outEdges.filter(e => e.label === branchLabel); + if (labeled.length > 0) outEdges = labeled; + } + const conditionalEdges: FlowEdgeParsed[] = []; const unconditionalEdges: FlowEdgeParsed[] = []; for (const edge of outEdges) { diff --git a/packages/spec/src/automation/approval.zod.ts b/packages/spec/src/automation/approval.zod.ts index ee985d0deb..1b79b46d8f 100644 --- a/packages/spec/src/automation/approval.zod.ts +++ b/packages/spec/src/automation/approval.zod.ts @@ -21,6 +21,12 @@ export const ApproverType = z.enum([ /** * Approval Action Type * Actions to execute on transition + * + * @deprecated ADR-0019 — actions are no longer attached to approval steps. + * In the flow model, "on approve / on reject" work is expressed as the + * downstream nodes wired to the Approval node's `approve` / `reject` out-edges + * (any registered action node — http, notify, update_record, …). Retained only + * until {@link ApprovalProcessSchema} is removed. */ export const ApprovalActionType = z.enum([ 'field_update', @@ -76,9 +82,21 @@ export const ApprovalStepSchema = lazySchema(() => z.object({ /** * Approval Process Protocol - * + * * Defines a complex review and approval cycle for a record. * Manages state locking, notifications, and transition logic. + * + * @deprecated ADR-0019 — the standalone approval *authoring* type is collapsed + * into Flow. An approval is now authored as a flow with one or more **Approval + * nodes** (see {@link ApprovalNodeConfigSchema}); the engine rides its durable + * pause. The process-level concepts re-home as follows: + * - `steps` → successive Approval nodes on the canvas + * - `entryCriteria` → the condition on the edge entering the node + * - `onApprove`/`onReject` → the nodes wired to the node's `approve`/`reject` edges + * - `rejectionBehavior: back_to_previous` → a back-edge to an earlier node + * - `lockRecord` / `approvalStatusField` / `escalation` / `behavior` / approvers + * → {@link ApprovalNodeConfigSchema} node config + * This schema is retained only for the migration window and will be removed. */ export const ApprovalProcessSchema = lazySchema(() => z.object({ name: SnakeCaseIdentifierSchema.describe('Unique process name'), @@ -132,3 +150,99 @@ export const ApprovalProcess = Object.assign(ApprovalProcessSchema, { export type ApprovalProcess = z.infer; export type ApprovalStep = z.infer; + +// ========================================================================== +// Approval as a Flow Node (ADR-0019, canonical) +// ========================================================================== + +/** + * Registry node type for the Approval node. The `plugin-approvals` package + * registers an executor under this type (ADR-0018), so an approval rides the + * one flow engine as a durable-pause node rather than a second engine. + */ +export const APPROVAL_NODE_TYPE = 'approval' as const; + +/** + * Canonical decisions an Approval node emits. The engine selects the + * downstream branch by matching these against out-edge `label`s + * (see {@link ApprovalNodeConfigSchema}). + */ +export const ApprovalDecision = z.enum(['approve', 'reject']); +export type ApprovalDecision = z.infer; + +/** + * Edge labels an Approval node's out-edges use to declare which branch a + * decision follows. `resume(runId, { branchLabel })` passes the matching + * label so the engine continues down the right edge. + */ +export const APPROVAL_BRANCH_LABELS = { + approve: 'approve', + reject: 'reject', +} as const; + +/** A single approver assignment on an Approval node. */ +export const ApprovalNodeApproverSchema = lazySchema(() => z.object({ + type: ApproverType, + /** + * The approver reference, interpreted per `type`: a user id (`user`), role + * name (`role`), team/department id (`team`/`department`), field name + * holding a user id (`field`), or queue id (`queue`). Omitted for `manager` + * (resolved from the submitter's `manager_id`). + */ + value: z.string().optional().describe('User id / role / team / department / field / queue — per `type`'), +})); +export type ApprovalNodeApprover = z.infer; + +/** + * Per-node SLA escalation — lowered from {@link ApprovalProcessSchema.escalation} + * to the node, so each Approval step on the canvas carries its own SLA. + */ +export const ApprovalEscalationSchema = lazySchema(() => z.object({ + enabled: z.boolean().default(false).describe('Enable SLA-based escalation for this node'), + timeoutHours: z.number().min(1).describe('Hours before escalation triggers'), + action: z.enum(['reassign', 'auto_approve', 'auto_reject', 'notify']).default('notify') + .describe('Action on escalation timeout'), + escalateTo: z.string().optional().describe('User id, role, or manager level to escalate to'), + notifySubmitter: z.boolean().default(true).describe('Notify the original submitter on escalation'), +})); +export type ApprovalEscalation = z.infer; + +/** + * Config for an **Approval node** (`type: 'approval'`) on a flow — the ADR-0019 + * replacement for an {@link ApprovalStepSchema}. The node opens an approval + * request on entry, suspends the run, and resumes down its `approve` / `reject` + * out-edge once a decision is recorded. + * + * What does NOT live here (re-homed to the flow graph, by design): + * - **entry criteria** → the condition on the edge entering this node + * - **on-approve / on-reject actions** → the nodes wired to the + * `approve` / `reject` out-edges + * - **back-to-previous rejection** → a back-edge to an earlier node + * + * Approval *state* (request/action rows, record lock, status mirror) remains + * first-class engine-adjacent state owned by `plugin-approvals`; this config + * only describes how the node behaves. + */ +export const ApprovalNodeConfigSchema = lazySchema(() => z.object({ + /** Who may act on this step. */ + approvers: z.array(ApprovalNodeApproverSchema).min(1).describe('Allowed approvers for this node'), + + /** How multiple approvers combine. (Enterprise adds quorum/weighted — ADR-0019 tiering.) */ + behavior: z.enum(['first_response', 'unanimous']).default('first_response') + .describe('How to combine multiple approvers'), + + /** Lock the triggering record from edits while this node is pending. */ + lockRecord: z.boolean().default(true).describe('Lock the record from editing while pending'), + + /** + * Field on the business object to mirror the request status onto + * (`pending`/`approved`/`rejected`/`recalled`). Should be readonly on the + * object. Omitted ⇒ status is exposed only via `sys_approval_request`. + */ + approvalStatusField: z.string().optional() + .describe('Business-object field to mirror request status onto'), + + /** Optional per-node SLA escalation. */ + escalation: ApprovalEscalationSchema.optional().describe('Per-node SLA escalation'), +})); +export type ApprovalNodeConfig = z.infer; diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 6f598a93f5..d368944b74 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -45,6 +45,31 @@ export interface AutomationResult { error?: string; /** Execution duration in milliseconds */ durationMs?: number; + /** + * Lifecycle status. `'paused'` means the run suspended at a node (e.g. + * an Approval node awaiting a human decision, ADR-0019) and can be + * continued later with {@link IAutomationService.resume}. Absent or + * `'completed'`/`'failed'` ⇒ the run reached a terminal state. + */ + status?: 'completed' | 'paused' | 'failed'; + /** Run id — set when `status` is `'paused'`, so callers can resume it. */ + runId?: string; +} + +/** Signal payload used to resume a paused run (ADR-0019). */ +export interface ResumeSignal { + /** + * Output to merge into flow variables under the suspended node's id + * (e.g. `{ decision: 'approved' }` → `.decision`). Downstream + * edges branch on it exactly as for a normally-executed node. + */ + output?: Record; + /** + * Optional edge label to select which out-edge of the suspended node to + * follow (e.g. `'approve'` / `'reject'`). When omitted, traversal falls + * back to the node's conditional/unconditional edges. + */ + branchLabel?: string; } export interface IAutomationService { @@ -113,4 +138,22 @@ export interface IAutomationService { * @returns Array of registered action descriptors */ getActionDescriptors?(): ActionDescriptor[]; + + /** + * Resume a run that suspended at a pausing node (ADR-0019). The run must + * have previously returned `{ status: 'paused', runId }` from + * {@link execute} (or a prior `resume`). Continues traversal from the + * suspended node's out-edges, applying `signal.output` / `signal.branchLabel`. + * @param runId - The paused run's id + * @param signal - Optional output to merge and/or branch label to follow + * @returns The result of continuing the run (may itself be `'paused'` again) + */ + resume?(runId: string, signal?: ResumeSignal): Promise; + + /** + * List the currently suspended (paused) runs awaiting a resume — id, the + * flow, the node they paused at, and any correlation key the pausing node + * attached. Backs operability (e.g. a "pending approvals" view). + */ + listSuspendedRuns?(): Array<{ runId: string; flowName: string; nodeId: string; correlation?: string }>; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46e8f0148b..bafbe3bc13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1061,6 +1061,9 @@ importers: specifier: workspace:* version: link:../../spec devDependencies: + '@objectstack/service-automation': + specifier: workspace:* + version: link:../../services/service-automation '@types/node': specifier: ^25.9.1 version: 25.9.1