Skip to content

Latest commit

 

History

History
471 lines (394 loc) · 16.7 KB

File metadata and controls

471 lines (394 loc) · 16.7 KB
title Flow Metadata
description Build visual automation with decision trees, data operations, HTTP requests, and screen interactions

Flow Metadata

A Flow is a visual automation that orchestrates business logic through connected nodes. Flows support decision branching, data operations (CRUD), HTTP integrations, script execution, user screens, and subflow composition.

Basic Structure

const approvalFlow = {
  name: 'order_approval',
  label: 'Order Approval Flow',
  type: 'record_change',
  version: 1,
  status: 'active',
  template: false,
  runAs: 'system',

  variables: [
    { name: 'order_amount', type: 'number', isInput: true, isOutput: false },
    { name: 'approved', type: 'boolean', isInput: false, isOutput: true },
  ],

  nodes: [
    { id: 'start', type: 'start', label: 'Start' },
    {
      id: 'check_amount',
      type: 'decision',
      label: 'Check Amount',
      config: {
        conditions: [
          { label: 'High Value', expression: 'order_amount > 10000' },
          { label: 'Standard', expression: 'order_amount <= 10000' },
        ],
      },
    },
    {
      id: 'auto_approve',
      type: 'update_record',
      label: 'Auto Approve',
      config: { object: 'order', fields: { status: 'approved' } },
    },
    {
      id: 'request_approval',
      type: 'screen',
      label: 'Manager Approval',
      config: { screenType: 'approval' },
    },
    { id: 'end', type: 'end', label: 'End' },
  ],

  edges: [
    { id: 'e1', source: 'start', target: 'check_amount', type: 'default' },
    { id: 'e2', source: 'check_amount', target: 'auto_approve', type: 'default', condition: 'order_amount <= 10000' },
    { id: 'e3', source: 'check_amount', target: 'request_approval', type: 'default', condition: 'order_amount > 10000' },
    { id: 'e4', source: 'auto_approve', target: 'end', type: 'default' },
    { id: 'e5', source: 'request_approval', target: 'end', type: 'default' },
  ],
};

Flow Properties

Property Type Required Description
name string Machine name (snake_case)
label string Display label
description string optional Flow description
version number Version number
status enum 'draft', 'active', 'obsolete', 'invalid'
type FlowType Flow trigger type (see below)
template boolean Is this a reusable subflow template
runAs enum 'system' or 'user' execution context
variables FlowVariable[] optional Input/output variables
nodes FlowNode[] Flow nodes
edges FlowEdge[] Connections between nodes
errorHandling object optional Error handling strategy

Flow Types

Type Description Trigger
autolaunched Runs automatically without user interaction Invoked by other flows or API
record_change Triggered by record create/update/delete Data mutation events
schedule Runs on a schedule (cron) Time-based
screen Interactive flow with user screens User clicks a button
api Exposed as an API endpoint HTTP request

Nodes

Each node performs a specific action in the flow.

Node Types

Type Description
start Flow entry point
end Flow termination
decision Conditional branching (if/else)
assignment Set variable values
loop Structured iteration container — runs a body region once per item (ADR-0031)
parallel Structured parallel block — runs N branch regions concurrently, implicit join (ADR-0031)
try_catch Structured try/catch/retry error handling (ADR-0031)
create_record Create a new record
update_record Update existing records
delete_record Delete records
get_record Query records
http_request Make an HTTP API call
script Execute custom JavaScript
screen Display a user form/screen (durable pause)
wait Pause for a timer or named signal (durable pause; timers auto-resume)
subflow Invoke another flow — a pause inside the child suspends both runs as a linked chain
connector_action Execute an external connector action

Node Structure

{
  id: 'unique_node_id',
  type: 'decision',
  label: 'Check Priority',
  config: { /* type-specific configuration */ },
  position: { x: 200, y: 100 },    // Canvas position (optional)
}
Property Type Required Description
id string Unique node identifier
type FlowNodeAction Node type
label string Display label
config object optional Type-specific configuration
connectorConfig object optional External connector settings
position { x, y } optional Visual position on canvas

Node Examples

Decision (branching):

{
  id: 'check_status',
  type: 'decision',
  label: 'Check Status',
  config: {
    conditions: [
      { label: 'Approved', expression: "status = 'approved'" },
      { label: 'Rejected', expression: "status = 'rejected'" },
    ],
  },
}

Create Record:

{
  id: 'create_task',
  type: 'create_record',
  label: 'Create Follow-up Task',
  config: {
    object: 'task',
    fields: {
      title: 'Follow up on {record.name}',
      assignee: '{record.owner}',
      due_date: 'TODAY() + 7',
    },
  },
}

HTTP Request:

{
  id: 'notify_slack',
  type: 'http_request',
  label: 'Send Slack Notification',
  config: {
    url: 'https://hooks.slack.com/services/...',
    method: 'POST',
    body: { text: 'New order: {record.name}' },
  },
}

Script:

{
  id: 'calculate',
  type: 'script',
  label: 'Calculate Discount',
  config: {
    code: `
      const discount = input.amount > 1000 ? 0.1 : 0.05;
      return { discount_rate: discount };
    `,
  },
}

Structured control flow (ADR-0031)

loop, parallel, and try_catch are structured control-flow constructs — the native, AI-authored model for iteration, concurrency, and error handling. Unlike free-form BPMN gateways (kept in the protocol for import/export interop only), these constructs are well-formed by construction: each owns its body as a self-contained, single-entry/single-exit region carried in config (representation B — a nested sub-graph). Ordinary step-to-step edges stay acyclic — iteration and concurrency are scoped containers, not back-edges — so the DAG invariant is preserved and termination stays analyzable. Regions are validated at registerFlow() (single-entry/single-exit, acyclic, bounded loop); a malformed construct is rejected before the flow can run.

A region runs in the enclosing variable scope (the iterator value and any body mutations are visible to the surrounding flow) — it is not a separate subflow invocation. The container node's ordinary out-edges are the "after-loop / after-block" continuation.

Loop container

Runs its body region once per item of a collection, binding the current item (and optionally its index) as flow variables, under a hard max-iteration guard.

{
  id: 'notify_each',
  type: 'loop',
  label: 'For each task',
  config: {
    collection: '{tasks}',        // template/variable resolving to an array
    iteratorVariable: 'task',     // current item, visible inside the body
    indexVariable: 'i',           // optional zero-based index
    maxIterations: 500,           // hard cap (clamped to the engine ceiling)
    body: {                       // single-entry/single-exit region
      nodes: [
        { id: 'send', type: 'script', label: 'Notify', config: { /* … */ } },
      ],
      edges: [],
    },
  },
}

A loop node with no body keeps the legacy flat-graph behavior — the container is additive.

Parallel block

Declares N branch regions that run concurrently and join implicitly when all complete — there is no author-visible split/join gateway. Branches should write distinct variables (last-writer-wins on collision); a failing branch fails the block.

{
  id: 'fan_out',
  type: 'parallel',
  label: 'Notify in parallel',
  config: {
    branches: [                   // ≥ 2 regions
      { name: 'Email', nodes: [{ id: 'email', type: 'script', label: 'Email', config: { /* … */ } }], edges: [] },
      { name: 'Slack', nodes: [{ id: 'slack', type: 'script', label: 'Slack', config: { /* … */ } }], edges: [] },
    ],
  },
}

Try / catch / retry

Wraps a protected try region; on failure runs the optional catch region (with the caught error bound to errorVariable), optionally retrying the try region first with exponential backoff. This surfaces the engine's existing fault + retry semantics as a structured construct (rather than BPMN boundary events).

{
  id: 'guarded',
  type: 'try_catch',
  label: 'Charge with fallback',
  config: {
    try: { nodes: [{ id: 'charge', type: 'http_request', label: 'Charge', config: { /* … */ } }], edges: [] },
    catch: { nodes: [{ id: 'flag', type: 'update_record', label: 'Flag failure', config: { /* … */ } }], edges: [] },
    errorVariable: '$error',
    retry: { maxRetries: 3, retryDelayMs: 1000, backoffMultiplier: 2 },
  },
}

BPMN parallel_gateway / join_gateway / boundary_event remain in the protocol as the interop representation and map onto these constructs on import/export — they are not the native authoring model.

Durable pause & resume (ADR-0019)

Some nodes don't finish in one pass — they suspend the run and wait for the outside world. The engine snapshots the run (variables, step log, position) to the sys_automation_run table and returns { status: 'paused', runId }; the pause survives a process restart and is continued later through a single entry point:

POST /api/v1/automation/{flow}/runs/{runId}/resume
{ "inputs": { … }, "branchLabel": "approve", "output": { … } }
Pausing node Suspends until… Resumed by
approval a human decision the approvals service (POST /api/v1/approvals/requests/:id/approve|reject) — resumes down the matching approve / reject edge. Always decide through the approvals API, never by calling resume directly: a direct resume strands the pending sys_approval_request, so the record stays locked and later approvals on the same record hit DUPLICATE_REQUEST.
screen a user submits the form the UI runner posting the collected inputs; a paused response carrying the next screen chains multi-step wizards under one stable runId
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)

Nested pause — pausing inside a subflow

A pausing node inside a subflow suspends the whole chain as linked runs: the child run pauses at its node, and the parent pauses at the subflow node (correlation: 'subflow:<childRunId>'). Both continuations are durable, and the chain resolves from either end:

  • deciding the child's approval (or its timer firing) completes the child and bubbles its output back into the parent, which continues with the same ${nodeId}.output / outputVariable mapping as a synchronous subflow;
  • resuming the parent run id (what a UI holds from the original launch) delegates down to the suspended child — multi-screen child wizards keep the parent run id stable.

A child that fails terminally after the pause fails every waiting ancestor, so no run is stranded as resumable-forever. See the worked example pair in the showcase app: showcase_project_closure invokes the reusable showcase_closure_signoff approval subflow and notifies the owner with the bubbled decision.

Observing runs

GET /api/v1/automation/{flow}/runs                 # run history (status, steps, error)
GET /api/v1/automation/{flow}/runs/{runId}         # one run
GET /api/v1/automation/{flow}/runs/{runId}/screen  # re-fetch a paused screen

Each run's steps[] records every executed node — including loop iterations, parallel branch bodies, and try/catch region steps — and the Studio flow designer surfaces the same data in its Runs side panel. Run history is an in-memory ring buffer (the durable rows in sys_automation_run hold only live pauses), so history starts fresh on each boot.

Edges

Edges connect nodes and define the execution path:

{
  id: 'edge_1',
  source: 'check_status',
  target: 'send_email',
  type: 'default',
  condition: "status = 'approved'",
  label: 'Approved',
}
Property Type Required Description
id string Unique edge identifier
source string Source node ID
target string Target node ID
type enum 'default' (success) or 'fault' (error)
condition string optional Boolean expression for branching
label string optional Label displayed on the connector

Variables

Flows use variables to pass data between nodes and to/from callers:

variables: [
  { name: 'input_id', type: 'text', isInput: true, isOutput: false },
  { name: 'result', type: 'object', isInput: false, isOutput: true },
  { name: 'counter', type: 'number', isInput: false, isOutput: false },
]
Property Type Description
name string Variable name
type string 'text', 'number', 'boolean', 'object', 'list'
isInput boolean Available as input parameter
isOutput boolean Available as output parameter

Error Handling

Configure how errors are handled during flow execution:

errorHandling: {
  strategy: 'retry',           // 'fail' | 'retry' | 'continue'
  maxRetries: 3,
  retryDelayMs: 5000,
  fallbackNodeId: 'error_handler',
}
Property Type Description
strategy enum 'fail' (stop), 'retry' (retry), 'continue' (skip)
maxRetries number Maximum retry attempts (0-10)
retryDelayMs number Delay between retries (ms)
fallbackNodeId string Node to execute on failure

Discovery & Registration

You almost never call engine.registerFlow() directly. The AutomationServicePlugin (@objectstack/service-automation) auto-discovers every inline flow definition during its start() phase by reading the ObjectQL schema registry:

// inside AutomationServicePlugin.start()
const ql = ctx.getService('objectql');
const flows = ql.registry.listItems('flow');
for (const flow of flows) {
  engine.registerFlow(flow.name, flow);
}

Any flow attached via the defineStack / manifest.register() pipeline is picked up automatically:

import { defineStack } from '@objectstack/spec';
import { approvalFlow } from './flows/approval';

export default defineStack({
  manifest: { id: 'com.example.crm', version: '1.0.0', type: 'app', name: 'CRM' },
  objects: [...],
  flows: [approvalFlow],   // ← registered with the engine on boot
});

The plugin is a soft dependency on metadata — it tolerates running without MetadataPlugin and it logs (not throws) on per-flow registration failures so one broken flow does not abort startup.

Console Flow Viewer & Test Runner

Every flow can surface in the Console metadata browser under /_console/. ObjectUI's flow viewer replaces the default JSON inspector with rich tabs when the flow viewer plugin is installed:

Tab Component Purpose
Overview FlowViewer Renders trigger type, variables, nodes, edges, and errorHandling as inspector cards
Run FlowTestRunner Auto-generates a form for every isInput: true variable (with type-aware coercion for number / boolean / object / list), executes the flow against the per-project kernel, and shows the returned outputs + run id
Runs FlowRunsPanel Lists historical executions for the selected flow with status, duration, and a deep-link to the run record

The runner posts to the standard automation execute endpoint, scoped to the active project. All three components participate in the Studio authentication / project-scope context, so they work identically in single-project mode and in cloud mode.

Related