From 180cbfb2b53f283b212a968d6b3108220630dcdd Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:26:49 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(automation):=20screen-flow=20runtime?= =?UTF-8?q?=20=E2=80=94=20interactive=20screen=20nodes=20(suspend=E2=86=92?= =?UTF-8?q?render=E2=86=92resume)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `screen` node with input fields now suspends the run on entry (reusing the ADR-0019 durable pause), surfaces a ScreenSpec for the client to render, and resumes with the collected values applied as bare flow variables so downstream nodes read them via `{var}`. Completes the screen-flow arm of the engine that previously left `screen` nodes as no-op pass-throughs (the showcase Reassign Wizard's input was never collected → `apply` failed). - spec: AutomationResult.screen, ResumeSignal.variables (bare vars), IAutomationService.getSuspendedScreen. - service-automation: screen executor builds the ScreenSpec + suspends when fields present (waitForInput:false forces pass-through); suspend/resume threads screen through FlowSuspendSignal → SuspendedRun → paused result; resume() applies signal.variables as bare vars; getSuspendedScreen getter. +2 unit tests. - runtime: POST /automation/:name/runs/:runId/resume + GET .../screen, wired in both the dispatcher route table and handleAutomation. - docs/design/screen-flow-runtime.md. Verified headless end-to-end: showcase Reassign Wizard launch → pause at screen → resume with new_assignee → task reassigned. objectui FlowRunner ships next. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/screen-flow-runtime.md | 27 +++++++++ docs/design/screen-flow-runtime.md | 50 ++++++++++++++++ packages/runtime/src/dispatcher-plugin.ts | 20 +++++++ packages/runtime/src/http-dispatcher.ts | 34 ++++++++++- .../src/builtin/screen-nodes.ts | 43 ++++++++++---- .../service-automation/src/engine.test.ts | 57 +++++++++++++++++++ .../services/service-automation/src/engine.ts | 36 ++++++++++-- .../spec/src/contracts/automation-service.ts | 47 +++++++++++++++ 8 files changed, 299 insertions(+), 15 deletions(-) create mode 100644 .changeset/screen-flow-runtime.md create mode 100644 docs/design/screen-flow-runtime.md diff --git a/.changeset/screen-flow-runtime.md b/.changeset/screen-flow-runtime.md new file mode 100644 index 000000000..ae936c32a --- /dev/null +++ b/.changeset/screen-flow-runtime.md @@ -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. diff --git a/docs/design/screen-flow-runtime.md b/docs/design/screen-flow-runtime.md new file mode 100644 index 000000000..27dff4fb7 --- /dev/null +++ b/docs/design/screen-flow-runtime.md @@ -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 // 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. diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index c22d78aca..35c0413d5 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -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 ───────────────────────────────────────── diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index f88e32e30..a74098923 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -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 { const automationService = await this.getService(CoreServiceName.enum.automation); @@ -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) }; diff --git a/packages/services/service-automation/src/builtin/screen-nodes.ts b/packages/services/service-automation/src/builtin/screen-nodes.ts index 6303d3fa7..b017ce955 100644 --- a/packages/services/service-automation/src/builtin/screen-nodes.ts +++ b/packages/services/service-automation/src/builtin/screen-nodes.ts @@ -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. @@ -32,11 +33,33 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext }), 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 }; + const rawFields = Array.isArray(cfg.fields) ? (cfg.fields as Array>) : []; + 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, + }, + }; }, }); diff --git a/packages/services/service-automation/src/engine.test.ts b/packages/services/service-automation/src/engine.test.ts index e3cc26132..ab75e42e0 100644 --- a/packages/services/service-automation/src/engine.test.ts +++ b/packages/services/service-automation/src/engine.test.ts @@ -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'; @@ -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 } = {}; diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index afb7148c1..08f5772a6 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, ResumeSignal, IAutomationService } from '@objectstack/spec/contracts'; +import type { AutomationContext, AutomationResult, ResumeSignal, IAutomationService, ScreenSpec } from '@objectstack/spec/contracts'; import type { Logger } from '@objectstack/spec/contracts'; import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES } from '@objectstack/spec/automation'; import type { Connector } from '@objectstack/spec/integration'; @@ -69,6 +69,12 @@ export interface NodeExecutionResult { * approval request id). For observability / lookup; not required to resume. */ correlation?: string; + /** + * Screen to render — set by a `screen` node that suspends to collect input. + * Surfaced on the paused {@link AutomationResult} so a UI runner can render + * the form and `resume()` with the values. + */ + screen?: ScreenSpec; } // ─── Trigger Interface (Plugin Extension Point) ───────────────────── @@ -227,7 +233,7 @@ interface ExecutionLogEntry { */ class FlowSuspendSignal { readonly __flowSuspend = true as const; - constructor(readonly nodeId: string, readonly correlation?: string) {} + constructor(readonly nodeId: string, readonly correlation?: string, readonly screen?: ScreenSpec) {} } function isSuspendSignal(err: unknown): err is FlowSuspendSignal { @@ -253,6 +259,8 @@ interface SuspendedRun { startedAt: string; startTime: number; correlation?: string; + /** Screen the run paused on (screen-flow runtime), for re-fetch + UI render. */ + screen?: ScreenSpec; } export class AutomationEngine implements IAutomationService { @@ -750,6 +758,7 @@ export class AutomationEngine implements IAutomationService { startedAt, startTime, correlation: err.correlation, + screen: err.screen, }); this.recordLog({ id: runId, @@ -770,6 +779,7 @@ export class AutomationEngine implements IAutomationService { status: 'paused', runId, durationMs, + screen: err.screen, }; } @@ -838,6 +848,14 @@ export class AutomationEngine implements IAutomationService { variables.set(`${run.nodeId}.${key}`, value); } } + // Bare flow variables — a `screen` node's collected inputs land under + // their plain names so downstream `{var}` interpolation / conditions + // read them directly (e.g. `new_assignee` → update_record fields). + if (signal?.variables) { + for (const [key, value] of Object.entries(signal.variables)) { + variables.set(key, value); + } + } const steps = run.steps; const context = run.context; @@ -880,6 +898,7 @@ export class AutomationEngine implements IAutomationService { variables: Object.fromEntries(variables), steps, correlation: err.correlation, + screen: err.screen, }); this.recordLog({ id: runId, @@ -895,7 +914,7 @@ export class AutomationEngine implements IAutomationService { }, steps, }); - return { success: true, status: 'paused', runId, durationMs }; + return { success: true, status: 'paused', runId, durationMs, screen: err.screen }; } const errorMessage = err instanceof Error ? err.message : String(err); @@ -933,6 +952,15 @@ export class AutomationEngine implements IAutomationService { })); } + /** + * The screen a paused run is currently waiting on (screen-flow runtime), or + * `null` if the run isn't suspended / didn't pause at a screen node. Lets a + * UI flow-runner re-fetch the form after a refresh. + */ + getSuspendedScreen(runId: string): ScreenSpec | null { + return this.suspendedRuns.get(runId)?.screen ?? null; + } + // ── DAG Traversal Core ────────────────────────────────── private recordLog(entry: ExecutionLogEntry): void { @@ -1187,7 +1215,7 @@ export class AutomationEngine implements IAutomationService { // 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); + throw new FlowSuspendSignal(node.id, result.correlation, result.screen); } } diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index cb6938321..4b6eb2093 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -40,6 +40,32 @@ export interface AutomationContext { params?: Record; } +/** One input field rendered by a paused `screen` node (ADR-0019 / screen-flow runtime). */ +export interface ScreenFieldSpec { + name: string; + label?: string; + /** Widget hint (text/select/boolean/number/date/…); the client maps it to a field widget. */ + type?: string; + required?: boolean; + /** Closed-enum options for select-style fields. */ + options?: Array<{ value: unknown; label: string }>; + defaultValue?: unknown; + placeholder?: string; +} + +/** + * The screen a paused `screen` node wants the client to render. Surfaced on a + * paused {@link AutomationResult} so a UI flow-runner can collect input and + * `resume()` the run with the values. + */ +export interface ScreenSpec { + /** The screen node's id (correlates the resume back to this pause point). */ + nodeId: string; + title?: string; + description?: string; + fields: ScreenFieldSpec[]; +} + /** * Result of an automation execution */ @@ -61,6 +87,13 @@ export interface AutomationResult { status?: 'completed' | 'paused' | 'failed'; /** Run id — set when `status` is `'paused'`, so callers can resume it. */ runId?: string; + /** + * The screen to render — set when the run paused at a `screen` node awaiting + * user input (screen-flow runtime). The client collects values for + * `screen.fields` and calls {@link IAutomationService.resume} with them as + * `signal.variables`. + */ + screen?: ScreenSpec; } /** Signal payload used to resume a paused run (ADR-0019). */ @@ -77,6 +110,13 @@ export interface ResumeSignal { * back to the node's conditional/unconditional edges. */ branchLabel?: string; + /** + * Bare flow variables to set on resume (e.g. a `screen` node's collected + * inputs: `{ new_assignee: 'ada@x' }` → variable `new_assignee`). Unlike + * {@link output} these are set under their plain names, so downstream + * `{var}` interpolation and conditions read them directly. + */ + variables?: Record; } export interface IAutomationService { @@ -163,4 +203,11 @@ export interface IAutomationService { * attached. Backs operability (e.g. a "pending approvals" view). */ listSuspendedRuns?(): Array<{ runId: string; flowName: string; nodeId: string; correlation?: string }>; + + /** + * The screen a paused run is currently awaiting (screen-flow runtime), or + * `null` if the run isn't suspended at a `screen` node. Lets a UI flow-runner + * re-fetch the form (e.g. after a page refresh). + */ + getSuspendedScreen?(runId: string): ScreenSpec | null; } From b0b7f4cfb67d442634825da026faa8d269f42722 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:47:57 +0800 Subject: [PATCH 2/2] feat(showcase): make Reassign a row-level screen-flow action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the Reassign wizard action to `list_item` (+ keep `list_toolbar`) and relabel to "Reassign…" so the row's recordId flows into the screen flow — the wizard collects `new_assignee` and writes it back to that task. Exercises the screen-flow runtime end-to-end in the showcase. --- examples/app-showcase/src/actions/index.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/examples/app-showcase/src/actions/index.ts b/examples/app-showcase/src/actions/index.ts index b6f0b09a6..ecaf813d5 100644 --- a/examples/app-showcase/src/actions/index.ts +++ b/examples/app-showcase/src/actions/index.ts @@ -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, };