From 46f4c8ffa137ca9503e5be0ec5baac8bef8ef50e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 13:21:51 +0000 Subject: [PATCH 1/4] wip(spec,service-automation): ScreenSpec.progress from the flow graph Claude-Session: https://claude.ai/code/session_01WGufzh5Z4kFNVFmensPenL Co-authored-by: Claude --- .../src/builtin/screen-nodes.ts | 224 +++++++++++++++++- packages/spec/src/api/automation-api.zod.ts | 25 ++ .../spec/src/contracts/automation-service.ts | 56 +++++ 3 files changed, 304 insertions(+), 1 deletion(-) diff --git a/packages/services/service-automation/src/builtin/screen-nodes.ts b/packages/services/service-automation/src/builtin/screen-nodes.ts index ecb18c3fd89..1cecbf95085 100644 --- a/packages/services/service-automation/src/builtin/screen-nodes.ts +++ b/packages/services/service-automation/src/builtin/screen-nodes.ts @@ -2,7 +2,8 @@ import type { PluginContext } from '@objectstack/core'; import { defineActionDescriptor, isExpressionEnvelopeShaped, ScreenConfigSchema, ScriptConfigSchema } from '@objectstack/spec/automation'; -import type { ScreenConfigParsed, ScriptConfigParsed } from '@objectstack/spec/automation'; +import type { FlowEdgeParsed, FlowNodeParsed, FlowParsed, ScreenConfigParsed, ScriptConfigParsed } from '@objectstack/spec/automation'; +import type { ScreenProgressSpec } from '@objectstack/spec/contracts'; import type { AutomationEngine } from '../engine.js'; import { flowScreenCopyKey, type FlowScreenCopyKey } from '@objectstack/spec/system'; import { renderTextSlot } from './template.js'; @@ -83,6 +84,201 @@ function valueKindOf(value: unknown): string { return `a ${typeof value} (\`${String(value)}\`)`; } +/** + * How one node of a flow's top-level graph counts toward the screens a run + * serves, for {@link readScreenProgress} (#26657): + * + * - `'screen'` — a `screen` node that pauses whenever the run reaches it. + * - `'maybe-screen'` — a `screen` node the run's caller may already have + * answered (`judgeHeadlessScreen`), so whether it pauses depends on values. + * - `'subflow'` — a node that may serve screens of its CHILD run, any number + * of them or none (`subflow-node.ts` forwards the child's `screen`), and + * whose pause may end the stretch of screens. + * - `'pause'` — any other node that can pause. It serves no screen (only a + * `screen` node sets `NodeExecutionResult.screen`, and `subflow` forwards + * it), so the stretch of screens may end there. + * - `'plain'` — everything else, a `screen` that never pauses included. + */ +export type ScreenProgressRole = 'screen' | 'maybe-screen' | 'subflow' | 'pause' | 'plain'; + +/** + * A paused screen's place in its run, read off the flow's top-level graph + * (#26657) — the {@link ScreenProgressSpec} served on `ScreenSpec.progress`. + * `undefined` only when the graph does not hold the node. + * + * Every out-edge is a way the run may go: the engine does not evaluate a + * condition ahead of time, so a branch it cannot see past answers + * `'possible'`, and a count that differs between paths is left out rather + * than guessed. Region bodies (`loop` / `try_catch` / `parallel`) are not + * walked: a pause inside one is refused (`region-suspension-refusal.ts`), so + * no screen there is served. + */ +export function readScreenProgress( + flow: Pick, + nodeId: string, + roleOf: (node: FlowNodeParsed) => ScreenProgressRole, +): ScreenProgressSpec | undefined { + const nodes = new Map(flow.nodes.map((n) => [n.id, n] as const)); + if (!nodes.has(nodeId)) return undefined; + const roles = new Map([...nodes.values()].map((n) => [n.id, roleOf(n)] as const)); + const outEdges = new Map([...nodes.keys()].map((id) => [id, []])); + for (const edge of flow.edges ?? []) { + if (nodes.has(edge.source) && nodes.has(edge.target)) outEdges.get(edge.source)!.push(edge); + } + const successors = (id: string): string[] => outEdges.get(id)!.map((e) => e.target); + const servesScreen = (id: string): boolean => { + const role = roles.get(id); + return role === 'screen' || role === 'maybe-screen' || role === 'subflow'; + }; + + // How one visit of a node continues, read the way `traverseNext` walks: + // the conditional out-edges that hold run (on an exclusive `decision` + // only the first), a default edge only when none held, the unconditional + // ones always, and a fault edge only when the node fails. A visit may + // STOP when nothing is sure to follow, or when the node may pause without + // a screen; it FANS OUT when it may continue into several successors. + const visitShape = (id: string): { mayStop: boolean; fansOut: boolean } => { + const node = nodes.get(id)!; + const role = roles.get(id)!; + const edges = outEdges.get(id)!; + const onSuccess = edges.filter((e) => e.type !== 'fault'); + const conditional = onSuccess.filter((e) => e.condition).length; + const fallback = onSuccess.filter((e) => !e.condition && e.isDefault).length; + const always = onSuccess.length - conditional - fallback; + const exclusive = node.type === 'decision' + && (node.config as { mode?: unknown } | undefined)?.mode !== 'inclusive'; + const fansOut = edges.length - onSuccess.length > 1 + || always + fallback > 1 + || (exclusive ? always > 0 && conditional > 0 : always + conditional > 1); + return { mayStop: role === 'subflow' || role === 'pause' || (always === 0 && fallback === 0), fansOut }; + }; + const shapes = new Map([...nodes.keys()].map((id) => [id, visitShape(id)] as const)); + + // `nextScreen`. "Another screen can follow" is plain reachability, a loop + // back to this very screen included. "Certain" is the fewest screens any + // path from here pauses on before it may stop (a 0-1 BFS, entering a + // screen costs 1), where only a `screen` that surely pauses counts. + const seen = new Set(); + const queue = [...successors(nodeId)]; + let reachesScreen = false; + while (queue.length > 0) { + const id = queue.shift()!; + if (seen.has(id)) continue; + seen.add(id); + if (servesScreen(id)) reachesScreen = true; + queue.push(...successors(id)); + } + const fewest = new Map([[nodeId, 0]]); + const deque: string[] = [nodeId]; + let fewestToStop = shapes.get(nodeId)!.mayStop ? 0 : Infinity; + while (deque.length > 0) { + const id = deque.shift()!; + const here = fewest.get(id)!; + for (const next of successors(id)) { + const cost = here + (roles.get(next) === 'screen' ? 1 : 0); + if (cost >= (fewest.get(next) ?? Infinity)) continue; + fewest.set(next, cost); + if (shapes.get(next)!.mayStop) fewestToStop = Math.min(fewestToStop, cost); + if (cost === here) deque.unshift(next); else deque.push(next); + } + } + const nextScreen: ScreenProgressSpec['nextScreen'] = !reachesScreen + ? 'none' + : fewestToStop >= 1 ? 'certain' : 'possible'; + + // `step` / `stepCount` — exact counts over the strongly connected + // components (Tarjan), so a loop collapses to one unit. Each count is the + // SET of screen totals the paths give; it is exact when the set has one + // member, and `null` stands for "varies": a loop holding a screen, a + // `subflow`, a maybe-screen. A fan-out anywhere the start reaches makes a + // run take several paths at once, so no count is exact then either. + const start = flow.nodes.find((n) => n.type === 'start'); + if (!start) return { nextScreen }; + const component = new Map(); + const members: string[][] = []; + const index = new Map(); + const low = new Map(); + const stack: string[] = []; + const onStack = new Set(); + const visit = (id: string): void => { + index.set(id, index.size); + low.set(id, index.get(id)!); + stack.push(id); + onStack.add(id); + for (const next of successors(id)) { + if (!index.has(next)) { + visit(next); + low.set(id, Math.min(low.get(id)!, low.get(next)!)); + } else if (onStack.has(next)) { + low.set(id, Math.min(low.get(id)!, index.get(next)!)); + } + } + if (low.get(id) !== index.get(id)) return; + const group: string[] = []; + let popped: string; + do { + popped = stack.pop()!; + onStack.delete(popped); + component.set(popped, members.length); + group.push(popped); + } while (popped !== id); + members.push(group); + }; + for (const id of nodes.keys()) if (!index.has(id)) visit(id); + + // Tarjan closes a component only after every component it reaches, so + // `members` runs sinks first (reverse topological order). + const loops = (c: number): boolean => + members[c].length > 1 || successors(members[c][0]).includes(members[c][0]); + const weight = (c: number): number | null => { + if (loops(c)) return members[c].some(servesScreen) ? null : 0; + const role = roles.get(members[c][0]); + return role === 'screen' ? 1 : role === 'plain' || role === 'pause' ? 0 : null; + }; + const exits = (c: number): number[] => + [...new Set(members[c].flatMap(successors).map((id) => component.get(id)!))].filter((d) => d !== c); + type Counts = Set | null; + const shift = (counts: Counts, by: number | null): Counts => + counts === null || by === null ? null : new Set([...counts].map((n) => n + by)); + const union = (a: Counts, b: Counts): Counts => (a === null || b === null ? null : new Set([...a, ...b])); + + // Screens strictly after a component, to wherever the run may stop. + const after = new Map(); + for (let c = 0; c < members.length; c += 1) { + let counts: Counts = members[c].some((id) => shapes.get(id)!.mayStop) ? new Set([0]) : new Set(); + for (const d of exits(c)) counts = union(counts, shift(after.get(d)!, weight(d))); + after.set(c, counts); + } + // Screens strictly before a component, from the start. + const reached: number[] = []; + const before = new Map([[component.get(start.id)!, new Set([0])]]); + for (let c = members.length - 1; c >= 0; c -= 1) { + const counts = before.get(c); + if (counts === undefined) continue; + reached.push(c); + for (const d of exits(c)) before.set(d, union(before.get(d) ?? new Set(), shift(counts, weight(c)))); + } + const exact = (counts: Counts | undefined): number | undefined => + counts && counts.size === 1 ? [...counts][0] : undefined; + + const own = component.get(nodeId)!; + const fansOut = reached.some((c) => members[c].some((id) => shapes.get(id)!.fansOut)); + if (fansOut || loops(own)) return { nextScreen }; + const prior = exact(before.get(own)); + if (prior === undefined) return { nextScreen }; + const remaining = exact(after.get(own)); + return { + nextScreen, + step: prior + 1, + ...(remaining !== undefined ? { stepCount: prior + 1 + remaining } : {}), + }; +} + +/** The `progress` member, spread in only when the graph answered — absent, never `undefined`. */ +function progressMember(progress: ScreenProgressSpec | undefined): { progress?: ScreenProgressSpec } { + return progress ? { progress } : {}; +} + export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext): void { // screen — server-side pass-through (input vars already injected by engine). engine.registerNodeExecutor({ @@ -231,6 +427,30 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext typeof cfg.description === 'string' && cfg.description.length > 0 ? screenText('description', cfg.description, 'the body text') : undefined; + // [#26657] Where this screen sits among the run's screens, read off + // the graph of the flow this run executes (`readScreenProgress`), so + // the runner can say "Next" or "Submit" and count steps. Asked only + // once the screen pauses. A screen inside a CHILD run (a `subflow`) + // sees its own graph alone: "another screen follows" still holds + // there, but "none follows" and the counts do not, because the + // parent may continue with screens of its own — so those are left out. + const progress = async (): Promise => { + if (flowName === undefined) return undefined; + const flow = await engine.getFlow(flowName); + if (!flow?.nodes.some((n) => n.id === node.id && n.type === 'screen')) return undefined; + const read = readScreenProgress(flow, node.id, (n) => { + if (n.type === 'subflow') return 'subflow'; + if (n.type !== 'screen') return engine.getActionDescriptor(n.type)?.supportsPause === true ? 'pause' : 'plain'; + // The executor's own pause rule, below: an object form always + // pauses, `waitForInput` decides, else declared fields do. + const raw = (n.config ?? {}) as { objectName?: unknown; fields?: unknown; waitForInput?: unknown }; + if ((typeof raw.objectName === 'string' && raw.objectName.trim()) || raw.waitForInput === true) return 'screen'; + if (!Array.isArray(raw.fields) || raw.fields.length === 0 || raw.waitForInput === false) return 'plain'; + return judgeHeadlessScreen(raw.fields, new Map(), context).supplied.length > 0 ? 'maybe-screen' : 'screen'; + }); + if (!read || (context as { $parentRunId?: unknown }).$parentRunId == null) return read; + return read.nextScreen === 'none' ? undefined : { nextScreen: read.nextScreen }; + }; // [#19939] The record an `edit` form opens. A literal is the contract's // string, served as written. An envelope must evaluate to the record's // id, a non-blank string: anything else — `null` included — fails the @@ -280,6 +500,7 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext defaults, idVariable, fields: [], + ...progressMember(await progress()), }, }; } @@ -361,6 +582,7 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext title: heading() ?? node.label ?? 'Input', description: body(), fields, + ...progressMember(await progress()), }, }; }, diff --git a/packages/spec/src/api/automation-api.zod.ts b/packages/spec/src/api/automation-api.zod.ts index ca18979720f..791e5fb0e64 100644 --- a/packages/spec/src/api/automation-api.zod.ts +++ b/packages/spec/src/api/automation-api.zod.ts @@ -257,6 +257,26 @@ const screenFieldSpecShape = () => z.object({ ), }); +/** + * The wire spelling of `ScreenProgressSpec` (`contracts/automation-service.ts`, + * #26657), module-local for the reason on {@link screenFieldSpecShape}. + */ +const screenProgressSpecShape = () => z.object({ + nextScreen: z.enum(['none', 'possible', 'certain']).describe( + 'Whether another screen can follow this one: `none` = no path reaches one (the last step, ' + + '"Submit"); `certain` = every path pauses on one first ("Next"); `possible` = a branch, a ' + + 'pause that serves no screen or a subflow decides, and the engine does not guess ("Next")', + ), + step: z.number().int().min(1).optional().describe( + '1-based position among the run\'s screens; present only when every path from the start ' + + 'pauses on the same number of screens before this one', + ), + stepCount: z.number().int().min(1).optional().describe( + 'Screens the run pauses on in all, this one included; present only alongside `step`, when ' + + 'every path through this screen has the same count. A runner draws a counter only from it', + ), +}); + /** * The screen a paused `screen` node wants the client to render — the wire * spelling of `ScreenSpec` (`contracts/automation-service.ts`), module-local @@ -282,6 +302,11 @@ const screenSpecShape = () => z.object({ 'Flow variable that receives the saved record\'s id when the client ' + 'resumes the run, so a later step can reference it', ), + progress: screenProgressSpecShape().optional().describe( + 'Where this screen sits among the screens of its run, read by the engine off the flow graph. ' + + 'Absent = the engine cannot answer (a server that predates it, or a screen in a child run ' + + 'whose own graph shows no further screen); a runner then keeps its previous label', + ), }); // ========================================== diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 8fc1e1ccba3..a12ce764331 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -397,6 +397,62 @@ export interface ScreenSpec { * prefilling its `account` FK with the id created by the Customer step). */ idVariable?: string; + /** + * Where this screen sits among the screens of its run, read by the engine + * off the flow graph at the moment it pauses (#26657). A runner labels its + * primary button from {@link ScreenProgressSpec.nextScreen} ("Next" while + * another screen can follow, "Submit" on the last) and draws a step + * counter only from {@link ScreenProgressSpec.stepCount}. + * + * Absent when the engine cannot answer at all — a server that predates + * this member, or a screen inside a child run whose own graph shows no + * further screen (the parent may still have some) — and a runner then + * keeps the label it drew before this member existed. + */ + progress?: ScreenProgressSpec; +} + +/** + * The graph's answer about a paused screen's place in its run (#26657), + * served on {@link ScreenSpec.progress}. Computed by the engine — the only + * party that holds the graph — so a client ⛔ never walks the flow itself. + * + * "Another screen" means another `screen` node this run can still pause on + * and serve before it ends. A screen that never pauses (no fields and no + * `waitForInput`) does not count. A pause that serves no screen (an approval, + * a wait) may end the stretch of screens a runner draws, so a screen that can + * only be reached past one counts as possible, never as certain. + */ +export interface ScreenProgressSpec { + /** + * Whether another screen can follow this one. + * + * - `'none'` — no path out of this screen reaches another one: it is the + * last step, and a runner says "Submit". + * - `'certain'` — every way the run can go from here pauses on another + * screen first: a runner says "Next". + * - `'possible'` — some ways do and some do not, because something after + * this screen decides: a branch (a `decision`, a fault path, a loop + * back), a pause that serves no screen, or a `subflow` that may serve + * a screen of its own. The engine does not guess which way the run + * goes; a runner says "Next", since a later screen is still possible. + */ + nextScreen: 'none' | 'possible' | 'certain'; + /** + * This screen's 1-based position among the screens of the run. Present + * only when every path from the flow's start to this screen pauses on the + * same number of screens before it, so the number is exact; absent when a + * branch, a loop through a screen, a parallel fan-out or a `subflow` makes + * it vary. + */ + step?: number; + /** + * How many screens the run pauses on in all, this one included. Present + * only alongside {@link step}, and only when every path through this + * screen pauses on the same number of screens after it too; a runner + * draws a step counter from it and draws none without it. + */ + stepCount?: number; } /** From c0d0c3c1257a357f08c90a2fae315965fbca2bfb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 13:26:12 +0000 Subject: [PATCH 2/4] wip: runner label/counter, tests, docs, changeset Claude-Session: https://claude.ai/code/session_01WGufzh5Z4kFNVFmensPenL Co-authored-by: Claude --- .../26657-screen-progress-next-submit.md | 18 ++ content/docs/automation/flows.mdx | 37 +++ .../src/builtin/screen-progress.test.ts | 256 ++++++++++++++++++ .../spec/src/api/automation-api.zod.test.ts | 25 ++ .../app-shell/src/views/FlowRunner.tsx | 44 ++- .../FlowRunner.stepProgress-26657.test.tsx | 113 ++++++++ 6 files changed, 485 insertions(+), 8 deletions(-) create mode 100644 .changeset/26657-screen-progress-next-submit.md create mode 100644 packages/services/service-automation/src/builtin/screen-progress.test.ts create mode 100644 ui/packages/app-shell/src/views/__tests__/FlowRunner.stepProgress-26657.test.tsx diff --git a/.changeset/26657-screen-progress-next-submit.md b/.changeset/26657-screen-progress-next-submit.md new file mode 100644 index 00000000000..94b8fab28c0 --- /dev/null +++ b/.changeset/26657-screen-progress-next-submit.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': patch +'@objectstack/service-automation': patch +'@object-ui/app-shell': patch +--- + +feat(automation): a paused screen says whether another screen can follow, and the screen-flow runner reads Next or Submit and counts steps from it + +Clause-②: yes (widening) +Metadata-protocol: none + +A screen-flow wizard's primary button read Submit on every step, so a requester believed the whole wizard was submitted on step 1, and no step counter could be drawn: the paused screen carried nothing about the screens after it, and a flow can branch after any screen, so only the engine can tell. + +- **`@objectstack/spec` — `ScreenSpec.progress`** (optional, `ScreenProgressSpec`) on the paused screen a run serves, and its wire spelling on `TriggerFlowResponseSchema`. `nextScreen` is `'none'` (no path out of this screen reaches another one: the last step), `'certain'` (every path pauses on another screen first) or `'possible'` (a branch, a fault path, a loop, a pause that serves no screen, or a `subflow` decides — the engine does not guess). `step` (1-based) and `stepCount` are present only when every path agrees on them. Nothing a metadata author writes changes. +- **`@objectstack/service-automation`** fills it from the flow graph of the run when a `screen` node pauses (`readScreenProgress`). A screen that never pauses is not counted; one the run's caller may already have answered counts as possible, never certain. A screen inside a child run (`subflow`) carries only `nextScreen` when its own graph shows another screen, and no `progress` when it does not, because the parent may continue with screens of its own. +- **`@object-ui/app-shell` — `FlowRunner`** labels the flat step's button `common.next` while another screen is certain or possible and `common.submit` on the last, and draws `form.stepOf` beside the flow's name only when both `step` and `stepCount` are served. A screen served without `progress` keeps Submit and no counter, as before. + +What to do: nothing. A custom runner that reads the paused screen can read `progress` the same way. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index e25eaafb082..f4cab036718 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -1326,6 +1326,43 @@ producer), and `'service'` when the decision to continue belongs to your service rather than to whoever holds the run id. Declaring neither leaves your pauses refused on the generic route, and the engine warns about it at registration. +### A paused screen says whether another one follows — Next, Submit and the step counter + +Every paused screen carries `progress`, which the engine reads off the flow +graph when the screen pauses; the client never walks the graph itself. + +```json +"screen": { + "nodeId": "contract_details", + "fields": [ … ], + "progress": { "nextScreen": "certain", "step": 1, "stepCount": 3 } +} +``` + +| `nextScreen` | means | the Console's flow runner | +|---|---|---| +| `none` | no path out of this screen reaches another one: the last step | **Submit** | +| `certain` | every path pauses on another screen first | **Next** | +| `possible` | something after this screen decides: a branch, a fault edge, a loop back, a pause that serves no screen (an approval, a wait) or a `subflow` | **Next** | + +`step` (1-based) and `stepCount` appear only when **every** path agrees on them, +and the runner draws "Step 1 of 3" only when both are there. A `decision` +whose branches pause on different numbers of screens drops `stepCount` for the +screens before it, and `step` for a screen where those branches meet again; a +loop through a screen, a parallel fan-out or a `subflow` drops both. The engine +never guesses which branch the run will take. + +What counts as "another screen": a `screen` node that pauses (it declares +fields, sets `waitForInput: true`, or names an `objectName`). A field-less +screen that passes through does not count, and a screen the caller may already +have answered ([below](#a-screen-the-caller-already-answered-does-not-pause)) +counts as possible, never certain. A screen inside a `subflow` carries only +`nextScreen`, and only when its own flow shows another screen after it: the +parent may continue with screens of its own. + +So a wizard that should read Next, Next, Submit and count "Step N of 3" keeps +its screens on one straight path, and branches only after its last screen. + ### A screen the caller already answered does not pause A `screen` node exists to collect input. When the run's **caller already supplied diff --git a/packages/services/service-automation/src/builtin/screen-progress.test.ts b/packages/services/service-automation/src/builtin/screen-progress.test.ts new file mode 100644 index 00000000000..3b93fec27f3 --- /dev/null +++ b/packages/services/service-automation/src/builtin/screen-progress.test.ts @@ -0,0 +1,256 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A paused screen says where it sits among its run's screens (#26657). + * + * A screen-flow wizard's button read 「提交」 on every step, so a requester + * believed the contract was submitted on step 1, and no step counter could be + * drawn: the paused `ScreenSpec` carried nothing about the screens after it, + * and a flow can branch after any screen, so only the engine — which holds + * the graph — can answer. It now serves `ScreenSpec.progress`: `nextScreen` + * (`none` / `possible` / `certain`), and `step` / `stepCount` only where the + * graph makes them exact. + * + * Two layers: the engine end to end (a run pauses, resumes and pauses again, + * reading what each served screen carries), and `readScreenProgress` over + * graph shapes the engine cannot cheaply reach (fan-out, fault paths, a + * caller-answered screen). + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { defineActionDescriptor } from '@objectstack/spec/automation'; +import { AutomationEngine, type NodeExecutor } from '../engine.js'; +import { installBuiltinNodes } from './index.js'; +import { readScreenProgress, type ScreenProgressRole } from './screen-nodes.js'; + +function silentLogger() { + return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } } as any; +} +function ctx() { + return { logger: silentLogger(), getService() { return undefined; } } as any; +} + +/** A screen that pauses: it declares a field. */ +const screen = (id: string, field = `${id}_answer`) => ({ + id, type: 'screen', label: id.toUpperCase(), + config: { fields: [{ name: field, label: field, type: 'text' }] }, +}); +const edge = (source: string, target: string, extra: Record = {}) => + ({ id: `${source}->${target}`, source, target, ...extra }); +const flowOf = (name: string, nodes: unknown[], edges: unknown[]) => + ({ name, label: name, type: 'screen', status: 'active', version: 1, nodes, edges }) as any; + +describe('ScreenSpec.progress — the engine reads the flow graph (#26657)', () => { + let engine: AutomationEngine; + + beforeEach(() => { + engine = new AutomationEngine(silentLogger()); + installBuiltinNodes(engine, ctx()); + // A pause that serves no screen (an approval / a wait stands for it). + engine.registerNodeExecutor({ + type: 'hold', + descriptor: defineActionDescriptor({ type: 'hold', version: '1.0.0', name: 'hold', supportsPause: true, resumeAuthority: 'any' }), + async execute() { return { success: true, suspend: true }; }, + } as NodeExecutor); + }); + + it('a linear wizard counts its steps exactly and says Next until the last', async () => { + engine.registerFlow('linear', flowOf('linear', [ + { id: 'start', type: 'start', label: 'Start' }, + screen('s1'), screen('s2'), screen('s3'), + { id: 'end', type: 'end', label: 'End' }, + ], [edge('start', 's1'), edge('s1', 's2'), edge('s2', 's3'), edge('s3', 'end')])); + + const first = await engine.execute('linear', {} as any); + expect(first.screen?.progress).toEqual({ nextScreen: 'certain', step: 1, stepCount: 3 }); + const second = await engine.resume(first.runId!, { variables: { s1_answer: 'a' } }); + expect(second.screen?.progress).toEqual({ nextScreen: 'certain', step: 2, stepCount: 3 }); + const third = await engine.resume(second.runId!, { variables: { s2_answer: 'b' } }); + expect(third.screen?.progress).toEqual({ nextScreen: 'none', step: 3, stepCount: 3 }); + const done = await engine.resume(third.runId!, { variables: { s3_answer: 'c' } }); + expect(done.status).not.toBe('paused'); + expect(done.success).toBe(true); + }); + + it('an object-form step carries it too, and a one-screen flow is step 1 of 1, Submit', async () => { + engine.registerFlow('single', flowOf('single', [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'form', type: 'screen', label: 'Form', config: { objectName: 'account' } }, + { id: 'end', type: 'end', label: 'End' }, + ], [edge('start', 'form'), edge('form', 'end')])); + + const paused = await engine.execute('single', {} as any); + expect(paused.screen?.kind).toBe('object-form'); + expect(paused.screen?.progress).toEqual({ nextScreen: 'none', step: 1, stepCount: 1 }); + }); + + it('a branch after a screen answers "possible" and drops the total, never a guess', async () => { + engine.registerFlow('branched', flowOf('branched', [ + { id: 'start', type: 'start', label: 'Start' }, + screen('s1', 'more'), + { id: 'd', type: 'decision', label: 'More?', config: {} }, + screen('s2'), + { id: 'end', type: 'end', label: 'End' }, + ], [ + edge('start', 's1'), edge('s1', 'd'), + edge('d', 's2', { condition: "more == 'yes'" }), + edge('d', 'end', { isDefault: true }), + edge('s2', 'end'), + ])); + + const first = await engine.execute('branched', {} as any); + // One way pauses on s2, the other ends: "possible", and the position is + // exact (no path reaches s1 any other way) while the total is not. + expect(first.screen?.progress).toEqual({ nextScreen: 'possible', step: 1 }); + const second = await engine.resume(first.runId!, { variables: { more: 'yes' } }); + // Past the branch every path agrees again: step 2 of 2, the last. + expect(second.screen?.nodeId).toBe('s2'); + expect(second.screen?.progress).toEqual({ nextScreen: 'none', step: 2, stepCount: 2 }); + }); + + it('a loop back through the screens keeps "Next" and draws no counter', async () => { + engine.registerFlow('looped', flowOf('looped', [ + { id: 'start', type: 'start', label: 'Start' }, + screen('s1'), screen('s2', 'again'), + { id: 'd', type: 'decision', label: 'Again?', config: {} }, + { id: 'end', type: 'end', label: 'End' }, + ], [ + edge('start', 's1'), edge('s1', 's2'), edge('s2', 'd'), + edge('d', 's1', { condition: "again == 'yes'", type: 'back' }), + edge('d', 'end', { isDefault: true }), + ])); + + const first = await engine.execute('looped', {} as any); + // s2 surely follows s1, but how many times the loop runs is the user's. + expect(first.screen?.progress).toEqual({ nextScreen: 'certain' }); + const second = await engine.resume(first.runId!, { variables: { s1_answer: 'a' } }); + expect(second.screen?.progress).toEqual({ nextScreen: 'possible' }); + const again = await engine.resume(second.runId!, { variables: { again: 'yes' } }); + expect(again.screen?.nodeId).toBe('s1'); + expect(again.screen?.progress).toEqual({ nextScreen: 'certain' }); + }); + + it('a pause that serves no screen ends the stretch: "none" before an end, "possible" before a screen', async () => { + engine.registerFlow('handoff', flowOf('handoff', [ + { id: 'start', type: 'start', label: 'Start' }, + screen('s1'), + { id: 'hold', type: 'hold', label: 'Hold' }, + { id: 'end', type: 'end', label: 'End' }, + ], [edge('start', 's1'), edge('s1', 'hold'), edge('hold', 'end')])); + engine.registerFlow('handoff_then_screen', flowOf('handoff_then_screen', [ + { id: 'start', type: 'start', label: 'Start' }, + screen('s1'), + { id: 'hold', type: 'hold', label: 'Hold' }, + screen('s2'), + { id: 'end', type: 'end', label: 'End' }, + ], [edge('start', 's1'), edge('s1', 'hold'), edge('hold', 's2'), edge('s2', 'end')])); + + expect((await engine.execute('handoff', {} as any)).screen?.progress) + .toEqual({ nextScreen: 'none', step: 1, stepCount: 1 }); + expect((await engine.execute('handoff_then_screen', {} as any)).screen?.progress) + .toEqual({ nextScreen: 'possible', step: 1 }); + }); + + it('a screen that never pauses is not counted', async () => { + engine.registerFlow('passthrough', flowOf('passthrough', [ + { id: 'start', type: 'start', label: 'Start' }, + screen('s1'), + { id: 'note', type: 'screen', label: 'Note', config: {} }, + { id: 'end', type: 'end', label: 'End' }, + ], [edge('start', 's1'), edge('s1', 'note'), edge('note', 'end')])); + + expect((await engine.execute('passthrough', {} as any)).screen?.progress) + .toEqual({ nextScreen: 'none', step: 1, stepCount: 1 }); + }); + + describe('a subflow', () => { + beforeEach(() => { + engine.registerFlow('child', flowOf('child', [ + { id: 'cs', type: 'start', label: 'Start' }, + screen('c1'), screen('c2'), + { id: 'ce', type: 'end', label: 'End' }, + ], [edge('cs', 'c1'), edge('c1', 'c2'), edge('c2', 'ce')])); + engine.registerFlow('parent', flowOf('parent', [ + { id: 'ps', type: 'start', label: 'Start' }, + screen('p1'), + { id: 'call', type: 'subflow', label: 'Call', config: { flowName: 'child' } }, + { id: 'pe', type: 'end', label: 'End' }, + ], [edge('ps', 'p1'), edge('p1', 'call'), edge('call', 'pe')])); + }); + + it('may serve screens of its own, so the screen before it answers "possible"', async () => { + const first = await engine.execute('parent', {} as any); + expect(first.screen?.nodeId).toBe('p1'); + expect(first.screen?.progress).toEqual({ nextScreen: 'possible', step: 1 }); + }); + + it('serves its child screens with only what the child graph proves', async () => { + const first = await engine.execute('parent', {} as any); + const c1 = await engine.resume(first.runId!, { variables: { p1_answer: 'a' } }); + expect(c1.screen?.nodeId).toBe('c1'); + // Another child screen surely follows; the position is the parent's + // to know, so no count rides. + expect(c1.screen?.progress).toEqual({ nextScreen: 'certain' }); + const c2 = await engine.resume(c1.runId!, { variables: { c1_answer: 'b' } }); + expect(c2.screen?.nodeId).toBe('c2'); + // The child graph ends here, but the parent may go on: no answer. + expect(c2.screen && 'progress' in c2.screen).toBe(false); + }); + }); +}); + +describe('readScreenProgress — graph shapes (#26657)', () => { + const roleByType = (n: { type: string }): ScreenProgressRole => + n.type === 'screen' ? 'screen' + : n.type === 'maybe' ? 'maybe-screen' + : n.type === 'subflow' ? 'subflow' + : n.type === 'hold' ? 'pause' : 'plain'; + const node = (id: string, type = 'screen') => ({ id, type, label: id }); + const read = (nodes: unknown[], edges: unknown[], at: string) => + readScreenProgress({ nodes, edges } as any, at, roleByType as any); + + it('a parallel fan-out makes every count inexact, and "certain" still holds where every branch has a screen', () => { + const nodes = [node('start', 'start'), node('s1'), node('fork', 'plain'), node('a'), node('b'), node('end', 'end')]; + const edges = [edge('start', 's1'), edge('s1', 'fork'), edge('fork', 'a'), edge('fork', 'b'), edge('a', 'end'), edge('b', 'end')]; + expect(read(nodes, edges, 's1')).toEqual({ nextScreen: 'certain' }); + }); + + it('a fault path is a way the run may go', () => { + const nodes = [node('start', 'start'), node('s1'), node('write', 'plain'), node('s2'), node('oops'), node('end', 'end')]; + const edges = [ + edge('start', 's1'), edge('s1', 'write'), edge('write', 's2'), edge('s2', 'end'), + edge('write', 'oops', { type: 'fault' }), edge('oops', 'end'), + ]; + // Both ways pause on exactly one more screen. + expect(read(nodes, edges, 's1')).toEqual({ nextScreen: 'certain', step: 1, stepCount: 2 }); + // A fault path that ends with no screen makes the next one only possible. + const bare = edges.filter((e) => e.target !== 'oops' && e.source !== 'oops').concat(edge('write', 'end', { type: 'fault' })); + expect(read(nodes, bare, 's1')).toEqual({ nextScreen: 'possible', step: 1 }); + }); + + it('a screen the caller may already have answered counts as maybe — never as a sure step', () => { + const nodes = [node('start', 'start'), node('s1'), node('m', 'maybe'), node('end', 'end')]; + const edges = [edge('start', 's1'), edge('s1', 'm'), edge('m', 'end')]; + expect(read(nodes, edges, 's1')).toEqual({ nextScreen: 'possible', step: 1 }); + }); + + it('an inclusive decision may take several branches at once: no counts', () => { + const nodes = [ + node('start', 'start'), node('s1'), + { id: 'd', type: 'decision', label: 'd', config: { mode: 'inclusive' } }, + node('a'), node('b'), node('end', 'end'), + ]; + const edges = [ + edge('start', 's1'), edge('s1', 'd'), + edge('d', 'a', { condition: 'x' }), edge('d', 'b', { condition: 'y' }), + edge('a', 'end'), edge('b', 'end'), + ]; + expect(read(nodes, edges, 's1')).toEqual({ nextScreen: 'possible' }); + }); + + it('with no start node the position is unknowable, and a node the graph lacks gets no answer', () => { + const nodes = [node('s1'), node('s2'), node('end', 'end')]; + expect(read(nodes, [edge('s1', 's2'), edge('s2', 'end')], 's1')).toEqual({ nextScreen: 'certain' }); + expect(read(nodes, [], 'missing')).toBeUndefined(); + }); +}); diff --git a/packages/spec/src/api/automation-api.zod.test.ts b/packages/spec/src/api/automation-api.zod.test.ts index 353d0fe8ee4..49f454e24e5 100644 --- a/packages/spec/src/api/automation-api.zod.test.ts +++ b/packages/spec/src/api/automation-api.zod.test.ts @@ -397,6 +397,31 @@ describe('TriggerFlowResponseSchema', () => { expect(result.data.screen?.idVariable).toBe('customerId'); }); + // #26657 — the screen's place among the run's screens, which the engine reads + // off the flow graph so a runner can say Next or Submit and count steps. + it('should preserve the screen progress the engine read off the graph', () => { + const paused = (progress: unknown) => TriggerFlowResponseSchema.safeParse({ + success: true, + data: { success: true, status: 'paused', runId: 'run_wizard', screen: { nodeId: 's1', fields: [], progress } }, + }); + const exact = paused({ nextScreen: 'certain', step: 1, stepCount: 3 }); + expect(exact.success && exact.data.data.screen?.progress).toEqual({ nextScreen: 'certain', step: 1, stepCount: 3 }); + // A branch leaves the counts out; the answer alone is complete. + const branched = paused({ nextScreen: 'possible' }); + expect(branched.success && branched.data.data.screen?.progress).toEqual({ nextScreen: 'possible' }); + }); + + it('should reject a progress answer outside its closed vocabulary or a step below 1', () => { + const paused = (progress: unknown) => TriggerFlowResponseSchema.safeParse({ + success: true, + data: { success: true, status: 'paused', runId: 'run_wizard', screen: { nodeId: 's1', fields: [], progress } }, + }); + expect(paused({ nextScreen: 'maybe' }).success).toBe(false); + expect(paused({}).success).toBe(false); + expect(paused({ nextScreen: 'none', step: 0, stepCount: 1 }).success).toBe(false); + expect(paused({ nextScreen: 'none', step: 1.5 }).success).toBe(false); + }); + it('should preserve terminal message and summary on a finished run', () => { const result = TriggerFlowResponseSchema.parse({ success: true, diff --git a/ui/packages/app-shell/src/views/FlowRunner.tsx b/ui/packages/app-shell/src/views/FlowRunner.tsx index 1ed20f95309..89e6ec8b14a 100644 --- a/ui/packages/app-shell/src/views/FlowRunner.tsx +++ b/ui/packages/app-shell/src/views/FlowRunner.tsx @@ -232,12 +232,25 @@ * step wrote nothing here, and the next screen appearing is its whole answer. * The terminal toasts are untouched. * - * The flat footer's button still reads Submit on every step, and that is a - * measured limit, not a choice. "Next" on a step that is not the last needs to - * know which step IS the last, and nothing on the wire says so: the paused - * screen (`ScreenSpec` in `@objectstack/spec/contracts`) carries no final-step - * or step-count member, and a flow can branch after any screen, so only the - * engine could tell from the graph. The same gap keeps a step counter out. + * ## A step says Next or Submit, and counts itself only when it can (objectstack#26657) + * + * The flat footer's button used to read Submit on every step, so a requester + * believed the whole wizard was submitted on step 1. Which step is the last + * is a fact of the flow graph, and a flow can branch after any screen, so + * the engine answers it on the paused screen (`ScreenSpec.progress`) and + * this dialog ⛔ never walks the graph itself: + * - `nextScreen` `'certain'` or `'possible'` reads `common.next` — a later + * screen may still come, and a step that turns out to finish the run is + * answered by the completion toast; `'none'` reads `common.submit`. + * - The step counter (`form.stepOf`, the wording the form wizard already + * uses) is drawn beside the flow's name only when the engine served both + * `step` and `stepCount`, which it does only where every path agrees. A + * branch or a loop leaves the count out rather than drawing a wrong one. + * - No `progress` at all — a server that predates it, or a screen of a child + * run whose graph cannot see the parent's — keeps Submit and no counter, + * exactly as before. + * The `object-form` step's own button (`flowRunner.saveAndContinue`) is the + * form's, and is not relabelled here. */ import { Suspense, useEffect, useState } from 'react'; import { @@ -665,6 +678,14 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat // A long flat screen widens to two columns (objectui#12080 — see the header). const isLongScreen = !isObjectForm && screenFields(screen).length > NARROW_SCREEN_MAX_FIELDS; const isWide = showScreenBody && (isObjectForm || isLongScreen); + // The engine's word on what follows this step (objectstack#26657 — see the + // header): Next while another screen may come, Submit on the last or when + // the server says nothing, and a counter only from an exact count. + const progress = screen.progress; + const anotherScreen = progress !== undefined && progress.nextScreen !== 'none'; + const stepCounter = progress?.step !== undefined && progress.stepCount !== undefined + ? t('form.stepOf', { current: progress.step, total: progress.stepCount, defaultValue: 'Step {{current}} of {{total}}' }) + : undefined; return ( { if (!o && !submitting) onClose(); }}> @@ -677,7 +698,12 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat {/* The flow being run (objectui#11092), above the step's own heading, which stays the dialog's title. */} -

{shownFlowLabel}

+
+

{shownFlowLabel}

+ {stepCounter && ( +

{stepCounter}

+ )} +
{shown.title || t('flowRunner.title', { defaultValue: 'Input' })} {/* Heading and body text are drawn as served: the engine already translated both in the run's locale (objectstack#22507 ruling A, @@ -760,7 +786,9 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat ? t('flowRunner.submitting', { defaultValue: 'Submitting…' }) : uploads.anyUploading ? t('fields.file.uploading', { defaultValue: 'Uploading…' }) - : t('common.submit', { defaultValue: 'Submit' })} + : anotherScreen + ? t('common.next', { defaultValue: 'Next' }) + : t('common.submit', { defaultValue: 'Submit' })} )} diff --git a/ui/packages/app-shell/src/views/__tests__/FlowRunner.stepProgress-26657.test.tsx b/ui/packages/app-shell/src/views/__tests__/FlowRunner.stepProgress-26657.test.tsx new file mode 100644 index 00000000000..c6ac6dc25c4 --- /dev/null +++ b/ui/packages/app-shell/src/views/__tests__/FlowRunner.stepProgress-26657.test.tsx @@ -0,0 +1,113 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26657 — a step says Next or Submit, and counts itself only + * when it can. + * + * The flat footer's button read Submit on every step of a wizard, so a + * requester believed the contract was submitted on step 1. The engine now + * serves `ScreenSpec.progress` off the flow graph, and the runner reads it: + * + * 1. `nextScreen` `'certain'` or `'possible'` reads Next; `'none'` reads Submit. + * 2. The counter is drawn only from a served `step` AND `stepCount`. + * 3. No `progress` keeps Submit and no counter — the pre-#26657 answer. + * 4. A resume that pauses again relabels from the NEXT screen's progress. + * 5. The words come from the catalog: zh reads 下一步 / 提交 / 第 N 步,共 M 步. + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createI18n, I18nProvider } from '@object-ui/i18n'; + +vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })); + +const { FlowRunner } = await import('../FlowRunner'); +type ScreenFlowState = import('../FlowRunner').ScreenFlowState; +type ScreenSpec = ScreenFlowState['screen']; +type Progress = NonNullable; + +const step = (nodeId: string, title: string, progress?: Progress): ScreenSpec => ({ + nodeId, + title, + fields: [{ name: `${nodeId}_note`, label: 'Note', type: 'text' }], + ...(progress ? { progress } : {}), +}); + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); +} + +function mount(s: ScreenSpec, next?: ScreenSpec) { + const body = next + ? { success: true, data: { success: true, status: 'paused', runId: 'run_1', screen: next } } + : { success: true, data: { success: true } }; + const authFetch = vi.fn(async () => jsonResponse(body)); + const ui = ( + + ); + return { authFetch, ui }; +} + +describe('objectstack#26657 — the step button and counter follow ScreenSpec.progress', () => { + it('a step another screen surely follows reads Next and counts itself', () => { + render(mount(step('s1', 'Details', { nextScreen: 'certain', step: 1, stepCount: 3 })).ui); + expect(screen.getByRole('button', { name: 'Next' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Submit' })).not.toBeInTheDocument(); + expect(screen.getByTestId('flow-step-counter')).toHaveTextContent('Step 1 of 3'); + }); + + it('a step a branch may end still reads Next, and draws no counter without a total', () => { + render(mount(step('s1', 'Details', { nextScreen: 'possible', step: 1 })).ui); + expect(screen.getByRole('button', { name: 'Next' })).toBeInTheDocument(); + expect(screen.queryByTestId('flow-step-counter')).not.toBeInTheDocument(); + }); + + it('the last step reads Submit and still counts itself', () => { + render(mount(step('s3', 'Send to legal', { nextScreen: 'none', step: 3, stepCount: 3 })).ui); + expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Next' })).not.toBeInTheDocument(); + expect(screen.getByTestId('flow-step-counter')).toHaveTextContent('Step 3 of 3'); + }); + + it('a screen served without progress keeps Submit and no counter', () => { + render(mount(step('s1', 'Details')).ui); + expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument(); + expect(screen.queryByTestId('flow-step-counter')).not.toBeInTheDocument(); + }); + + it('a resume that pauses again relabels from the next screen', async () => { + const { ui, authFetch } = mount( + step('s1', 'Details', { nextScreen: 'certain', step: 1, stepCount: 2 }), + step('s2', 'Send to legal', { nextScreen: 'none', step: 2, stepCount: 2 }), + ); + render(ui); + await userEvent.setup().click(screen.getByRole('button', { name: 'Next' })); + + expect(await screen.findByRole('heading', { name: 'Send to legal' })).toBeInTheDocument(); + expect(authFetch).toHaveBeenCalledTimes(1); + expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument(); + expect(screen.getByTestId('flow-step-counter')).toHaveTextContent('Step 2 of 2'); + }); + + it('reads the words from the catalog — zh: 下一步, 提交 and the step counter', async () => { + const zh = createI18n({ defaultLanguage: 'zh', detectBrowserLanguage: false }); + expect(zh.language).toBe('zh'); + const { ui } = mount( + step('s1', '合同信息', { nextScreen: 'certain', step: 1, stepCount: 2 }), + step('s2', '提交法务', { nextScreen: 'none', step: 2, stepCount: 2 }), + ); + render({ui}); + + expect(await screen.findByRole('button', { name: '下一步' })).toBeInTheDocument(); + expect(screen.getByTestId('flow-step-counter')).toHaveTextContent('第 1 步,共 2 步'); + await userEvent.setup().click(screen.getByRole('button', { name: '下一步' })); + expect(await screen.findByRole('button', { name: '提交' })).toBeInTheDocument(); + expect(screen.getByTestId('flow-step-counter')).toHaveTextContent('第 2 步,共 2 步'); + }); +}); From 653293e7b8177159c052c1f26227d001312b1375 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 13:37:35 +0000 Subject: [PATCH 3/4] chore(spec): regenerate api-surface, export-origins and strictness counts for ScreenProgressSpec Claude-Session: https://claude.ai/code/session_01WGufzh5Z4kFNVFmensPenL Co-authored-by: Claude --- docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md | 2 +- packages/spec/api-surface/contracts.json | 1 + packages/spec/export-origins/contracts.json | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md index 80c986c933e..c85442a231d 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `api/` | 446 | +| `api/` | 447 | diff --git a/packages/spec/api-surface/contracts.json b/packages/spec/api-surface/contracts.json index 77cc67cea52..6ff711e7c89 100644 --- a/packages/spec/api-surface/contracts.json +++ b/packages/spec/api-surface/contracts.json @@ -275,6 +275,7 @@ "SchemaValidationReport (interface)", "SchemaValidationResult (interface)", "ScreenFieldSpec (interface)", + "ScreenProgressSpec (interface)", "ScreenSpec (interface)", "SearchHit (interface)", "SearchOptions (interface)", diff --git a/packages/spec/export-origins/contracts.json b/packages/spec/export-origins/contracts.json index 4ed8068441e..e907e6beed7 100644 --- a/packages/spec/export-origins/contracts.json +++ b/packages/spec/export-origins/contracts.json @@ -275,6 +275,7 @@ "SchemaValidationReport": "src/contracts/external-datasource-service.ts#SchemaValidationReport (interface)", "SchemaValidationResult": "src/contracts/external-datasource-service.ts#SchemaValidationResult (interface)", "ScreenFieldSpec": "src/contracts/automation-service.ts#ScreenFieldSpec (interface)", + "ScreenProgressSpec": "src/contracts/automation-service.ts#ScreenProgressSpec (interface)", "ScreenSpec": "src/contracts/automation-service.ts#ScreenSpec (interface)", "SearchHit": "src/contracts/search-service.ts#SearchHit (interface)", "SearchOptions": "src/contracts/search-service.ts#SearchOptions (interface)", From 094a249d92dcba0fb134ea601be98e95259eddcb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 14:49:23 +0000 Subject: [PATCH 4/4] docs(automation): say when a paused screen carries no progress The flows page opened with "every paused screen carries progress", which its own subflow paragraph and the contract contradict. Name the absent cases there and in the ScreenSpec.progress TSDoc: no flow name, a node that is not a screen of the registered flow, no start node, a child-run screen with no further screen, an older server. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WGufzh5Z4kFNVFmensPenL --- content/docs/automation/flows.mdx | 9 +++++++-- packages/spec/src/contracts/automation-service.ts | 8 +++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index f4cab036718..a5132ac1c1f 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -1328,8 +1328,13 @@ refused on the generic route, and the engine warns about it at registration. ### A paused screen says whether another one follows — Next, Submit and the step counter -Every paused screen carries `progress`, which the engine reads off the flow -graph when the screen pauses; the client never walks the graph itself. +A paused screen carries `progress`, which the engine reads off the flow graph +when the screen pauses; the client never walks the graph itself. It is absent +when the engine has no graph to read (a run started with no flow name, a node +that is not a `screen` of the registered flow, a flow with no start node), on a +screen inside a `subflow` whose own flow shows no further screen (below), and +from a server that predates it. With no `progress` the runner keeps its +**Submit** label and draws no counter. ```json "screen": { diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index a12ce764331..05cd45ba9b6 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -405,9 +405,11 @@ export interface ScreenSpec { * counter only from {@link ScreenProgressSpec.stepCount}. * * Absent when the engine cannot answer at all — a server that predates - * this member, or a screen inside a child run whose own graph shows no - * further screen (the parent may still have some) — and a runner then - * keeps the label it drew before this member existed. + * this member; a run with no flow name, a node that is not a `screen` of + * the registered flow, or a flow with no start node (no graph to read); + * or a screen inside a child run whose own graph shows no further screen + * (the parent may still have some) — and a runner then keeps the label + * it drew before this member existed. */ progress?: ScreenProgressSpec; }