Skip to content

Commit 9fab076

Browse files
feat(spec,plugin-approvals,app-shell): the approvals get serves a request's own route, and both approval step lines draw it (#26976)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB7EoNpfhCGLSFBvVvp6hb9f)_ Fixes #26668 Clause-②: yes (widening) Metadata-protocol: none Skills-docs: none — `flow_route` is a served read member of the approvals API, not anything a metadata author writes; no skill teaches `flow_steps` (`git grep` over `skills/` and `content/docs/`: 0 hits), and the approvals guide gains a paragraph naming `flow_route` here (`content/docs/automation/approvals.mdx`) Nothing is left of #26563, which #26666 already completed: the part #26666 had to drop, the request page's line, is drawn again here. ## 维护者速读 - **改了什么**:审批单读取(`GET /api/v1/approvals/requests/:id`)新增 `flow_route`:本单所在流程运行已经走过的审批步骤(取自同一次运行自己的审批单行,不取流程图)、本单当前步骤,以及仅对待审单、仅在流程图自己就能确定时列出的后续步骤;遇到尚未决定的分支即停,并以 `ahead: 'undecided'` 明说。`flow_steps`(流程图主干)原样保留,文档写明步骤线不画它。业务记录审批面板与审批单页面都改画 `flow_route`;#26666 的客户端临时方案 `visitedApprovalSteps` 和审批单页「不画步骤线」的规则一并删除。 - **为什么改**:合同审批走「直属上级 → 仅财务」时,服务端只给主干「直属上级 · 法务 + 财务会签 · 分管领导 · 总经理」,全部是「待办」,本单所在的「财务」根本不在其中。#26666 只能让页面不画;现在由服务端给出本单真实路线,两处步骤线恢复。 - **风险与回滚**:契约是新增字段(加宽),`flow_steps` 字节不变,老客户端不受影响;每次单条读取多一次按 `flow_run_id` 的查询(最多 1000 行,系统身份)。业务记录面板上,未参与前序步骤的查看者现在也能看到这些步骤(以前因列表读只给参与者而缺失);步骤名与主干原本就已对同一读者可见。回滚 = revert 本 PR。 - **先看**:① `packages/plugins/plugin-approvals/src/approval-route.ts` 的 `stepsAhead`(哪些节点会让后续步骤停为 undecided);② `packages/spec/src/contracts/approval-service.ts` 的 `flow_route` 文档(契约原文);③ `packages/plugins/plugin-approvals/src/flow-route.integration.test.ts`(真实引擎上的报告路线)。 - **两分钟验证**:`pnpm --filter @objectstack/plugin-approvals exec vitest run src/flow-route.integration.test.ts`,预期 6 个测试全绿,其中报告路线的 `flow_route` 只有「Manager(done)· Finance(current)」两步,`ahead` 为 `undecided`。 - **需要你做的**:无,席位落地。 ## What changes **Measured first, on `origin/main` `7a2818c5`.** - The served row. `attachFlowSteps` walks the graph from `start`, taking `approve`, else `true`, else the first edge, and states each step against that one trunk. On the real engine, a `contract_approval`-shaped flow routed manager → finance only serves `flow_steps` with every trunk entry `upcoming` and `finance_only` absent (pinned as the first assertion of the reported-route case below, green on both sides on purpose). A route that rejoins the trunk (manager → legal only → executive) is served the joint rung as `done`, a rung the run never visited. - The history the row can draw from. The request row names its run (`flow_run_id`), its node (`flow_node_id`), `status` and `created_at`; every step the run opened is its own `sys_approval_request` row with the same four. The automation engine writes no step log for a run while it is paused (`ApprovalResumeSurface.getRun`: "the engine writes no execution-log entry until a run reaches a terminal state"), so the run's own request rows are the only history there is. The route is read from them. - The readers. After #26666 no surface draws `flow_steps`: the record approvals panel rebuilt its line client-side from the list read (`visitedApprovalSteps`), and the request page drew no line. Its other readers are type declarations only: `ApprovalRequestLite` (app-shell `useRecordApprovals.ts`), `ApprovalStepLine`'s step type, and the console's `approvalsApi.ts` row type. No API doc or skill names it. **The shape, option (a): `flow_steps` stays the trunk, and `ApprovalRequestRow.flow_route` is added.** Why not (b), redefining `flow_steps`: the field ships in published contracts and clients outside this tree may read it as the trunk, so redefining it changes the meaning of a served member under them. Adding a member leaves every existing reader byte-identical. The two fields do disagree off the trunk, so the contract says which one a step line reads: `flow_steps`' doc now says it is the trunk and that a step line draws `flow_route`, never it. Clause-② consequence: an added optional member of a published contract, `yes (widening)`. Metadata-protocol consequence: none. The member is served by a read, and nothing a metadata author writes changes (the claim's provisional `added` assumed an authorable key; re-judged from the diff, following #26361's `yes (widening)` / `none` for an added approvals read). **`flow_route` (contract text in `packages/spec/src/contracts/approval-service.ts`).** - `done`: earlier requests of the same run, on another node, `approved`, in the order the run opened them. A node approved in several rounds is listed once, at its latest round. These are the rules `visitedApprovalSteps` had, now on the server, read over every row of the run, not the viewer's list read. - The request's own node: `done` once approved, else `current`, as `flow_steps` marks it. - `upcoming`, for a pending request only: from the node's `approve` edges on, through nodes with exactly one non-fault out-edge and no condition. **What I chose for steps after the current one, and why:** the walk lists a step only when the graph alone puts it on this request's path once each step approves. It stops at a `decision`, a conditioned edge, several out-edges (a fan-out has no one order), a loop back, a missing node, or a node that runs other nodes (`subflow`, `map`, `loop`, or any node with regions). Evaluating a gateway's condition against the record now would be a guess: the record or the run's variables can change before the run gets there, and conditions can read outputs of decisions not yet made. Assuming each step approves is the reading a progress line has always had; a reject edge ends the request rather than routing it. - `ahead`: `end` (the walk reached the flow's end), `undecided` (it stopped at a branch; nothing past it is listed, and the trunk is never listed in its place), `decided` (the request is decided; its route ends at itself). - Single reads only, like `flow_steps`: `listRequests` serves neither. The run's rows are read as the system: the route carries step ids, labels and states, which `flow_steps` already served to the same reader. **Producer.** `packages/plugins/plugin-approvals/src/approval-route.ts` holds the rule as three pure functions (`passedSteps`, `stepsAhead`, `flowRoute`). `ApprovalService.attachFlowRoute` reads the run's rows and calls it from `attachFlowSteps`, on the one flow lookup. Steps are named as `flow_steps` names them (#24863): the reader's label, else the node's authored label, else the label the step's request stored. **Readers.** - `RecordApprovalsPanel` draws `pendingRequest.flow_route.steps`. `visitedApprovalSteps` is deleted. - `ApprovalDecisionPanel` (the request page, `record:approval_decision`) draws `request.flow_route.steps` again, below the snapshot summary and above the tally, at any status. The no-line rule is deleted. - `ApprovalStepLine`'s markup is unchanged; its step type is now `flow_route`'s, and its header says both callers hand it the served route. - `ApprovalRequestLite` and the console's row type declare `flow_route`; the app-shell README and the approvals guide say the same. ## Tests - **Real service path, new `flow-route.integration.test.ts` (6).** A real `ObjectQL` over `SqlDriver` (sqlite), the real `AutomationEngine` with its logic nodes, the real approval node and `ApprovalService`; every request opened by the engine, every decision resuming the run. A `contract_approval`-shaped flow (manager, then a gateway to joint / legal only / finance only / none, then an executive above 500000, then a general manager above 1000000) and a linear three-step flow. - Off the trunk, manager → finance only: `flow_route` is manager `done`, finance `current`, `ahead: 'undecided'` (the executive gateway is next), and none of the trunk's rungs. `flow_steps` is served as before (all `upcoming`, own node absent). - At the first step, a gateway right after it: `[manager current]`, `undecided`, never the trunk's joint rung. - A branch that rejoins the trunk: manager, legal only (`done`), executive (`current`); the trunk still says the joint rung was `done`. - A decided request: its route ends at itself, every step `done`, `ahead: 'decided'`. - On the trunk (the linear flow): at every step `flow_route.steps` deep-equals `flow_steps`, `ahead: 'end'`. - The list read serves neither member. - **The rule, new `approval-route.test.ts` (16).** `passedSteps`: order, outcomes that are not a pass (`returned`, `rejected`, `pending`, `cancelled`), rows opened later, the current node's earlier rounds, round dedup, rows with no node or time. `stepsAhead`: a linear tail (`end`), a non-approval node crossed, and each stop (`decision` even with one edge, conditioned edge, fan-out, `subflow` / `map` / `loop` / regions, loop back, missing node), fault edges ignored. `flowRoute`: pending, decided at each outcome, no node. - **ui (re-pinned or removed).** - `RecordApprovalsPanel.visitedRoute-26563.test.tsx` is **removed**, with the function it pinned. `RecordApprovalsPanel.servedRoute-26668.test.tsx` **replaces** it (5): the reported route drawn as served with no trunk rung, upcoming steps drawn muted and numbered, a viewer the list serves only the pending row still sees the whole route, a server that serves no route draws no line whatever the trunk reads, a one-step route draws none. - `RecordApprovalsPanel.stepProgressVertical.test.tsx` **re-pinned**: its fixtures are served routes again (done, current, upcoming), and the connector-tint case pins the upcoming segment's `bg-border` again. - `ApprovalDecisionPanel.stepLine-12491.test.tsx` **re-pinned** from "no line" to the drawn route: the reported row draws manager → finance and none of the trunk's other rungs; a decided row draws its route; a row with no route draws nothing; the line sits above the tally and the tally above the decisions. - `approvalRequestsDataSource.servedPageStepLine-12491.test.tsx` (console, the real served page) **re-pinned**: the off-trunk row draws its served three-step route, current marked, and none of the trunk's other rungs; a row with no route draws none; decided and person-submitted rows draw their routes; a one-step route draws none. <details><summary>Test readings, head 4efa377 (every run under scripts/pm/os-verify-lock.sh)</summary> - Reproduction on `origin/main`'s producer: `approval-service.ts` restored to `7a2818c5`'s blob (`92744064111b`, `attachFlowRoute` 0 hits) under an EXIT trap, then `flow-route.integration.test.ts`: Tests 5 failed, 1 passed (6), each failure `expected undefined to deeply equal …` on `flow_route`; the reported-route case's `flow_steps` assertion (all `upcoming`, own node absent) passed before it. The passing case is the list-read control. Restored with `git checkout HEAD --`: blob `1aa185105009` equal to HEAD, `git diff HEAD -- approval-service.ts` empty. No test file kept for it. - `approval-route.test.ts` + `flow-route.integration.test.ts`: Test Files 2 passed (2), Tests 22 passed (22), VERDICT command-exit 0. - `pnpm --filter @objectstack/plugin-approvals test`: Test Files 104 passed (104), Tests 1321 passed (1321); then `typecheck` (`tsc --noEmit`, the scripts project, `check:test-typecheck`: OK): VERDICT command-exit 0. - `pnpm --filter @objectstack/spec test`: Test Files 738 passed (738), Tests 22101 passed, 1 todo (22102), VERDICT command-exit 0. `pnpm --filter @objectstack/spec check:generated`: all 14 generated artifacts up to date (`flow_route` is an interface member; no api-surface, docs or authorable-surface artifact moves). - ui, the four touched suites: Test Files 4 passed (4), Tests 20 passed (20). Every approval test file in `packages/app-shell` and `apps/console` (by file name, plus every test naming `RecordApprovalsPanel`, `ApprovalDecisionPanel`, `ApprovalStepLine`, `useRecordApprovals`, `approval-decision`, `approval-progress`, `flow_steps` or `flow_route`): Test Files 61 passed (61), Tests 697 passed (697), VERDICT command-exit 0. The whole `packages/app-shell` suite was not run locally; CI's Test UI shards run it. - `pnpm --filter @object-ui/app-shell run type-check` and `pnpm --filter @object-ui/console run type-check`: VERDICT command-exit 0 (`tsconfig.test.json --listFilesOnly` lists the three touched app-shell tests; the console project lists its touched test). </details> ## Acceptance notes - **The line does not mark an undecided tail.** When `ahead` is `undecided` the line ends at the last listed step, as the #26666 line ended at the pending step. Drawing a "decided later" marker needs a new `approvalsInbox.*` pack row in every locale; it is left out of this diff. The served row says it; whether the line shows it is a ui follow-up if wanted. - **Who sees which steps changed on the record panel.** The client-side rebuild could only draw rows the list read served the viewer (participants only), so a finance controller saw a one-step line, which draws nothing. The served route lists the run's passed steps to anyone who can read the request. These are step ids, labels and states; `flow_steps` already served every step label of the flow, with `done` states along the trunk, to the same reader. - **A step a reject edge routes to, or a rung sent back for revision, is not `done`.** Only `approved` rows count as passed, the rule `visitedApprovalSteps` had. A route through a rejected rung shows only its approved rungs. - **Ordering** is by the run's `created_at` on its request rows. Two requests of one run opened in the same millisecond would order by the store's answer; a run opens its next request only after the previous decision, so this needs a sub-millisecond decide-and-resume. - `flow_steps` keeps its producer and its pins (`approval-service.test.ts`, `reader-locale-display-labels.integration.test.ts`) unchanged. ## Verification <details><summary>Derived gate sweep, head 4efa377</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 110 commands from the change set. Each ran serially with its exit code captured before any pipe, and no build ran alongside: 107 exited 0, and three exited 3 (PREREQUISITE NOT MET: `spec check:skill-examples`, `check:dual-build-cjs-loads`, `check:i18n`, which read a full build). After one `pnpm build` under the lock (VERDICT command-exit 0), those three ran again and exited 0 (`267 prose examples type-check`; the CJS load floors held; `check-i18n-bundles: OK`). `dispatch-gates --ran` reconciliation: `✓ 110 derived famil(ies) accounted for — 110 run, 0 NOT-MEASURED`. Local-only `check-dev-prereqs`: exit 0. </details> - `origin/main` gained commits after the branch point; none touches a file of this diff (`git diff --name-only HEAD...origin/main`, filtered for approval paths: empty). CI runs on the merge ref. - No dogfood battery: the title carries no `!` and `Clause-②` carries no `(narrowing)` arm. - No `skills/**` edit: not a governed (Tier H) diff. No ADR-0087 marker: nothing stored changes meaning; `check-adr-0087-registration --base origin/main` exited 0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzgrMKmuKm7tjxa5v95 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a8ee8a2 commit 9fab076

18 files changed

Lines changed: 1127 additions & 418 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/plugin-approvals': patch
4+
'@object-ui/app-shell': patch
5+
'@object-ui/console': patch
6+
---
7+
8+
feat(spec,plugin-approvals,app-shell): the approvals get serves a request's own route, and both approval step lines draw it
9+
10+
Clause-②: yes (widening)
11+
Metadata-protocol: none
12+
13+
The single request read (`GET /api/v1/approvals/requests/:id`) served one step list, `flow_steps`: the flow's trunk, walked from its start by taking the `approve` edge, else the `true` edge, else the first edge. A request a decision routed off the trunk was not on it. A contract routed from the manager to the finance-only rung was served the manager, the joint legal + finance rung, the executive and the general manager, all upcoming, and not its own rung.
14+
15+
What changes:
16+
17+
- `ApprovalRequestRow` gains `flow_route`, served on the same single reads as `flow_steps`. Its `steps` are the approval steps the request's flow run has passed (earlier requests of the same run, on another node, approved, in the order the run opened them, one entry per step), then the request's own step, then, for a pending request, the steps ahead that the flow's graph decides on its own when each step approves.
18+
- Nothing past an undecided branch is listed. The walk ahead stops at a decision node, a conditioned edge, several out-edges, a loop back, or a node that runs other nodes (`subflow`, `map`, `loop`). `ahead` then reads `undecided`. It reads `end` when the walk reached the flow's end, and `decided` for a request that is already decided, whose route ends at itself.
19+
- `flow_steps` is served unchanged. Its doc now says it is the trunk and that a step line never draws it.
20+
- The business record's approvals panel draws the pending request's `flow_route`, so a viewer who took no part in an earlier rung now sees it too. Before, the panel rebuilt the line from the request rows the list read served that viewer.
21+
- The approval request page draws its request's `flow_route`, at any status, where it had stopped drawing a step line at all.
22+
23+
What to do: nothing. A client that reads `flow_steps` reads what it read before. A client that draws a request's progress should draw `flow_route` instead.

‎content/docs/automation/approvals.mdx‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -873,6 +873,29 @@ It is computed from the open-time approver snapshot (OOO substitution applied),
873873
so it reflects who **actually** still needs to sign — the console renders it as
874874
per-group tick badges or a "2 of 3 · finance pending" count.
875875

876+
The same read serves the request's own **route**, `flow_route`, which both
877+
approval step lines draw (the record's approvals panel and the request page):
878+
879+
```jsonc
880+
// getRequest, for a contract a gateway routed from the manager to finance:
881+
"flow_route": {
882+
"steps": [
883+
{ "id": "manager_review", "label": "Manager", "state": "done" },
884+
{ "id": "finance_only", "label": "Finance", "state": "current" }
885+
],
886+
"ahead": "undecided" // a gateway follows: nothing past it is listed
887+
}
888+
```
889+
890+
`done` steps come from the run's own request rows (earlier requests of the same
891+
run, approved), never from the flow graph. Steps `upcoming` are listed only for
892+
a pending request, and only as far as the graph decides them when each step
893+
approves: the walk stops at a `decision`, a conditioned edge, several
894+
out-edges, a loop back or a `subflow` / `map` / `loop` node, and `ahead` reads
895+
`undecided`. `ahead` is `end` when the walk reached the flow's end, and
896+
`decided` for a request already decided. The older `flow_steps` member is the
897+
graph's first branch at every decision, not the route; nothing draws it.
898+
876899
Approval **notifications deep-link into the request**: `notify()` rewrites the
877900
inbox action URL to `/system/approvals?request=<id>`, so clicking the bell opens
878901
that request's drawer directly instead of a generic list.
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#26668] The rule behind `flow_route`, case by case. The served path, over
5+
* the real engine and a real run, is pinned in `flow-route.integration.test.ts`;
6+
* these are the cases a real run reaches rarely or slowly: rounds, other runs,
7+
* outcomes that are not a pass, and each kind of branch the walk refuses to
8+
* decide.
9+
*/
10+
11+
import { describe, it, expect } from 'vitest';
12+
import { flowRoute, passedSteps, stepsAhead, type RouteFlowGraph } from './approval-route.js';
13+
14+
const label = (id: string) => id.toUpperCase();
15+
const row = (flow_node_id: string, status: string, created_at: string, id = `${flow_node_id}@${created_at}`) =>
16+
({ id, flow_node_id, status, created_at });
17+
const at = (n: number) => `2026-10-27T09:00:${String(n).padStart(2, '0')}.000Z`;
18+
19+
/** start → a → b → end, each approval with an `approve` and a `reject` edge. */
20+
const LINE: RouteFlowGraph = {
21+
nodes: [
22+
{ id: 'start', type: 'start' },
23+
{ id: 'a', type: 'approval' },
24+
{ id: 'b', type: 'approval' },
25+
{ id: 'ok', type: 'end' },
26+
{ id: 'no', type: 'end' },
27+
],
28+
edges: [
29+
{ source: 'start', target: 'a' },
30+
{ source: 'a', target: 'b', label: 'approve' },
31+
{ source: 'a', target: 'no', label: 'reject' },
32+
{ source: 'b', target: 'ok', label: 'approve' },
33+
{ source: 'b', target: 'no', label: 'reject' },
34+
],
35+
};
36+
37+
/** `a` approves into `via`, then one more approval `z`. */
38+
const via = (node: { id: string; type: string; regions?: unknown }, edges: RouteFlowGraph['edges'] = []): RouteFlowGraph => ({
39+
nodes: [{ id: 'a', type: 'approval' }, node, { id: 'z', type: 'approval' }, { id: 'ok', type: 'end' }],
40+
edges: [
41+
{ source: 'a', target: node.id, label: 'approve' },
42+
...(edges.length ? edges : [{ source: node.id, target: 'z' }]),
43+
{ source: 'z', target: 'ok', label: 'approve' },
44+
],
45+
});
46+
47+
describe('passedSteps — what counts as a passed step', () => {
48+
const current = { id: 'cur', flow_node_id: 'c', created_at: at(30) };
49+
50+
it('earlier approved rows of the run, oldest first', () => {
51+
expect(passedSteps(current, [row('b', 'approved', at(20)), row('a', 'approved', at(10))], label)).toEqual([
52+
{ id: 'a', label: 'A', state: 'done' },
53+
{ id: 'b', label: 'B', state: 'done' },
54+
]);
55+
});
56+
57+
it('a step not approved is not passed: returned, rejected, pending, cancelled', () => {
58+
const rows = ['returned', 'rejected', 'pending', 'cancelled'].map((s, i) => row(`n${i}`, s, at(10 + i)));
59+
expect(passedSteps(current, rows, label)).toEqual([]);
60+
});
61+
62+
it('a row opened after the current one, and the current node\'s earlier rounds, are not passed', () => {
63+
expect(passedSteps(current, [row('a', 'approved', at(40)), row('c', 'approved', at(5))], label)).toEqual([]);
64+
});
65+
66+
it('a node approved in several rounds is listed once, at its latest round', () => {
67+
const rows = [row('a', 'approved', at(1)), row('b', 'approved', at(2)), row('a', 'approved', at(3))];
68+
expect(passedSteps(current, rows, label).map((s) => s.id)).toEqual(['b', 'a']);
69+
});
70+
71+
it('a row with no node or no open time passes nothing; nor does a current row without them', () => {
72+
expect(passedSteps(current, [{ id: 'x', status: 'approved', created_at: at(1) }, { id: 'y', flow_node_id: 'a', status: 'approved' }], label)).toEqual([]);
73+
expect(passedSteps({ id: 'cur', flow_node_id: 'c' }, [row('a', 'approved', at(1))], label)).toEqual([]);
74+
});
75+
});
76+
77+
describe('stepsAhead — what the graph alone decides past a node', () => {
78+
it('a linear tail: every step to the end, `end`', () => {
79+
expect(stepsAhead(LINE, 'a', label)).toEqual({ steps: [{ id: 'b', label: 'B', state: 'upcoming' }], ahead: 'end' });
80+
expect(stepsAhead(LINE, 'b', label)).toEqual({ steps: [], ahead: 'end' });
81+
});
82+
83+
it('a non-approval node with one unconditioned edge is crossed', () => {
84+
expect(stepsAhead(via({ id: 'note', type: 'notify' }), 'a', label)).toEqual({
85+
steps: [{ id: 'z', label: 'Z', state: 'upcoming' }], ahead: 'end',
86+
});
87+
});
88+
89+
it('a decision stops the walk, `undecided`, even with one edge', () => {
90+
expect(stepsAhead(via({ id: 'gate', type: 'decision' }), 'a', label)).toEqual({ steps: [], ahead: 'undecided' });
91+
});
92+
93+
it('a conditioned edge stops the walk', () => {
94+
const flow = via({ id: 'note', type: 'notify' }, [{ source: 'note', target: 'z', condition: 'record.amount > 1' }]);
95+
expect(stepsAhead(flow, 'a', label)).toEqual({ steps: [], ahead: 'undecided' });
96+
});
97+
98+
it('several out-edges stop the walk: a fan-out has no one order', () => {
99+
const flow = via({ id: 'fork', type: 'parallel_gateway' }, [{ source: 'fork', target: 'z' }, { source: 'fork', target: 'ok' }]);
100+
expect(stepsAhead(flow, 'a', label)).toEqual({ steps: [], ahead: 'undecided' });
101+
});
102+
103+
it('a node that runs other nodes stops the walk: subflow, map, loop, or any node with regions', () => {
104+
for (const node of [
105+
{ id: 'sub', type: 'subflow' }, { id: 'each', type: 'map' }, { id: 'loop', type: 'loop' }, { id: 'box', type: 'container', regions: [{}] },
106+
]) {
107+
expect(stepsAhead(via(node), 'a', label), node.type).toEqual({ steps: [], ahead: 'undecided' });
108+
}
109+
});
110+
111+
it('a loop back stops the walk', () => {
112+
const flow: RouteFlowGraph = {
113+
nodes: [{ id: 'a', type: 'approval' }, { id: 'b', type: 'approval' }],
114+
edges: [{ source: 'a', target: 'b', label: 'approve' }, { source: 'b', target: 'a', label: 'approve' }],
115+
};
116+
expect(stepsAhead(flow, 'a', label)).toEqual({ steps: [{ id: 'b', label: 'B', state: 'upcoming' }], ahead: 'undecided' });
117+
});
118+
119+
it('a fault edge is an error path, never the route; an edge to a missing node stops the walk', () => {
120+
const fault = via({ id: 'note', type: 'notify' }, [{ source: 'note', target: 'z' }, { source: 'note', target: 'ok', type: 'fault' }]);
121+
expect(stepsAhead(fault, 'a', label).steps.map((s) => s.id)).toEqual(['z']);
122+
const missing: RouteFlowGraph = { nodes: [{ id: 'a', type: 'approval' }], edges: [{ source: 'a', target: 'gone', label: 'approve' }] };
123+
expect(stepsAhead(missing, 'a', label)).toEqual({ steps: [], ahead: 'undecided' });
124+
});
125+
});
126+
127+
describe('flowRoute — the whole route', () => {
128+
const runRows = [row('a', 'approved', at(1))];
129+
130+
it('a pending request: passed, itself current, then the steps ahead', () => {
131+
expect(flowRoute({ id: 'b1', flow_node_id: 'b', created_at: at(2), status: 'pending' }, LINE, runRows, label)).toEqual({
132+
steps: [{ id: 'a', label: 'A', state: 'done' }, { id: 'b', label: 'B', state: 'current' }],
133+
ahead: 'end',
134+
});
135+
});
136+
137+
it('a decided request ends at itself: done once approved, current for any other outcome', () => {
138+
for (const [status, state] of [['approved', 'done'], ['rejected', 'current'], ['returned', 'current']] as const) {
139+
expect(flowRoute({ id: 'a2', flow_node_id: 'a', created_at: at(2), status }, LINE, [], label), status).toEqual({
140+
steps: [{ id: 'a', label: 'A', state }], ahead: 'decided',
141+
});
142+
}
143+
});
144+
145+
it('a row with no node has no route', () => {
146+
expect(flowRoute({ id: 'x', status: 'pending' }, LINE, runRows, label)).toBeUndefined();
147+
});
148+
});
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#26668] A request's own route through its flow — `ApprovalRequestRow.flow_route`,
5+
* the contract doc in `@objectstack/spec/contracts` is the rule. Two halves:
6+
*
7+
* - {@link passedSteps}: the steps the run has already passed, read off the
8+
* run's own `sys_approval_request` rows. History, never the graph.
9+
* - {@link stepsAhead}: the steps the graph alone decides past a pending node,
10+
* when each step approves. The walk stops, and says so, at the first branch
11+
* it cannot decide; it never takes the trunk's branch in its place, which is
12+
* what `flow_steps` does.
13+
*
14+
* Pure functions over the flow and the rows: the service reads both and
15+
* attaches the result (`ApprovalService.attachFlowRoute`).
16+
*/
17+
18+
import type { ApprovalRequestRow } from '@objectstack/spec/contracts';
19+
20+
export type FlowRoute = NonNullable<ApprovalRequestRow['flow_route']>;
21+
type RouteStep = FlowRoute['steps'][number];
22+
23+
/** The parts of a flow graph the route reads. */
24+
export interface RouteFlowGraph {
25+
nodes?: ReadonlyArray<{ id: string; type?: string; label?: string; regions?: unknown }>;
26+
edges?: ReadonlyArray<{ source: string; target: string; label?: string; type?: string; condition?: unknown; isDefault?: boolean }>;
27+
}
28+
29+
/** The parts of one stored request row the route reads. */
30+
export interface RouteRequestRow {
31+
id?: unknown;
32+
flow_node_id?: unknown;
33+
status?: unknown;
34+
created_at?: unknown;
35+
}
36+
37+
/** Names a step: the reader's label, else the node's, else the id. */
38+
export type RouteStepLabel = (nodeId: string) => string;
39+
40+
/**
41+
* Node types that run other nodes (another flow, or a region body), so an
42+
* approval may sit inside them where this walk cannot see it.
43+
*/
44+
const OPAQUE_NODE_TYPES = new Set(['subflow', 'map', 'loop']);
45+
46+
/**
47+
* The approval steps `current`'s run passed before `current`, oldest first:
48+
* a row of the same run, on another node, `approved`, opened before
49+
* `current`. A node approved in several rounds is listed once, at its latest.
50+
* `runRows` is the run's own request rows: the caller reads them by the run's
51+
* `flow_run_id`, and this function does not filter by run.
52+
*/
53+
export function passedSteps(
54+
current: { id: string; flow_node_id?: string; created_at?: string },
55+
runRows: ReadonlyArray<RouteRequestRow>,
56+
label: RouteStepLabel,
57+
): RouteStep[] {
58+
const nodeId = current.flow_node_id;
59+
const openedAt = current.created_at;
60+
if (!nodeId || !openedAt) return [];
61+
const passed = new Map<string, string>();
62+
for (const r of runRows) {
63+
const node = typeof r.flow_node_id === 'string' ? r.flow_node_id : undefined;
64+
const at = typeof r.created_at === 'string' ? r.created_at : undefined;
65+
if (!node || node === nodeId || String(r.id) === current.id) continue;
66+
if (r.status !== 'approved' || !at || at >= openedAt) continue;
67+
const prev = passed.get(node);
68+
if (!prev || prev < at) passed.set(node, at);
69+
}
70+
return [...passed.entries()]
71+
.sort(([, a], [, b]) => a.localeCompare(b))
72+
.map(([id]) => ({ id, label: label(id), state: 'done' as const }));
73+
}
74+
75+
/**
76+
* The approval steps past `fromNodeId` that the graph alone decides when each
77+
* step approves, and what lies past the last of them: `end` when the walk
78+
* reached the flow's end, `undecided` when it stopped at a branch.
79+
*
80+
* From an approval node the walk takes its `approve` out-edges (the edge a
81+
* decision resumes the run down); from any other node, every out-edge. Fault
82+
* edges are an error path and never part of the route. The walk follows ONE
83+
* edge with no condition and stops, `undecided`, at anything else: a
84+
* `decision` node, a conditioned edge, several out-edges, a node seen before,
85+
* a node it cannot find, or a node that runs other nodes.
86+
*/
87+
export function stepsAhead(
88+
flow: RouteFlowGraph,
89+
fromNodeId: string,
90+
label: RouteStepLabel,
91+
): { steps: RouteStep[]; ahead: 'end' | 'undecided' } {
92+
const nodes = new Map((flow.nodes ?? []).map((n) => [n.id, n]));
93+
const edges = (flow.edges ?? []).filter((e) => e.type !== 'fault');
94+
const steps: RouteStep[] = [];
95+
const seen = new Set<string>([fromNodeId]);
96+
let node = nodes.get(fromNodeId);
97+
while (node) {
98+
const out = edges.filter((e) => e.source === node!.id);
99+
const approve = node.type === 'approval' ? out.filter((e) => e.label === 'approve') : [];
100+
const next = approve.length > 0 ? approve : out;
101+
if (next.length === 0) return { steps, ahead: 'end' };
102+
if (node.type === 'decision' || next.length > 1 || next[0].condition) return { steps, ahead: 'undecided' };
103+
const target = nodes.get(next[0].target);
104+
if (!target || seen.has(target.id)) return { steps, ahead: 'undecided' };
105+
if (OPAQUE_NODE_TYPES.has(String(target.type)) || target.regions) return { steps, ahead: 'undecided' };
106+
seen.add(target.id);
107+
if (target.type === 'approval') steps.push({ id: target.id, label: label(target.id), state: 'upcoming' });
108+
node = target;
109+
}
110+
return { steps, ahead: 'undecided' };
111+
}
112+
113+
/**
114+
* The whole route of `row`: {@link passedSteps}, then `row`'s own node, then
115+
* — for a pending row only — {@link stepsAhead}. `undefined` when the row
116+
* names no node.
117+
*/
118+
export function flowRoute(
119+
row: Pick<ApprovalRequestRow, 'id' | 'flow_node_id' | 'created_at' | 'status'>,
120+
flow: RouteFlowGraph,
121+
runRows: ReadonlyArray<RouteRequestRow>,
122+
label: RouteStepLabel,
123+
): FlowRoute | undefined {
124+
const nodeId = row.flow_node_id;
125+
if (!nodeId) return undefined;
126+
const own: RouteStep = { id: nodeId, label: label(nodeId), state: row.status === 'approved' ? 'done' : 'current' };
127+
const before = passedSteps(row, runRows, label);
128+
if (row.status !== 'pending') return { steps: [...before, own], ahead: 'decided' };
129+
const after = stepsAhead(flow, nodeId, label);
130+
return { steps: [...before, own, ...after.steps], ahead: after.ahead };
131+
}

0 commit comments

Comments
 (0)