|
2 | 2 |
|
3 | 3 | import type { PluginContext } from '@objectstack/core'; |
4 | 4 | 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'; |
6 | 7 | import type { AutomationEngine } from '../engine.js'; |
7 | 8 | import { flowScreenCopyKey, type FlowScreenCopyKey } from '@objectstack/spec/system'; |
8 | 9 | import { renderTextSlot } from './template.js'; |
@@ -83,6 +84,201 @@ function valueKindOf(value: unknown): string { |
83 | 84 | return `a ${typeof value} (\`${String(value)}\`)`; |
84 | 85 | } |
85 | 86 |
|
| 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 | + |
86 | 282 | export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext): void { |
87 | 283 | // screen — server-side pass-through (input vars already injected by engine). |
88 | 284 | engine.registerNodeExecutor({ |
@@ -231,6 +427,30 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext |
231 | 427 | typeof cfg.description === 'string' && cfg.description.length > 0 |
232 | 428 | ? screenText('description', cfg.description, 'the body text') |
233 | 429 | : 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 | + }; |
234 | 454 | // [#19939] The record an `edit` form opens. A literal is the contract's |
235 | 455 | // string, served as written. An envelope must evaluate to the record's |
236 | 456 | // id, a non-blank string: anything else — `null` included — fails the |
@@ -280,6 +500,7 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext |
280 | 500 | defaults, |
281 | 501 | idVariable, |
282 | 502 | fields: [], |
| 503 | + ...progressMember(await progress()), |
283 | 504 | }, |
284 | 505 | }; |
285 | 506 | } |
@@ -361,6 +582,7 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext |
361 | 582 | title: heading() ?? node.label ?? 'Input', |
362 | 583 | description: body(), |
363 | 584 | fields, |
| 585 | + ...progressMember(await progress()), |
364 | 586 | }, |
365 | 587 | }; |
366 | 588 | }, |
|
0 commit comments