Skip to content

Commit 2826d1e

Browse files
os-zhuangclaude
andauthored
fix(automation,approvals): 审批决策不能在流程原地不动的情况下"成功" (#4420) (#4460)
* fix(automation,approvals): an approval decision can no longer succeed while its flow stays parked (#4420) A flow paused at an `approval` node, a deploy, then an approver clicking Approve: the request row flipped to `approved`, the UI toasted success — and the flow never moved. No next-stage request, no error, the record's mirrored status frozen mid-workflow. Approval flows pause for days by design, so a restart mid-flight is the normal case, not the edge one. Durable suspended runs (#1518) had shipped and were not the missing piece. Two other things were. The wiring could enable a store over a table nobody had created. Object registration and store activation resolve different services in different phases — `manifest` at init(), `objectql` at start() — and the plugin declared no ordering. Composed ahead of ObjectQL, init() found no `manifest`, warned, and continued; start() attached the DB-backed store anyway. Every suspend then failed with `no such table: sys_automation_run` into a log line nobody read. Now: `optionalDependencies: ['com.objectstack.engine.objectql']` (order-if- present per ADR-0116 — an engine-less kernel must still boot), a missed registration retried at start() while it still precedes schema sync, no store attached when registration did not happen (at error level), a boot-time table probe, and a failed durable write logged at error rather than warn. A reported resume failure read as success. `AutomationEngine.resume()` answers a lost run by RETURNING `{ success: false }`, never by throwing; approvals discarded that value and counted only throws as failure. Resume failures are now classified — RUN_NOT_FOUND, STORE_UNAVAILABLE, RESUME_IN_PROGRESS — so a run gone for good is distinguishable from a store merely unreachable, and the raw resume route maps them to 404 / 503 / 409. Approvals acts on them. The new `hasSuspendedRun(runId)` — which reads the suspension store, unlike `getRun()`, and throws rather than answering false when the store is unreadable — pre-flights decide / sendBack / resubmit BEFORE their first write, so the zombie half-state is never created rather than merely reported: RESUME_TARGET_LOST (409), request left actionable. A resume that fails after the decision is durable throws RESUME_FAILED (500) naming the stranded run. A concurrent duplicate stays benign via the new `resumeError` field; recall and revise-window cancellation stay non-fatal but log at error. Compositions with no automation engine attached are unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EPRaZTNbACfvGKytYESX2h * docs(spec): regenerate the API reference for the new resume error codes (#4420) `check:docs` compares `content/docs/references/` against what `gen:schema && gen:docs` produces. The five codes this branch registers in the ADR-0112 ledger — RUN_NOT_FOUND, STORE_UNAVAILABLE, RESUME_IN_PROGRESS, RESUME_TARGET_LOST, RESUME_FAILED — feed `ErrorCode`, which the generator inlines into every response-envelope table. Hence eleven files, all of it the same union widening: the ledger page gains the five entries and every page that renders an ApiError picks them up. Generated, not hand-edited: pnpm --filter @objectstack/spec gen:schema && pnpm --filter @objectstack/spec gen:docs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EPRaZTNbACfvGKytYESX2h --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 83cf2d3 commit 2826d1e

26 files changed

Lines changed: 1288 additions & 161 deletions
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-automation": minor
4+
"@objectstack/plugin-approvals": minor
5+
"@objectstack/rest": patch
6+
"@objectstack/runtime": patch
7+
---
8+
9+
fix(automation,approvals): an approval decision can no longer succeed while its flow stays parked (#4420)
10+
11+
A flow paused at an `approval` node, a deploy, then an approver clicking
12+
Approve: the request row flipped to `approved`, the UI toasted success — and
13+
the flow never moved. No next-stage request, no error, the record's mirrored
14+
status frozen mid-workflow. Approval flows pause for days by design, so a
15+
restart mid-flight is the normal case: every release could quietly zombify
16+
every in-flight approval, with the approvers none the wiser.
17+
18+
Durable suspended runs (#1518) had shipped and were not the missing piece. Two
19+
other things were.
20+
21+
**The wiring could enable a store over a table nobody had created.** Object
22+
registration and store activation resolve different services in different
23+
phases — `manifest` at `init()`, `objectql` at `start()` — and the plugin
24+
declared no ordering. Composed ahead of ObjectQL, `init()` found no `manifest`,
25+
warned, and continued; `start()` then attached the DB-backed store anyway. Every
26+
suspend failed with `no such table: sys_automation_run` into a log line nobody
27+
read, pauses silently stayed in memory, and the next restart lost them all.
28+
Now: `AutomationServicePlugin` declares `optionalDependencies:
29+
['com.objectstack.engine.objectql']` (order-if-present, per ADR-0116 — an
30+
engine-less kernel must still boot); a registration missed at `init()` is
31+
retried at `start()`, which still lands before ObjectQL's schema sync; the
32+
store is never attached when registration did not happen, and says so at
33+
**error** level instead of warning; the table is probed once at boot so a
34+
broken setup surfaces there rather than one failed write at a time; and a
35+
failed durable write of a paused run is logged at error — it is data loss in
36+
waiting, not a warning.
37+
38+
**A reported resume failure read as success.** `AutomationEngine.resume()`
39+
answers a lost run by *returning* `{ success: false }`, never by throwing.
40+
`ApprovalService` discarded that return value, and `decide()` counted only a
41+
thrown error as failure — so a decision against a dead run came back
42+
`resumed: true`, HTTP 200. Resume failures are now classified
43+
(`RUN_NOT_FOUND`, `STORE_UNAVAILABLE`, `RESUME_IN_PROGRESS`, joining
44+
`PERMISSION_DENIED` / `INVALID_SIGNAL`), so a run that is gone for good is
45+
distinguishable from a store that is merely unreachable, and the raw resume
46+
route maps them to 404 / 503 / 409.
47+
48+
Approvals acts on them. A new `AutomationEngine.hasSuspendedRun(runId)` — which
49+
reads the suspension store, unlike `getRun()`, and throws rather than answering
50+
`false` when the store is unreadable — pre-flights every flow-advancing
51+
operation (`decide`, `sendBack`, `resubmit`) **before its first write**, so the
52+
zombie half-state is never created rather than merely reported: the decision
53+
fails with `RESUME_TARGET_LOST` (HTTP 409) and the request stays actionable. A
54+
resume that fails after the decision is durable can no longer be undone, but it
55+
now throws `RESUME_FAILED` (HTTP 500) naming the stranded run instead of
56+
reporting success. A concurrent duplicate resume stays benign — the engine's
57+
idempotency guard is doing its job — and reports through the new optional
58+
`resumeError` field. Recall and revise-window cancellation stay non-fatal by
59+
design (they abandon the request), but log at error with the reason instead of
60+
swallowing it. Compositions with no automation engine attached are unaffected.
61+
62+
Existing zombie requests from affected deployments (already `approved`, run
63+
stranded) are not repaired by this change — `releaseDeadRunRequests` only
64+
sweeps requests that are still `pending`.

content/docs/references/api/analytics.mdx

Lines changed: 3 additions & 3 deletions
Large diffs are not rendered by default.

content/docs/references/api/auth.mdx

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

content/docs/references/api/automation-api.mdx

Lines changed: 9 additions & 9 deletions
Large diffs are not rendered by default.

content/docs/references/api/batch.mdx

Lines changed: 3 additions & 3 deletions
Large diffs are not rendered by default.

content/docs/references/api/contract.mdx

Lines changed: 8 additions & 8 deletions
Large diffs are not rendered by default.

content/docs/references/api/error-code-ledger.mdx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,13 +280,17 @@ const result = ErrorCode.parse(data);
280280
* `REQUEST_NOT_FOUND`
281281
* `RESEED_NO_ROWS`
282282
* `RESEED_SKIPPED`
283+
* `RESUME_FAILED`
284+
* `RESUME_IN_PROGRESS`
285+
* `RESUME_TARGET_LOST`
283286
* `ROUTE_NOT_FOUND`
284287
* `RULE_DEFINE_FAILED`
285288
* `RULE_DELETE_FAILED`
286289
* `RULE_EVALUATE_FAILED`
287290
* `RULE_GET_FAILED`
288291
* `RULE_LIST_FAILED`
289292
* `RULE_NOT_FOUND`
293+
* `RUN_NOT_FOUND`
290294
* `SAML_REGISTER_FAILED`
291295
* `SCHEDULES_LIST_FAILED`
292296
* `SCHEDULE_DELETE_FAILED`
@@ -303,6 +307,7 @@ const result = ErrorCode.parse(data);
303307
* `SIGN_IN_REQUIRED`
304308
* `SSO_REGISTER_FAILED`
305309
* `SSO_REGISTER_FORBIDDEN`
310+
* `STORE_UNAVAILABLE`
306311
* `SUGGESTION_CONFIRM_FAILED`
307312
* `SUGGESTION_DISMISS_FAILED`
308313
* `SUGGESTION_LIST_FAILED`

content/docs/references/api/export.mdx

Lines changed: 6 additions & 6 deletions
Large diffs are not rendered by default.

content/docs/references/api/metadata.mdx

Lines changed: 19 additions & 19 deletions
Large diffs are not rendered by default.

content/docs/references/api/package-api.mdx

Lines changed: 8 additions & 8 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)