Repository navigation
Commit 9fab076
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
File tree
- .changeset
- content/docs/automation
- packages
- plugins/plugin-approvals/src
- spec/src/contracts
- ui
- apps/console/src/services
- packages/app-shell
- src
- hooks
- views
- approval-decision
- approval-progress
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
873 | 873 | | |
874 | 874 | | |
875 | 875 | | |
| 876 | + | |
| 877 | + | |
| 878 | + | |
| 879 | + | |
| 880 | + | |
| 881 | + | |
| 882 | + | |
| 883 | + | |
| 884 | + | |
| 885 | + | |
| 886 | + | |
| 887 | + | |
| 888 | + | |
| 889 | + | |
| 890 | + | |
| 891 | + | |
| 892 | + | |
| 893 | + | |
| 894 | + | |
| 895 | + | |
| 896 | + | |
| 897 | + | |
| 898 | + | |
876 | 899 | | |
877 | 900 | | |
878 | 901 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
0 commit comments