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
27 changes: 27 additions & 0 deletions .changeset/screen-flow-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"@objectstack/service-automation": minor
"@objectstack/runtime": minor
"@objectstack/spec": minor
---

Screen-flow runtime — interactive `screen` nodes (suspend → render → resume).

A `screen` node that declares input fields now suspends the run on entry
(reusing the ADR-0019 durable pause), surfaces a `ScreenSpec` describing the
form, and resumes with the collected values applied as **bare** flow variables
so downstream nodes read them via `{var}`. (`waitForInput: false` forces the
old server pass-through.)

- **spec**: `AutomationResult.screen?: ScreenSpec`, `ResumeSignal.variables?`
(bare vars), `IAutomationService.getSuspendedScreen?(runId)`.
- **service-automation**: the `screen` executor builds the `ScreenSpec` and
suspends when fields are present; the suspend/resume plumbing threads the
screen through `FlowSuspendSignal` → `SuspendedRun` → the paused result;
`resume()` sets `signal.variables` as bare flow variables; `getSuspendedScreen`.
- **runtime**: `POST /api/v1/automation/:name/runs/:runId/resume` (body
`{ inputs }`) and `GET …/runs/:runId/screen`, wired through both the
dispatcher route table and `handleAutomation`.

Verified end-to-end headlessly: the showcase Reassign Wizard launches → pauses
at the "New Assignee" screen → resumes with the input → the task is reassigned.
The objectui `FlowRunner` UI that renders these screens ships separately.
50 changes: 50 additions & 0 deletions docs/design/screen-flow-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Design — Screen-Flow Runtime (interactive `screen` nodes)

**Builds on**: [ADR-0019](../adr/0019-approval-as-flow-node.md) durable pause/resume (the same primitive approvals use).
**Audience**: implementing agent. Scope: make a `screen`-node flow (e.g. the showcase Reassign Wizard) actually collect input in the UI and resume.

## Current state (verified)

The pause/resume spine already exists:
- `screen` executor (`service-automation/src/builtin/screen-nodes.ts`) suspends **only** when `config.waitForInput === true`; otherwise it's a server pass-through. It does **not** surface the screen's field spec.
- `engine.resume(runId, signal)` restores variables, merges `signal.output` under `${nodeId}.key`, and continues from the node's out-edges. It does **not** set bare flow variables.
- No resume HTTP endpoint (only approvals have one); no UI runner.

Net gap for a working screen flow: (a) surface the **screen spec** on the paused result, (b) resume must set the collected inputs as **bare** flow variables (the showcase `apply` node reads `{new_assignee}`/`{recordId}`), (c) a resume HTTP endpoint, (d) an objectui `FlowRunner`, (e) suspend by default when a screen declares input fields.

## Protocol

### Types (spec/contracts)
```ts
interface ScreenFieldSpec { name: string; label?: string; type?: string; required?: boolean; options?: {value:unknown;label:string}[]; defaultValue?: unknown; }
interface ScreenSpec { nodeId: string; title?: string; description?: string; fields: ScreenFieldSpec[]; }
// AutomationResult gains: screen?: ScreenSpec // present when status==='paused' at a screen node
// ResumeSignal gains: variables?: Record<string, unknown> // bare flow vars (screen inputs)
```

### Engine
- **screen executor**: suspend when `waitForInput === true` **or** (`config.fields` non-empty **and** `waitForInput !== false`). When suspending, return `{ success:true, suspend:true, screen: { nodeId, title, description, fields } }` built from `node.config`.
- **suspend plumbing**: `NodeExecutionResult.screen` → `FlowSuspendSignal.screen` → `SuspendedRun.screen` → paused `AutomationResult.screen`.
- **resume**: apply `signal.variables` as **bare** variables (`variables.set(name, value)`) in addition to the existing `signal.output` (`${nodeId}.key`). If the continuation suspends at another screen, return that screen (multi-screen wizards).
- `getSuspendedScreen(runId)` getter so HTTP can re-fetch the current screen.

### HTTP (`runtime/http-dispatcher.ts` `handleAutomation`)
- **Launch**: existing `POST /api/v1/automation/:name/trigger` — when the run pauses at a screen, the response includes `{ status:'paused', runId, screen }`.
- **Resume**: `POST /api/v1/automation/runs/:runId/resume` body `{ inputs: {field:value} }` → `engine.resume(runId, { variables: inputs })` → returns next `{ status:'paused', runId, screen }` or `{ status:'completed' }`. (Mirrors the approvals decide endpoint, keyed by runId.)
- `GET /api/v1/automation/runs/:runId/screen` — re-fetch the current screen (refresh-safe).

### objectui `FlowRunner` (app-shell)
- A modal driven by `{ runId, screen }`: render `screen.fields` as a form (reuse field widgets), submit → POST resume with `{inputs}` → render the next `screen` or close on `completed` (toast + refresh the originating view).
- Wired to actions that launch a screen flow: the action's launch response carrying `{ runId, screen }` opens the `FlowRunner` instead of just toasting.

### showcase
- `ReassignWizardFlow.collect` already declares `fields` → suspends by the new default (or set `waitForInput: true` explicitly). `recordId` is supplied at launch (the selected row); `new_assignee` is collected by the screen and applied by `update_record`.

## Phases
1. **contracts + engine + screen executor + resume** (+ unit tests) — server can launch→pause→resume a screen flow headlessly.
2. **HTTP** resume endpoint + launch surfaces the screen.
3. **showcase** flag/verify the wizard suspends & applies.
4. **objectui `FlowRunner`** + action wiring.
5. **browser verify** end-to-end (Bulk Reassign → form → submit → assignee updated).

Each phase is independently testable; 1–3 are framework, 4 is objectui.
11 changes: 8 additions & 3 deletions examples/app-showcase/src/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,20 @@ export const OpenDocsAction: Action = {
refreshAfter: false,
};

/** flow — launch a screen flow wizard from the toolbar. */
/**
* flow — launch the Reassign screen-flow wizard. Row-level (`list_item`) so the
* row's `recordId` flows into the flow, which collects `new_assignee` via a
* `screen` node and writes it back with `update_record`. The objectui
* FlowRunner renders the screen and resumes the run.
*/
export const BulkReassignAction: Action = {
name: 'showcase_bulk_reassign',
label: 'Bulk Reassign',
label: 'Reassign',
icon: 'users',
objectName: task,
type: 'flow',
target: 'showcase_reassign_wizard',
locations: ['list_toolbar'],
locations: ['list_item', 'list_toolbar'],
refreshAfter: true,
};

Expand Down
20 changes: 20 additions & 0 deletions packages/runtime/src/dispatcher-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,26 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
errorResponse(err, res);
}
});

// Screen-flow runtime (ADR-0019): resume a paused run with a
// screen node's collected input, and re-fetch its pending screen.
server!.post(`${base}/automation/:name/runs/:runId/resume`, async (req: any, res: any) => {
try {
const result = await dispatcher.dispatch('POST', `/automation/${req.params.name}/runs/${req.params.runId}/resume`, req.body, req.query, { request: req });
sendResult(result, res);
} catch (err: any) {
errorResponse(err, res);
}
});

server!.get(`${base}/automation/:name/runs/:runId/screen`, async (req: any, res: any) => {
try {
const result = await dispatcher.dispatch('GET', `/automation/${req.params.name}/runs/${req.params.runId}/screen`, undefined, req.query, { request: req });
sendResult(result, res);
} catch (err: any) {
errorResponse(err, res);
}
});
};

// ── AI / Assistants ─────────────────────────────────────────
Expand Down
34 changes: 33 additions & 1 deletion packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1677,6 +1677,8 @@ export class HttpDispatcher {
* POST /:name/toggle → toggleFlow
* GET /:name/runs → listRuns
* GET /:name/runs/:runId → getRun
* POST /:name/runs/:runId/resume → resume a paused run (screen input / ADR-0019)
* GET /:name/runs/:runId/screen → the screen a paused run awaits
*/
async handleAutomation(path: string, method: string, body: any, context: HttpProtocolContext, query?: any): Promise<HttpDispatcherResult> {
const automationService = await this.getService(CoreServiceName.enum.automation);
Expand Down Expand Up @@ -1818,8 +1820,38 @@ export class HttpDispatcher {
}
}

// POST /:name/runs/:runId/resume → resume a paused run (screen-flow
// runtime / ADR-0019). Body `{ inputs }` = a screen node's collected
// values, applied as bare flow variables; `output`/`branchLabel` also
// forwarded for approval-style resumes. Returns the next paused
// `{ screen }` (multi-screen) or the completed result.
if (parts[1] === 'runs' && parts[2] && parts[3] === 'resume' && m === 'POST') {
if (typeof automationService.resume === 'function') {
const b = (body && typeof body === 'object') ? body : {};
const inputs = (b.inputs ?? b.variables);
const signal: any = {};
if (inputs && typeof inputs === 'object') signal.variables = inputs;
if (b.output && typeof b.output === 'object') signal.output = b.output;
if (typeof b.branchLabel === 'string') signal.branchLabel = b.branchLabel;
const result = await automationService.resume(parts[2], signal);
return { handled: true, response: this.success(result) };
}
return { handled: true, response: this.error('Resume not supported', 501) };
}

// GET /:name/runs/:runId/screen → the screen a paused run awaits
// (refresh-safe re-fetch for the UI flow-runner).
if (parts[1] === 'runs' && parts[2] && parts[3] === 'screen' && m === 'GET') {
if (typeof automationService.getSuspendedScreen === 'function') {
const screen = automationService.getSuspendedScreen(parts[2]);
if (!screen) return { handled: true, response: this.error('No pending screen for run', 404) };
return { handled: true, response: this.success({ runId: parts[2], screen }) };
}
return { handled: true, response: this.error('Screen lookup not supported', 501) };
}

// GET /:name/runs/:runId → getRun
if (parts[1] === 'runs' && parts[2] && m === 'GET') {
if (parts[1] === 'runs' && parts[2] && !parts[3] && m === 'GET') {
if (typeof automationService.getRun === 'function') {
const run = await automationService.getRun(parts[2]);
if (!run) return { handled: true, response: this.error('Execution not found', 404) };
Expand Down
43 changes: 33 additions & 10 deletions packages/services/service-automation/src/builtin/screen-nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ 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 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.
* - 'screen' nodes collect user input. A screen that declares `config.fields`
* (or sets `config.waitForInput === true`) suspends the run on entry via the
* engine's durable pause (ADR-0019), surfacing a `ScreenSpec` for the client
* to render; the run continues via `resume()` with the collected values (set
* as bare flow variables). A field-less screen — or one with
* `waitForInput === false` — stays a server pass-through (input vars, if any,
* are already injected from `context.params`).
* - '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.
Expand All @@ -32,11 +33,33 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext
}),
async execute(node, _variables, _context) {
const cfg = (node.config ?? {}) as Record<string, unknown>;
// Opt-in durable pause: suspend the run awaiting the screen's input.
if (cfg.waitForInput === true) {
return { success: true, suspend: true };
const rawFields = Array.isArray(cfg.fields) ? (cfg.fields as Array<Record<string, unknown>>) : [];
const hasFields = rawFields.length > 0;
// Suspend to collect input when the screen declares fields, or opts in
// explicitly. `waitForInput === false` forces a server pass-through.
const shouldPause = cfg.waitForInput === true || (hasFields && cfg.waitForInput !== false);
if (!shouldPause) {
return { success: true };
}
return { success: true };
const fields = rawFields.map((f) => ({
name: String(f.name ?? ''),
label: f.label != null ? String(f.label) : undefined,
type: f.type != null ? String(f.type) : undefined,
required: f.required === true,
options: Array.isArray(f.options) ? (f.options as Array<{ value: unknown; label: string }>) : undefined,
defaultValue: f.defaultValue,
placeholder: f.placeholder != null ? String(f.placeholder) : undefined,
})).filter((f) => f.name.length > 0);
return {
success: true,
suspend: true,
screen: {
nodeId: node.id,
title: (cfg.title as string | undefined) ?? node.label ?? 'Input',
description: cfg.description as string | undefined,
fields,
},
};
},
});

Expand Down
57 changes: 57 additions & 0 deletions packages/services/service-automation/src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
import { LiteKernel } from '@objectstack/core';
import { AutomationEngine } from './engine.js';
import { AutomationServicePlugin } from './plugin.js';
import { registerScreenNodes } from './builtin/screen-nodes.js';
import type { NodeExecutor } from './engine.js';
import type { IAutomationService } from '@objectstack/spec/contracts';

Expand Down Expand Up @@ -435,6 +436,62 @@ describe('AutomationEngine', () => {
expect(engine.listSuspendedRuns()).toHaveLength(0);
});

// ── Screen-flow runtime (interactive `screen` nodes) ──
const fakeScreenCtx = () => ({ logger: { info() {}, warn() {}, error() {} } }) as any;

it('suspends at a screen with fields, surfaces the spec, and resume sets bare vars', async () => {
registerScreenNodes(engine, fakeScreenCtx());
let captured: unknown = 'UNSET';
engine.registerNodeExecutor({
type: 'capture',
async execute(_node, variables) { captured = variables.get('new_assignee'); return { success: true }; },
});
engine.registerFlow('screen_flow', {
name: 'screen_flow', label: 'Screen Flow', type: 'screen',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'collect', type: 'screen', label: 'New Assignee', config: { fields: [{ name: 'new_assignee', label: 'New Assignee', type: 'text', required: true }] } },
{ id: 'apply', type: 'capture', label: 'Apply' },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'collect' },
{ id: 'e2', source: 'collect', target: 'apply' },
{ id: 'e3', source: 'apply', target: 'end' },
],
});

const paused = await engine.execute('screen_flow');
expect(paused.status).toBe('paused');
expect(paused.screen).toMatchObject({ nodeId: 'collect', title: 'New Assignee' });
expect(paused.screen!.fields[0]).toMatchObject({ name: 'new_assignee', required: true, type: 'text' });
expect(captured).toBe('UNSET'); // downstream not run yet
// Re-fetchable for a refreshed client.
expect(engine.getSuspendedScreen(paused.runId!)).toMatchObject({ nodeId: 'collect' });

const done = await engine.resume(paused.runId!, { variables: { new_assignee: 'ada@example.com' } });
expect(done.success).toBe(true);
expect(done.status).toBeUndefined();
expect(captured).toBe('ada@example.com'); // bare var set on resume → downstream read it
expect(engine.getSuspendedScreen(paused.runId!)).toBeNull();
});

it('passes a field-less screen straight through (no pause)', async () => {
registerScreenNodes(engine, fakeScreenCtx());
engine.registerFlow('passthrough_screen', {
name: 'passthrough_screen', label: 'Passthrough', type: 'screen',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 's', type: 'screen', label: 'noop', config: {} },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [{ id: 'e1', source: 'start', target: 's' }, { id: 'e2', source: 's', target: 'end' }],
});
const r = await engine.execute('passthrough_screen');
expect(r.success).toBe(true);
expect(r.status).toBeUndefined();
});

it('should select the branch named by the resume signal label', async () => {
const executed: string[] = [];
const captured: { runId?: unknown } = {};
Expand Down
Loading