Skip to content

Commit e438a55

Browse files
feat(automation): a paused screen says whether another screen follows, and the flow runner reads Next or Submit and counts steps (#26752)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBGT99SR6FyoGywxafVEzdB1)_ Fixes #26657 Clause-②: yes (widening) Metadata-protocol: none Skills-docs: #26756 ## 维护者速读 - **改了什么**:屏幕流暂停时,引擎按流程图在 `ScreenSpec.progress` 里说明「后面还有没有屏幕」(`none` / `possible` / `certain`),只有在每条路径都一致时才附带第几步、共几步;Console 的 `FlowRunner` 据此把按钮写成「下一步」或「提交」,并且只在总数确定时画「第 N 步,共 M 步」。 - **为什么改**:hotcrm 的发起合同向导每一步按钮都是「提交」,申请人在第 1 步就以为合同已经提交;也画不出步数,因为暂停的屏幕里没有这些信息,而流程在任何屏幕之后都可能分支,只有引擎知道。 - **风险与回滚**:契约只加一个可选成员,旧服务端不发它,旧客户端忽略它;没有 `progress` 时按钮照旧是「提交」。引擎侧只在屏幕暂停时读一次流程图。回滚即 revert 本 PR。 - **先看**:`packages/services/service-automation/src/builtin/screen-nodes.ts` 的 `readScreenProgress`(分支、环、扇出与子流程的判定),`packages/spec/src/contracts/automation-service.ts` 的 `ScreenProgressSpec`,`ui/packages/app-shell/src/views/FlowRunner.tsx` 的按钮与计步。 - **两分钟验证**:`pnpm --filter @objectstack/service-automation exec vitest run src/builtin/screen-progress.test.ts`,预期 13 个测试全绿(线性、分支、环、审批式暂停、子流程各有一条)。 - **需要你做的**:无,席位落地(需先过契约档复核)。 ## What changes **The shape: `ScreenSpec.progress?: ScreenProgressSpec`** (`packages/spec/src/contracts/automation-service.ts`, with its wire spelling on `TriggerFlowResponseSchema` in `api/automation-api.zod.ts`, bound to the interface by the existing compile-time identity pin): ```ts interface ScreenProgressSpec { nextScreen: 'none' | 'possible' | 'certain'; step?: number; // 1-based, only when every path from the start agrees stepCount?: number; // only alongside step, when every path through this screen agrees } ``` Why this shape: - **A tri-state, not a boolean.** A flow can branch after any screen. A boolean `isLast` would have to guess on a branch: `false` claims more screens follow when they may not, `true` claims the end when one may follow. `'possible'` says exactly what the graph knows. The runner reads both `'possible'` and `'certain'` as "Next", because a later screen can still come; `'certain'` stays distinct so a reader can tell the two apart without walking the graph. - **The counts are optional members of the same object, each present only when exact.** "Absent" means "the graph does not make it exact", never "zero" or "unknown server". The whole object is absent only when the engine cannot answer at all (a server that predates it, or the child-run case below), and a runner then keeps its old label. - **One nested member, not three flat ones on `ScreenSpec`.** The three come from one computation and mean nothing apart; one optional key keeps the "no answer" state a single check. - **A served fact, not an authorable key.** Nothing a metadata author writes changes (`Metadata-protocol: none`); no author can mis-declare it. **The engine fills it** (`packages/services/service-automation/src/builtin/screen-nodes.ts`, `readScreenProgress`), reading the graph of the flow the run executes at the moment a `screen` pauses: - Every out-edge is a way the run may go; conditions are not evaluated ahead of time. `nextScreen` is plain reachability for `'none'`, and the fewest surely-pausing screens on any path to a point where the run may stop (a 0-1 BFS) for `'certain'`. - **A branch after a screen answers `'possible'`** — a `decision` with a branch that pauses on another screen and one that does not, a fault edge, a loop back. The engine never picks a branch. - `step` / `stepCount` are computed over strongly connected components, as the SET of screen totals the paths give; a count is served only when that set has one member. A loop through a screen, a parallel fan-out (several unconditional successors, an inclusive decision), a `subflow`, or a screen the caller may already have answered makes the count vary, so it is left out. - What counts as a screen: a `screen` node that surely pauses (an `objectName`, `waitForInput: true`, or declared fields without `waitForInput: false`), the executor's own rule. A field-less pass-through screen is not counted. A screen whose fields the caller supplied (`judgeHeadlessScreen`) may not pause, so it is "maybe". A non-screen node that can pause (an approval, a wait) serves no screen (only a `screen` node sets `NodeExecutionResult.screen`), so the stretch of screens may end there: a screen behind one is possible, never certain. - **A `subflow`** may serve screens of its child, or none, so the screen before one answers `'possible'` with no total. A screen INSIDE a child run sees only the child graph: it keeps `nextScreen` when that graph shows another screen, and serves no `progress` when it does not, because the parent may continue with screens of its own. **The runner reads it** (`ui/packages/app-shell/src/views/FlowRunner.tsx`): the flat step's primary button reads `common.next` while `nextScreen` is `'certain'` or `'possible'` and `common.submit` on `'none'` or with no `progress`; the step counter (`form.stepOf`, the wording the form wizard already uses) is drawn beside the flow's name only when both `step` and `stepCount` are served. The `object-form` step's own button (`flowRunner.saveAndContinue`) is the form's and is unchanged. **Docs:** `content/docs/automation/flows.mdx` gains "A paused screen says whether another one follows — Next, Submit and the step counter", with the authoring advice that follows from it (keep a wizard's screens on one straight path and branch after the last to get an exact counter). ## Tests - New `packages/services/service-automation/src/builtin/screen-progress.test.ts` (13 pins). End to end through the engine: a linear three-screen wizard (`certain 1/3`, `certain 2/3`, `none 3/3`, then completes); an object-form step (`none 1/1`); a branched flow (`possible`, step 1, no total; past the branch `none 2/2`); a looped flow (`certain` then `possible`, no counts, and `certain` again after looping back); a pause that serves no screen (`none 1/1` before an end, `possible` before a screen); a pass-through screen not counted; a subflow (`possible` before it; the child's first screen `certain` with no counts; the child's last screen with no `progress`). Over `readScreenProgress` directly: parallel fan-out, fault paths, a caller-answered screen, an inclusive decision, no start node and an unknown node. - `packages/spec/src/api/automation-api.zod.test.ts`: the trigger response keeps `progress`, and refuses a `nextScreen` outside the closed set, a missing `nextScreen`, `step: 0` and a fractional step. - New `ui/packages/app-shell/src/views/__tests__/FlowRunner.stepProgress-26657.test.tsx` (6 pins): Next and "Step 1 of 3" on `certain`; Next and no counter on `possible` with no total; Submit and "Step 3 of 3" on `none`; Submit and no counter with no `progress`; a resume that pauses again relabels from the next screen; in zh the words are 下一步 / 提交 / 第 1 步,共 2 步. ## Acceptance notes - **No new translation keys.** The runner reuses `common.next`, `common.submit` and `form.stepOf`, which every one of the ten bundles already carries (zh: 下一步 / 提交 / 第 {{current}} 步,共 {{total}} 步), so each `defaultValue` equals its `en` value. - **Skills half owed, not written here.** `skills/**` is a Tier H governed surface; teaching the screen row of `skills/objectstack-automation/SKILL.md` (and an eval) in this PR would make the whole PR Tier H. The docs half is here; the skills half is #26756. - **File surface beyond the claim, all part of the same contract change:** the wire spelling in `packages/spec/src/api/automation-api.zod.ts` (the existing `TriggerFlowDataMatchesContract` identity pin reds without it), its test, the docs page, and three regenerated spec artifacts (`api-surface/contracts.json`, `export-origins/contracts.json`, the `api/` strictness count 446 → 447 for the new module-local `z.object`). - The engine reads the graph by the run's `flowName` from the engine's flow map (`getFlow`), the same map a run starts and resumes from, and checks the node is a `screen` in it before answering. - The object-form step's button still reads "Save & Continue" on its last step too; the signal now exists, but relabelling the form's own button is outside this card. ## Verification Head `653293e7b` for everything below except the last block. Every run went through `os-verify-lock`, and each reading is the lock's own `VERDICT` line. <details><summary>Engine (service-automation)</summary> - `pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2 src/builtin/screen-progress.test.ts` → `Tests 13 passed (13)`, `VERDICT command-exit 0`. - Whole package: `pnpm --filter @objectstack/service-automation test` → `Test Files 213 passed (213)`, `Tests 2744 passed (2744)`; `typecheck` (`tsc --noEmit` and `check:test-typecheck`) → OK, 0 errors. </details> <details><summary>Spec</summary> - `vitest run src/api/automation-api.zod.test.ts` → `Tests 68 passed (68)`; `pnpm --filter @objectstack/spec typecheck` → exit 0 (`VERDICT command-exit 0` for the pair). The `TriggerFlowDataMatchesContract` identity pin compiles, so the wire spelling and `AutomationResult` agree. - `check:generated` found three artifacts stale (`api-surface/`, `export-origins/`, the `api/` strictness count); `check:generated --fix` rebuilt spec and regenerated exactly those (`VERDICT command-exit 0`), committed as their own commit. </details> <details><summary>Runner (ui)</summary> - `pnpm exec vitest run --maxWorkers=2 packages/app-shell/src/views/__tests__/FlowRunner` (ui root) → `Test Files 16 passed (16)`, `Tests 100 passed (100)`, `VERDICT command-exit 0`. - `pnpm --filter @object-ui/app-shell type-check` (both `tsc --noEmit` and `tsc -p tsconfig.test.json`), after building `@object-ui/app-shell^...` → `VERDICT command-exit 0`. - `pnpm check:new-line-citations` (ui) → `0 new citation(s)`. </details> <details><summary>Ablations (one per changed behaviour, via scripts/ablation-replace.mjs)</summary> - Engine: anchor `if (fansOut || loops(own)) return { nextScreen };` → `if (fansOut) return { nextScreen };` (anchor 1 → 0, blob `1cecbf95085c` → `11426a9602b7`). `screen-progress.test.ts`: `Tests 1 failed | 12 passed` — the looped flow, which then served a step on a screen inside a loop. Restored: blob equals HEAD, `git diff HEAD` empty. - Runner: anchor `const anotherScreen = progress !== undefined && progress.nextScreen !== 'none';` → `const anotherScreen = false;` (blob `89e6ec8b14af` → `862b624909c2`). The new runner file: `Tests 4 failed | 2 passed` — every Next pin (en and zh). Restored: blob equals HEAD, `git diff HEAD` empty. - Both test files import their subject from `src`, so no `dist` was involved. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `653293e7b`: 108 commands, each run serially with its exit code recorded before any pipe. `--ran`: `108 derived famil(ies) accounted for — 106 run, 2 NOT-MEASURED`. All 106 exit 0. NOT MEASURED: `spec check:skill-examples` and `check:dual-build-cjs-loads`, which exit 3 (prerequisite) without a whole-repo `pnpm build`; CI measures both. PR-body gates simulated through a local `GITHUB_EVENT_PATH` before opening: `check-changeset-no-major`, `check-adr-0087-registration`, `check-empty-changeset` exit 0; `check-closing-keyword-parity --body` binds exactly `Fixes #26657`. </details> <details><summary>Review follow-up at `094a249d9` (docs and TSDoc only)</summary> - The flows page no longer says every paused screen carries `progress`; it and the `ScreenSpec.progress` TSDoc now name the absent cases (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). - Re-ran the derived docs families on that head: `check-docs-section-name`, `check-issue-citations`, `check-affected-docs`, `spec gen:schema` then `spec check:docs`, `check:docs-transcript-drift` (after building `@objectstack/lint...`) → `VERDICT command-exit 0`; `check-drift-comment`, `spec check:empty-state`, `check:liveness`, `check:variant-docs`, `check:docs-audit-scope`, `check:docs-redirects`, `check:docs-single-h1`, `check:docs-spec-enumerations`, `check:issue-citations` → each exit 0. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzh5Z4kFNVFmensPenL)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4f05de4 commit e438a55

12 files changed

Lines changed: 799 additions & 10 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/service-automation': patch
4+
'@object-ui/app-shell': patch
5+
---
6+
7+
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
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
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.
13+
14+
- **`@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.
15+
- **`@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.
16+
- **`@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.
17+
18+
What to do: nothing. A custom runner that reads the paused screen can read `progress` the same way.

‎content/docs/automation/flows.mdx‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1326,6 +1326,48 @@ producer), and `'service'` when the decision to continue belongs to your service
13261326
rather than to whoever holds the run id. Declaring neither leaves your pauses
13271327
refused on the generic route, and the engine warns about it at registration.
13281328

1329+
### A paused screen says whether another one follows — Next, Submit and the step counter
1330+
1331+
A paused screen carries `progress`, which the engine reads off the flow graph
1332+
when the screen pauses; the client never walks the graph itself. It is absent
1333+
when the engine has no graph to read (a run started with no flow name, a node
1334+
that is not a `screen` of the registered flow, a flow with no start node), on a
1335+
screen inside a `subflow` whose own flow shows no further screen (below), and
1336+
from a server that predates it. With no `progress` the runner keeps its
1337+
**Submit** label and draws no counter.
1338+
1339+
```json
1340+
"screen": {
1341+
"nodeId": "contract_details",
1342+
"fields": [ … ],
1343+
"progress": { "nextScreen": "certain", "step": 1, "stepCount": 3 }
1344+
}
1345+
```
1346+
1347+
| `nextScreen` | means | the Console's flow runner |
1348+
|---|---|---|
1349+
| `none` | no path out of this screen reaches another one: the last step | **Submit** |
1350+
| `certain` | every path pauses on another screen first | **Next** |
1351+
| `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** |
1352+
1353+
`step` (1-based) and `stepCount` appear only when **every** path agrees on them,
1354+
and the runner draws "Step 1 of 3" only when both are there. A `decision`
1355+
whose branches pause on different numbers of screens drops `stepCount` for the
1356+
screens before it, and `step` for a screen where those branches meet again; a
1357+
loop through a screen, a parallel fan-out or a `subflow` drops both. The engine
1358+
never guesses which branch the run will take.
1359+
1360+
What counts as "another screen": a `screen` node that pauses (it declares
1361+
fields, sets `waitForInput: true`, or names an `objectName`). A field-less
1362+
screen that passes through does not count, and a screen the caller may already
1363+
have answered ([below](#a-screen-the-caller-already-answered-does-not-pause))
1364+
counts as possible, never certain. A screen inside a `subflow` carries only
1365+
`nextScreen`, and only when its own flow shows another screen after it: the
1366+
parent may continue with screens of its own.
1367+
1368+
So a wizard that should read Next, Next, Submit and count "Step N of 3" keeps
1369+
its screens on one straight path, and branches only after its last screen.
1370+
13291371
### A screen the caller already answered does not pause
13301372

13311373
A `screen` node exists to collect input. When the run's **caller already supplied

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.
1919

2020
| Dir | Sites |
2121
|---|---|
22-
| `api/` | 446 |
22+
| `api/` | 447 |

‎packages/services/service-automation/src/builtin/screen-nodes.ts‎

Lines changed: 223 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
import type { PluginContext } from '@objectstack/core';
44
import { defineActionDescriptor, isExpressionEnvelopeShaped, ScreenConfigSchema, ScriptConfigSchema } from '@objectstack/spec/automation';
5-
import type { ScreenConfigParsed, ScriptConfigParsed } from '@objectstack/spec/automation';
5+
import type { FlowEdgeParsed, FlowNodeParsed, FlowParsed, ScreenConfigParsed, ScriptConfigParsed } from '@objectstack/spec/automation';
6+
import type { ScreenProgressSpec } from '@objectstack/spec/contracts';
67
import type { AutomationEngine } from '../engine.js';
78
import { flowScreenCopyKey, type FlowScreenCopyKey } from '@objectstack/spec/system';
89
import { renderTextSlot } from './template.js';
@@ -83,6 +84,201 @@ function valueKindOf(value: unknown): string {
8384
return `a ${typeof value} (\`${String(value)}\`)`;
8485
}
8586

87+
/**
88+
* How one node of a flow's top-level graph counts toward the screens a run
89+
* serves, for {@link readScreenProgress} (#26657):
90+
*
91+
* - `'screen'` — a `screen` node that pauses whenever the run reaches it.
92+
* - `'maybe-screen'` — a `screen` node the run's caller may already have
93+
* answered (`judgeHeadlessScreen`), so whether it pauses depends on values.
94+
* - `'subflow'` — a node that may serve screens of its CHILD run, any number
95+
* of them or none (`subflow-node.ts` forwards the child's `screen`), and
96+
* whose pause may end the stretch of screens.
97+
* - `'pause'` — any other node that can pause. It serves no screen (only a
98+
* `screen` node sets `NodeExecutionResult.screen`, and `subflow` forwards
99+
* it), so the stretch of screens may end there.
100+
* - `'plain'` — everything else, a `screen` that never pauses included.
101+
*/
102+
export type ScreenProgressRole = 'screen' | 'maybe-screen' | 'subflow' | 'pause' | 'plain';
103+
104+
/**
105+
* A paused screen's place in its run, read off the flow's top-level graph
106+
* (#26657) — the {@link ScreenProgressSpec} served on `ScreenSpec.progress`.
107+
* `undefined` only when the graph does not hold the node.
108+
*
109+
* Every out-edge is a way the run may go: the engine does not evaluate a
110+
* condition ahead of time, so a branch it cannot see past answers
111+
* `'possible'`, and a count that differs between paths is left out rather
112+
* than guessed. Region bodies (`loop` / `try_catch` / `parallel`) are not
113+
* walked: a pause inside one is refused (`region-suspension-refusal.ts`), so
114+
* no screen there is served.
115+
*/
116+
export function readScreenProgress(
117+
flow: Pick<FlowParsed, 'nodes' | 'edges'>,
118+
nodeId: string,
119+
roleOf: (node: FlowNodeParsed) => ScreenProgressRole,
120+
): ScreenProgressSpec | undefined {
121+
const nodes = new Map(flow.nodes.map((n) => [n.id, n] as const));
122+
if (!nodes.has(nodeId)) return undefined;
123+
const roles = new Map([...nodes.values()].map((n) => [n.id, roleOf(n)] as const));
124+
const outEdges = new Map<string, FlowEdgeParsed[]>([...nodes.keys()].map((id) => [id, []]));
125+
for (const edge of flow.edges ?? []) {
126+
if (nodes.has(edge.source) && nodes.has(edge.target)) outEdges.get(edge.source)!.push(edge);
127+
}
128+
const successors = (id: string): string[] => outEdges.get(id)!.map((e) => e.target);
129+
const servesScreen = (id: string): boolean => {
130+
const role = roles.get(id);
131+
return role === 'screen' || role === 'maybe-screen' || role === 'subflow';
132+
};
133+
134+
// How one visit of a node continues, read the way `traverseNext` walks:
135+
// the conditional out-edges that hold run (on an exclusive `decision`
136+
// only the first), a default edge only when none held, the unconditional
137+
// ones always, and a fault edge only when the node fails. A visit may
138+
// STOP when nothing is sure to follow, or when the node may pause without
139+
// a screen; it FANS OUT when it may continue into several successors.
140+
const visitShape = (id: string): { mayStop: boolean; fansOut: boolean } => {
141+
const node = nodes.get(id)!;
142+
const role = roles.get(id)!;
143+
const edges = outEdges.get(id)!;
144+
const onSuccess = edges.filter((e) => e.type !== 'fault');
145+
const conditional = onSuccess.filter((e) => e.condition).length;
146+
const fallback = onSuccess.filter((e) => !e.condition && e.isDefault).length;
147+
const always = onSuccess.length - conditional - fallback;
148+
const exclusive = node.type === 'decision'
149+
&& (node.config as { mode?: unknown } | undefined)?.mode !== 'inclusive';
150+
const fansOut = edges.length - onSuccess.length > 1
151+
|| always + fallback > 1
152+
|| (exclusive ? always > 0 && conditional > 0 : always + conditional > 1);
153+
return { mayStop: role === 'subflow' || role === 'pause' || (always === 0 && fallback === 0), fansOut };
154+
};
155+
const shapes = new Map([...nodes.keys()].map((id) => [id, visitShape(id)] as const));
156+
157+
// `nextScreen`. "Another screen can follow" is plain reachability, a loop
158+
// back to this very screen included. "Certain" is the fewest screens any
159+
// path from here pauses on before it may stop (a 0-1 BFS, entering a
160+
// screen costs 1), where only a `screen` that surely pauses counts.
161+
const seen = new Set<string>();
162+
const queue = [...successors(nodeId)];
163+
let reachesScreen = false;
164+
while (queue.length > 0) {
165+
const id = queue.shift()!;
166+
if (seen.has(id)) continue;
167+
seen.add(id);
168+
if (servesScreen(id)) reachesScreen = true;
169+
queue.push(...successors(id));
170+
}
171+
const fewest = new Map<string, number>([[nodeId, 0]]);
172+
const deque: string[] = [nodeId];
173+
let fewestToStop = shapes.get(nodeId)!.mayStop ? 0 : Infinity;
174+
while (deque.length > 0) {
175+
const id = deque.shift()!;
176+
const here = fewest.get(id)!;
177+
for (const next of successors(id)) {
178+
const cost = here + (roles.get(next) === 'screen' ? 1 : 0);
179+
if (cost >= (fewest.get(next) ?? Infinity)) continue;
180+
fewest.set(next, cost);
181+
if (shapes.get(next)!.mayStop) fewestToStop = Math.min(fewestToStop, cost);
182+
if (cost === here) deque.unshift(next); else deque.push(next);
183+
}
184+
}
185+
const nextScreen: ScreenProgressSpec['nextScreen'] = !reachesScreen
186+
? 'none'
187+
: fewestToStop >= 1 ? 'certain' : 'possible';
188+
189+
// `step` / `stepCount` — exact counts over the strongly connected
190+
// components (Tarjan), so a loop collapses to one unit. Each count is the
191+
// SET of screen totals the paths give; it is exact when the set has one
192+
// member, and `null` stands for "varies": a loop holding a screen, a
193+
// `subflow`, a maybe-screen. A fan-out anywhere the start reaches makes a
194+
// run take several paths at once, so no count is exact then either.
195+
const start = flow.nodes.find((n) => n.type === 'start');
196+
if (!start) return { nextScreen };
197+
const component = new Map<string, number>();
198+
const members: string[][] = [];
199+
const index = new Map<string, number>();
200+
const low = new Map<string, number>();
201+
const stack: string[] = [];
202+
const onStack = new Set<string>();
203+
const visit = (id: string): void => {
204+
index.set(id, index.size);
205+
low.set(id, index.get(id)!);
206+
stack.push(id);
207+
onStack.add(id);
208+
for (const next of successors(id)) {
209+
if (!index.has(next)) {
210+
visit(next);
211+
low.set(id, Math.min(low.get(id)!, low.get(next)!));
212+
} else if (onStack.has(next)) {
213+
low.set(id, Math.min(low.get(id)!, index.get(next)!));
214+
}
215+
}
216+
if (low.get(id) !== index.get(id)) return;
217+
const group: string[] = [];
218+
let popped: string;
219+
do {
220+
popped = stack.pop()!;
221+
onStack.delete(popped);
222+
component.set(popped, members.length);
223+
group.push(popped);
224+
} while (popped !== id);
225+
members.push(group);
226+
};
227+
for (const id of nodes.keys()) if (!index.has(id)) visit(id);
228+
229+
// Tarjan closes a component only after every component it reaches, so
230+
// `members` runs sinks first (reverse topological order).
231+
const loops = (c: number): boolean =>
232+
members[c].length > 1 || successors(members[c][0]).includes(members[c][0]);
233+
const weight = (c: number): number | null => {
234+
if (loops(c)) return members[c].some(servesScreen) ? null : 0;
235+
const role = roles.get(members[c][0]);
236+
return role === 'screen' ? 1 : role === 'plain' || role === 'pause' ? 0 : null;
237+
};
238+
const exits = (c: number): number[] =>
239+
[...new Set(members[c].flatMap(successors).map((id) => component.get(id)!))].filter((d) => d !== c);
240+
type Counts = Set<number> | null;
241+
const shift = (counts: Counts, by: number | null): Counts =>
242+
counts === null || by === null ? null : new Set([...counts].map((n) => n + by));
243+
const union = (a: Counts, b: Counts): Counts => (a === null || b === null ? null : new Set([...a, ...b]));
244+
245+
// Screens strictly after a component, to wherever the run may stop.
246+
const after = new Map<number, Counts>();
247+
for (let c = 0; c < members.length; c += 1) {
248+
let counts: Counts = members[c].some((id) => shapes.get(id)!.mayStop) ? new Set([0]) : new Set();
249+
for (const d of exits(c)) counts = union(counts, shift(after.get(d)!, weight(d)));
250+
after.set(c, counts);
251+
}
252+
// Screens strictly before a component, from the start.
253+
const reached: number[] = [];
254+
const before = new Map<number, Counts>([[component.get(start.id)!, new Set([0])]]);
255+
for (let c = members.length - 1; c >= 0; c -= 1) {
256+
const counts = before.get(c);
257+
if (counts === undefined) continue;
258+
reached.push(c);
259+
for (const d of exits(c)) before.set(d, union(before.get(d) ?? new Set(), shift(counts, weight(c))));
260+
}
261+
const exact = (counts: Counts | undefined): number | undefined =>
262+
counts && counts.size === 1 ? [...counts][0] : undefined;
263+
264+
const own = component.get(nodeId)!;
265+
const fansOut = reached.some((c) => members[c].some((id) => shapes.get(id)!.fansOut));
266+
if (fansOut || loops(own)) return { nextScreen };
267+
const prior = exact(before.get(own));
268+
if (prior === undefined) return { nextScreen };
269+
const remaining = exact(after.get(own));
270+
return {
271+
nextScreen,
272+
step: prior + 1,
273+
...(remaining !== undefined ? { stepCount: prior + 1 + remaining } : {}),
274+
};
275+
}
276+
277+
/** The `progress` member, spread in only when the graph answered — absent, never `undefined`. */
278+
function progressMember(progress: ScreenProgressSpec | undefined): { progress?: ScreenProgressSpec } {
279+
return progress ? { progress } : {};
280+
}
281+
86282
export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext): void {
87283
// screen — server-side pass-through (input vars already injected by engine).
88284
engine.registerNodeExecutor({
@@ -231,6 +427,30 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext
231427
typeof cfg.description === 'string' && cfg.description.length > 0
232428
? screenText('description', cfg.description, 'the body text')
233429
: undefined;
430+
// [#26657] Where this screen sits among the run's screens, read off
431+
// the graph of the flow this run executes (`readScreenProgress`), so
432+
// the runner can say "Next" or "Submit" and count steps. Asked only
433+
// once the screen pauses. A screen inside a CHILD run (a `subflow`)
434+
// sees its own graph alone: "another screen follows" still holds
435+
// there, but "none follows" and the counts do not, because the
436+
// parent may continue with screens of its own — so those are left out.
437+
const progress = async (): Promise<ScreenProgressSpec | undefined> => {
438+
if (flowName === undefined) return undefined;
439+
const flow = await engine.getFlow(flowName);
440+
if (!flow?.nodes.some((n) => n.id === node.id && n.type === 'screen')) return undefined;
441+
const read = readScreenProgress(flow, node.id, (n) => {
442+
if (n.type === 'subflow') return 'subflow';
443+
if (n.type !== 'screen') return engine.getActionDescriptor(n.type)?.supportsPause === true ? 'pause' : 'plain';
444+
// The executor's own pause rule, below: an object form always
445+
// pauses, `waitForInput` decides, else declared fields do.
446+
const raw = (n.config ?? {}) as { objectName?: unknown; fields?: unknown; waitForInput?: unknown };
447+
if ((typeof raw.objectName === 'string' && raw.objectName.trim()) || raw.waitForInput === true) return 'screen';
448+
if (!Array.isArray(raw.fields) || raw.fields.length === 0 || raw.waitForInput === false) return 'plain';
449+
return judgeHeadlessScreen(raw.fields, new Map(), context).supplied.length > 0 ? 'maybe-screen' : 'screen';
450+
});
451+
if (!read || (context as { $parentRunId?: unknown }).$parentRunId == null) return read;
452+
return read.nextScreen === 'none' ? undefined : { nextScreen: read.nextScreen };
453+
};
234454
// [#19939] The record an `edit` form opens. A literal is the contract's
235455
// string, served as written. An envelope must evaluate to the record's
236456
// id, a non-blank string: anything else — `null` included — fails the
@@ -280,6 +500,7 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext
280500
defaults,
281501
idVariable,
282502
fields: [],
503+
...progressMember(await progress()),
283504
},
284505
};
285506
}
@@ -361,6 +582,7 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext
361582
title: heading() ?? node.label ?? 'Input',
362583
description: body(),
363584
fields,
585+
...progressMember(await progress()),
364586
},
365587
};
366588
},

0 commit comments

Comments
 (0)