Skip to content

Commit a3009cb

Browse files
fix(plugin-approvals)!: refuse a decision whose paused run lost its flow before recording it (409 RESUME_TARGET_LOST, not 500 after the write) (#26544)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XW7vnyaZVQ7e4bbVPxZpnVF)_ Fixes #26477 Clause-②: yes (narrowing: an approve, reject or send-back on a request whose paused run can never resume, because its flow was deleted or the node it parked on was edited away, is refused 409 `RESUME_TARGET_LOST` with nothing written, where it was recorded as finalized and answered 500 `RESUME_FAILED`; widened: `@objectstack/service-automation`'s `AutomationEngine` gains the read-only public method `unresumableSuspensionCause(runId)`) Metadata-protocol: none Skills-docs: none — no skill or hand-written docs page teaches what a decision answers when its run's flow is gone; the answer it now gets is the existing `RESUME_TARGET_LOST` ledger row, whose docs page is generated ## 维护者速读 - **改了什么**:流程在审批暂停期间被删除(或其暂停节点被编辑掉)后,审批人点"批准"不再先记下决定再报 500,而是在写入任何东西之前就答 409 `RESUME_TARGET_LOST`,与"先取消运行再批准"的对照情形同形。 - **为什么改**:原来的前置检查只问"暂停记录还在不在",而暂停记录比流程活得久,于是决定被记成已批准、流程却永远不再推进,也无法修复。 - **风险与回滚**:风险低。只多了一次只读查询;读不到存储时照旧放行(fail open)。回滚即还原本 PR 的两个包。 - **先看**:`packages/plugins/plugin-approvals/src/approval-service.ts` 的 `assertRunResumable`;`packages/services/service-automation/src/run-resume.ts` 的 `resolveResumeTarget`;`engine.ts` 的 `unresumableSuspensionCause`。 - **两分钟验证**:`pnpm --filter @objectstack/plugin-approvals exec vitest run src/deleted-flow-decision-refusal.test.ts`,预期 5 个用例全绿。 - **需要你做的**:无,席位落地。 ## What changes - **`@objectstack/service-automation`.** The resume's two never-dispatched exits (the run's flow is not registered, or the node it parked on is gone) are now one pure function, `resolveResumeTarget` in `run-resume.ts`. `resumeInternal` calls it with byte-identical cause texts, so the resume and settle behaviour from #23963 is unchanged. `AutomationEngine.unresumableSuspensionCause(runId)` is a new read-only public method that asks the same function without acting on the answer. It returns the cause for an existing suspension that can never be dispatched, and `undefined` when the run can continue or when no suspension exists (that answer belongs to `hasSuspendedRun`). It rejects when the store cannot be read, as `hasSuspendedRun` does. It settles and records nothing, so it may answer before the boot seal (AGENTS.md "Startup registry reads": a read-only probe is legal). - **`@objectstack/plugin-approvals`.** `ApprovalResumeSurface` declares the optional member. `assertRunResumable`, the pre-flight that every decision door (approve, reject, send back, resubmit) runs before its first write, asks it after the existence check. A run whose flow or parked node is gone now gets the 409 `RESUME_TARGET_LOST` that a cancelled run already gets, naming the cause, with nothing recorded. The request stays `pending` with its approvers. A probe that cannot read the store fails open, the same posture as the existence check. An engine without the member gets the existence check alone. - One `patch` changeset for both packages, with a `**BREAKING**` banner and ADR-0087 `not-required (no-migration-prescription)`. Door (b) of triage `6434168375`. Door (a), refusing `DELETE /automation/:name` while runs are paused, is not added: (b) holds whatever removed the flow (a delete, an uninstall, an overlay removal), and the order allows (a) only at the same size with its own pin. ## Why A suspended run outlives the flow it belongs to. The pre-flight asked only `hasSuspendedRun`, which answers `true` for the orphaned row, so the decision was written and the request flipped to `approved`. Only the resume after that hit the engine's flow-gone exit (`RUN_NOT_FOUND`), and the decision door answered 500 `RESUME_FAILED` with `finalized: true, repairable: false`. The approver was told an error happened, but the decision stood and the flow never ran its approve branch. **Ordering, not atomicity.** The check and the decision write are ordered: the check runs first, and nothing is written when it refuses. A flow deleted in the window between the check and the resume still reaches the post-resume `RESUME_FAILED` report. That is the same residual race the cancelled-run pre-flight has carried since #4420, and `approval-restart-resume.test.ts` already pins it. This PR does not widen that window. ## Tests - **New: `plugin-approvals/src/deleted-flow-decision-refusal.test.ts`** (5 cases). It uses a real `AutomationEngine` over a durable in-memory suspended-run store, sealed the way the plugin seals it, plus the real `approval` node and the real `ApprovalService`: - the flow is deleted while paused (`unregisterFlow`, the `DELETE /automation/:name` door): the answer is `RESUME_TARGET_LOST` naming `Flow 'scratch_approval' not found for run …`, no approve row is written, the request stays `pending` with `u1` still pending, no branch runs, and the run row is left alone; - the parked node is edited away: the same refusal, with the node-gone cause; - **contrast**: the run is cancelled first, then approved. This gives the 409 `RESUME_TARGET_LOST` it already gave, with nothing recorded. It was green on `main` and stays green; - a probe that cannot read the store fails open, and the decision proceeds and resumes; - the refusal's remedy holds: a recall afterwards releases the request (`recalled`), and its resume settles the orphaned run. - **New: `service-automation/src/unresumable-suspension-cause.test.ts`** (6 cases). For both causes, the probe's cause equals byte for byte the `error` that the resume then answers `RUN_NOT_FOUND` with, and the parked row is untouched until that resume. A live suspension and a missing one both answer `undefined`. Before the seal the probe still answers, records nothing, and the run resumes once its flow registers. A store that cannot be read rejects. - **Red first on `main`** (base `45de8c6cba`, only the new approvals pin applied, closure built): 3 failed and 1 passed. The flow-gone, node-gone and remedy cases each received `RESUME_FAILED: the approve decision was recorded on request …, but its flow run '…' could not be resumed and is now stranded: … [RUN_NOT_FOUND]: Flow 'scratch_approval' not found for run '…'`. That is the card's reproduction. The contrast case passed. - **Ablation** (fix committed, `scripts/ablation-replace.mjs`; the approvals pin imports `approval-service.ts` from source, so no build sits in between): `if (lostCause !== undefined) {` → `if (false && lostCause !== undefined) {`. The anchor went 1 → 0 and the blob `2fa79298581b` → `1f41d1e74334`. Result: 3 failed and 2 passed, the same three cases. Restored: the blob equals HEAD and `git diff HEAD` is empty. ## Acceptance notes - The wire status comes from the message prefix at the REST door (`approvals-routes.ts`: `RESUME_TARGET_LOST` → 409, `RESUME_FAILED` → 500), which `rest-approvals-wire-codes.test.ts` pins. These pins assert the thrown code. - After the 409 the orphaned run row still reads `paused`, as it did before any decision. The refusal is a read. Settling a gone-for-good run stays with the resume (#23963): any later resume of the run, including the recall's, settles it. - Open PR #26337 also edits `approval-service.ts`, in other hunks. A local `git merge-tree` of the two heads reports no conflict. ## Verification All readings below are at head `3fb9ba3199` unless a line names another commit. - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 72 commands for this diff. Each one was run, with its exit code captured before any pipe. `--ran` reconciliation: `72 derived famil(ies) accounted for — 72 run, 0 NOT-MEASURED`. All 72 exited 0. The first pass, at `bd15ba3d1`, caught three reds, all fixed before this head. `check:engine-double-contract` and `check:objectql-double-limit` flagged the hand-copied fake engine in the new approvals pin, which now uses the pinned double from `decision-strand-envelope.test.ts` and is recorded in `scripts/engine-double-contract.pinned.json` by the gate's own `--write`. `check-plugin-teardown-shape --self-test` exited 3 because the container lacked its positive-control commit, and it is green once that commit is fetched. - **Package suites:** `pnpm --filter @objectstack/plugin-approvals test`: 102 files and 1295 tests passed. `pnpm --filter @objectstack/service-automation test` (at `ccc86788`; nothing in that package changed after it): 211 files and 2725 tests passed. - **Typecheck:** `pnpm --filter @objectstack/service-automation typecheck` and `pnpm --filter @objectstack/plugin-approvals typecheck` both exit 0, including `check:test-typecheck: OK` with the ledger unchanged. - **Lint:** `eslint --no-inline-config packages/plugins/plugin-approvals packages/services/service-automation` exit 0. - **Full build:** `pnpm build` exit 0 (13m09s under the shared lock). - **Dogfood, full battery** (owed by the `(narrowing)` arm), at `bd15ba3d1`. The one later commit changes only the new approvals test file and the double ledger, neither of which dogfood reads. `pnpm --filter @objectstack/dogfood test` under the lock gave `VERDICT command-exit 0`, with Test Files 300 passed and 3 skipped (303), and Tests 2532 passed and 18 skipped (2550). - **CI:** every family CI runs is read from this PR's check-runs. They are not re-run locally. <details><summary>--ran sheet (72 commands, exit codes)</summary> ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-tenant-audit-census.mjs :: exit 0 node scripts/check-tenant-audit-census.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:agent-test-spelling :: exit 0 pnpm check:bash32-floor :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:cli-command-ids :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:durability-log-level :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:entry-guard :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:i18n :: exit 0 pnpm check:i18n-stale-fill :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:parse-guard :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:pnpm-filter-targets :: exit 0 pnpm check:pnpm-flag-spellings :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:scripts-symbol-anchors :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No `packages/spec` change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (eslint over both touched packages, exit 0). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the maintainer channel). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5nQQbWD6Bz9UduzNSs)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d9bf777 commit a3009cb

7 files changed

Lines changed: 527 additions & 11 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
'@objectstack/service-automation': patch
4+
---
5+
6+
fix(plugin-approvals)!: a decision on a request whose flow was deleted while its run was paused is refused 409 `RESUME_TARGET_LOST` before anything is recorded, instead of being recorded and answered 500 `RESUME_FAILED` (#26477)
7+
8+
Clause-②: yes (narrowing: an approve, reject or send-back on a request whose paused run can never resume, because its flow was deleted or the node it parked on was edited away, is refused 409 `RESUME_TARGET_LOST` with nothing written, where it was recorded as finalized and answered 500 `RESUME_FAILED`; widened: `@objectstack/service-automation`'s `AutomationEngine` gains the read-only public method `unresumableSuspensionCause(runId)`)
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) the change is a runtime refusal of a decision that could never take effect; nothing an author writes is renamed or removed, so there is no FROM → TO mapping to register -->
12+
13+
A suspended run outlives the flow it belongs to: `DELETE /api/v1/automation/:name`, an uninstall or an overlay removal drops the flow and leaves the parked row. The approvals pre-flight asked only whether a suspension still existed, so it passed such a run, the decision was written and the request flipped to `approved`, and only the resume after it hit the engine's flow-gone exit. The approver got a 500 for a decision that stood, and the flow never continued.
14+
15+
What changes:
16+
17+
- `@objectstack/service-automation`: `AutomationEngine.unresumableSuspensionCause(runId)` answers why an existing suspension can never be resumed (`Flow 'F' not found for run 'R'`, or `Suspended node 'N' no longer exists in flow 'F'`), or `undefined` when it can continue or no suspension exists. It is the resume's own never-dispatched classification, shared with the resume path, so the two cannot disagree. It is read-only: it settles nothing (the resume still settles a gone-for-good run, as before), and it rejects when the suspended-run store cannot be read, as `hasSuspendedRun` does.
18+
- `@objectstack/plugin-approvals`: the decision pre-flight (approve, reject, send back and resubmit) asks that question after the existence check. A run whose flow or parked node is gone answers the same 409 `RESUME_TARGET_LOST` a cancelled run already gets, naming the cause, and nothing is recorded: the request stays `pending` with its approvers. A store that cannot be read still fails open, as the existence check does.
19+
20+
**BREAKING** for a caller that relied on the decision being recorded:
21+
22+
- **Before.** Approving a request whose flow had been deleted wrote the decision, set the request to `approved`, and answered 500 `RESUME_FAILED` with `finalized: true` and `repairable: false`. The run stayed parked or stranded.
23+
- **Now.** The same call answers 409 `RESUME_TARGET_LOST` and writes nothing.
24+
- **Fix.** Nothing to change in metadata. To release such a request, recall it (the recall's resume settles the orphaned run), or restore the flow and decide again.

‎packages/plugins/plugin-approvals/src/approval-service.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,22 @@ export interface ApprovalResumeSurface {
251251
* engine that does not implement it simply gets no pre-flight.
252252
*/
253253
hasSuspendedRun?(runId: string): Promise<boolean>;
254+
/**
255+
* [#26477] Why a suspension that DOES exist can never be resumed — its flow
256+
* is no longer registered, or the node it parked on was edited away — as
257+
* the cause the engine's resume would answer `RUN_NOT_FOUND` with; or
258+
* `undefined` when it can continue (or when no suspension exists, which is
259+
* {@link hasSuspendedRun}'s answer). The second half of the pre-flight in
260+
* {@link ApprovalService.assertRunResumable}: a parked row outlives its
261+
* flow, so {@link hasSuspendedRun} alone answered `true` for a run whose
262+
* flow was deleted, and the decision was recorded before the resume failed.
263+
*
264+
* ⚠️ Declares a method `AutomationEngine` implements publicly
265+
* (`unresumableSuspensionCause`). Read-only, and rejects when the store
266+
* cannot be read, as {@link hasSuspendedRun} does. Optional: an engine
267+
* without it gets the existence check alone.
268+
*/
269+
unresumableSuspensionCause?(runId: string): Promise<string | undefined>;
254270
/**
255271
* [#15389] Where each suspended run is currently parked. Read by
256272
* {@link ApprovalService.continueRestoredRun} to prove the pause it was asked
@@ -4551,9 +4567,19 @@ export class ApprovalService implements IApprovalService {
45514567
* flipped to `approved`, a success toast, and a flow that never moves. Once
45524568
* the decision row is written there is nothing left to fail cleanly.
45534569
*
4570+
* Two questions, both the resume's own (#26477): does a suspension exist
4571+
* for the run at all, and can it be dispatched — is its flow still
4572+
* registered and the node it parked on still there? A parked row outlives
4573+
* its flow (a `DELETE /automation/:name`, an uninstall), so the first alone
4574+
* passed a run whose flow was deleted, and the decision was recorded before
4575+
* the resume failed with 500 `RESUME_FAILED`. Both answer the same 409
4576+
* `RESUME_TARGET_LOST`, with nothing written.
4577+
*
45544578
* Deliberately permissive at the edges:
45554579
* - no automation attached, or an engine without `hasSuspendedRun` → no
45564580
* pre-flight at all (standalone approvals compositions are unaffected);
4581+
* an engine without `unresumableSuspensionCause` gets the first question
4582+
* alone;
45574583
* - the store cannot be READ → fail OPEN. A transient outage must not block
45584584
* every decision in the tenant; the post-resume check still catches a real
45594585
* failure and reports it loudly.
@@ -4563,8 +4589,12 @@ export class ApprovalService implements IApprovalService {
45634589
if (typeof this.automation?.resume !== 'function') return;
45644590
if (typeof this.automation?.hasSuspendedRun !== 'function') return;
45654591
let alive: boolean;
4592+
let lostCause: string | undefined;
45664593
try {
45674594
alive = await this.automation.hasSuspendedRun(runId);
4595+
if (alive && typeof this.automation.unresumableSuspensionCause === 'function') {
4596+
lostCause = await this.automation.unresumableSuspensionCause(runId);
4597+
}
45684598
} catch (err: any) {
45694599
this.logger?.warn?.('[approvals] could not verify the flow run is resumable — proceeding', {
45704600
request: requestId, run: runId, error: err?.message ?? String(err),
@@ -4578,6 +4608,13 @@ export class ApprovalService implements IApprovalService {
45784608
`Nothing was recorded. An administrator can recall the request to release the record.`,
45794609
);
45804610
}
4611+
if (lostCause !== undefined) {
4612+
throw new Error(
4613+
`RESUME_TARGET_LOST: the flow run '${runId}' behind request ${requestId} can never be resumed: ` +
4614+
`${lostCause} (the flow was deleted or edited while the run was paused). ` +
4615+
`Nothing was recorded. An administrator can recall the request to release the record.`,
4616+
);
4617+
}
45814618
}
45824619

45834620
/**
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A decision on a request whose flow was deleted while its run was paused is
5+
* refused BEFORE anything is written (#26477).
6+
*
7+
* The reported failure: an admin deletes a scratch approval flow
8+
* (`DELETE /api/v1/automation/:name`) while a request is pending, and the
9+
* approver's Approve answers 500 `RESUME_FAILED` — after the decision row was
10+
* written and the request flipped to `approved`. The run never continued, and
11+
* nothing could repair it.
12+
*
13+
* The pre-flight (`assertRunResumable`, #4420) asked one question: does a
14+
* suspension still exist for the run? For a deleted flow it does — the parked
15+
* row outlives the flow — so the decision passed, was recorded, and the resume
16+
* then hit the engine's flow-gone exit (`RUN_NOT_FOUND`). The pre-flight now
17+
* also asks the engine the resume's own never-dispatched question
18+
* (`AutomationEngine.unresumableSuspensionCause`), so a run whose flow or
19+
* parked node is gone answers the same 409 `RESUME_TARGET_LOST` a cancelled
20+
* run gets, and records nothing.
21+
*
22+
* Real automation engine (durable in-memory suspended-run store, sealed as the
23+
* plugin seals it at `kernel:bootstrapped`), real `approval` node executor,
24+
* real `ApprovalService`; the approvals tables are an in-memory stand-in. The
25+
* REST door maps the message prefix to the status (`RESUME_TARGET_LOST` → 409,
26+
* `RESUME_FAILED` → 500); that mapping is pinned in `@objectstack/rest`'s
27+
* `rest-approvals-wire-codes.test.ts`, so these pins assert the thrown code.
28+
*/
29+
30+
import { describe, it, expect, beforeEach, vi } from 'vitest';
31+
import { AutomationEngine, InMemorySuspendedRunStore } from '@objectstack/service-automation';
32+
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql';
33+
import { ApprovalService } from './approval-service.js';
34+
import { registerApprovalNode } from './approval-node.js';
35+
36+
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as any;
37+
const noopLogger = { info() {}, warn() {}, error() {}, debug() {} };
38+
39+
/**
40+
* In-memory ObjectQL stand-in for the approvals tables: the pinned double of
41+
* `decision-strand-envelope.test.ts`, copied whole (bound by presence, writes
42+
* through the engine-dispatch assertions).
43+
*/
44+
function makeFakeEngine() {
45+
const tables = new Map<string, any[]>();
46+
const rows = (o: string) => (tables.get(o) ?? (tables.set(o, []), tables.get(o)!));
47+
const matches = (row: any, where: any) => Object.entries(where ?? {}).every(([k, v]) => {
48+
if (k.startsWith('$')) throw new Error(`fake engine: unsupported filter operator ${k}`);
49+
if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]);
50+
if (v && typeof v === 'object' && '$ne' in (v as any)) return row[k] !== (v as any).$ne;
51+
return row[k] === v;
52+
});
53+
return {
54+
tables,
55+
async find(object: string, opts: any = {}) {
56+
const where = opts.where ?? opts.filter ?? {};
57+
const out = rows(object).filter(r => matches(r, where));
58+
// The caller's bound is honoured by PRESENCE, never truthiness: `limit: 0`
59+
// is a real bound that must return no rows, and a falsy test hands back
60+
// the whole table instead.
61+
const start = opts.offset ?? 0;
62+
const page = typeof opts.limit === 'number' ? out.slice(start, start + opts.limit) : out.slice(start);
63+
return page.map(r => ({ ...r }));
64+
},
65+
async insert(object: string, data: any) { rows(object).push({ ...data }); return { ...data }; },
66+
async update(object: string, data: any, options?: any) {
67+
const dispatch = assertEngineUpdateDispatch(data, options);
68+
const table = rows(object);
69+
if (dispatch.kind === 'multi') {
70+
let n = 0;
71+
for (let i = 0; i < table.length; i++) {
72+
if (matches(table[i], options?.where)) { table[i] = { ...table[i], ...data }; n++; }
73+
}
74+
return { updated: n };
75+
}
76+
const i = table.findIndex(r => r.id === dispatch.id);
77+
if (i >= 0) table[i] = { ...table[i], ...data };
78+
return i >= 0 ? { ...table[i] } : null;
79+
},
80+
async delete(object: string, options?: any) {
81+
const dispatch = assertEngineDeleteDispatch(options);
82+
const table = rows(object);
83+
if (dispatch.kind === 'multi') {
84+
const survivors = table.filter(r => !matches(r, options?.where));
85+
const deleted = table.length - survivors.length;
86+
table.splice(0, table.length, ...survivors);
87+
return { deleted };
88+
}
89+
const i = table.findIndex(r => r.id === dispatch.id);
90+
if (i >= 0) table.splice(i, 1);
91+
return { id: dispatch.id };
92+
},
93+
};
94+
}
95+
96+
/** A scratch flow with one approval node, as the card's reproduction registers it. */
97+
function scratchFlow(opts: { withApprovalNode: boolean } = { withApprovalNode: true }) {
98+
return {
99+
name: 'scratch_approval',
100+
label: 'Scratch Approval',
101+
type: 'autolaunched',
102+
nodes: [
103+
{ id: 'start', type: 'start', label: 'Start' },
104+
...(opts.withApprovalNode
105+
? [{ id: 'approve_step', type: 'approval', label: 'Approval', config: { approvers: [{ type: 'user', value: 'u1' }] } }]
106+
: []),
107+
{ id: 'on_approved', type: 'mark', label: 'Approved' },
108+
{ id: 'on_rejected', type: 'mark', label: 'Rejected' },
109+
{ id: 'end', type: 'end', label: 'End' },
110+
],
111+
edges: opts.withApprovalNode
112+
? [
113+
{ id: 'e1', source: 'start', target: 'approve_step' },
114+
{ id: 'e2', source: 'approve_step', target: 'on_approved', label: 'approve' },
115+
{ id: 'e3', source: 'approve_step', target: 'on_rejected', label: 'reject' },
116+
{ id: 'e4', source: 'on_approved', target: 'end' },
117+
{ id: 'e5', source: 'on_rejected', target: 'end' },
118+
]
119+
: [
120+
{ id: 'e1', source: 'start', target: 'on_approved' },
121+
{ id: 'e4', source: 'on_approved', target: 'end' },
122+
],
123+
};
124+
}
125+
126+
describe('a decision on a run whose flow is gone is refused before it is recorded (#26477)', () => {
127+
let data: ReturnType<typeof makeFakeEngine>;
128+
let service: ApprovalService;
129+
let automation: AutomationEngine;
130+
let marks: string[];
131+
let runId: string;
132+
let requestId: string;
133+
134+
beforeEach(async () => {
135+
marks = [];
136+
data = makeFakeEngine();
137+
service = new ApprovalService({ engine: data as any, logger: noopLogger });
138+
automation = new AutomationEngine(noopLogger as any, new InMemorySuspendedRunStore());
139+
registerApprovalNode(automation, service, noopLogger as any);
140+
automation.registerNodeExecutor({
141+
type: 'mark',
142+
async execute(node: any) { marks.push(node.id); return { success: true }; },
143+
});
144+
automation.registerFlow('scratch_approval', scratchFlow() as never);
145+
// The plugin seals at `kernel:bootstrapped`; a live server is past it.
146+
automation.sealNodeTypeVocabulary();
147+
service.attachAutomation(automation);
148+
149+
const paused = await automation.execute('scratch_approval', {
150+
object: 'showcase_field_zoo', record: { id: 'r1' }, userId: 'submitter',
151+
});
152+
expect(paused.status).toBe('paused');
153+
runId = paused.runId!;
154+
requestId = (await data.find('sys_approval_request', { where: { status: 'pending' } }))[0].id;
155+
});
156+
157+
/** The half-state the card reported, asserted absent: nothing of the decision was written. */
158+
async function expectNothingRecorded() {
159+
const request = (await data.find('sys_approval_request', { where: { id: requestId } }))[0];
160+
expect(request.status, 'still pending, not a recorded "approved"').toBe('pending');
161+
expect(request.pending_approvers).toContain('u1');
162+
expect(
163+
await data.find('sys_approval_action', { where: { request_id: requestId, action: 'approve' } }),
164+
'no approve row for a decision that could not take effect',
165+
).toHaveLength(0);
166+
expect(marks, 'no branch of the flow ran').toEqual([]);
167+
}
168+
169+
const approve = () => service
170+
.decide(requestId, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX)
171+
.then(() => null, (e: Error) => e);
172+
173+
it('flow deleted while paused: 409 RESUME_TARGET_LOST naming the cause, nothing recorded', async () => {
174+
// `DELETE /api/v1/automation/:name` → `unregisterFlow`. The parked row
175+
// outlives the flow, so a suspension still exists for the run.
176+
automation.unregisterFlow('scratch_approval');
177+
expect(await automation.hasSuspendedRun(runId), 'the parked row outlived its flow').toBe(true);
178+
179+
const err = await approve();
180+
181+
expect(err?.message).toMatch(/^RESUME_TARGET_LOST/);
182+
expect(err?.message, 'says why the run cannot continue').toContain(
183+
`Flow 'scratch_approval' not found for run '${runId}'`,
184+
);
185+
await expectNothingRecorded();
186+
// The pre-flight is a read: it settles nothing on the run either.
187+
expect(await automation.hasSuspendedRun(runId), 'the run row is untouched by the refusal').toBe(true);
188+
});
189+
190+
it('parked node edited away: the same refusal, nothing recorded', async () => {
191+
automation.registerFlow('scratch_approval', scratchFlow({ withApprovalNode: false }) as never);
192+
193+
const err = await approve();
194+
195+
expect(err?.message).toMatch(/^RESUME_TARGET_LOST/);
196+
expect(err?.message).toContain(`Suspended node 'approve_step' no longer exists in flow 'scratch_approval'`);
197+
await expectNothingRecorded();
198+
});
199+
200+
it('contrast — run cancelled first: the 409 RESUME_TARGET_LOST it already answered, nothing recorded', async () => {
201+
expect(await automation.cancelRun(runId, 'operator cancelled')).toBe(true);
202+
203+
const err = await approve();
204+
205+
expect(err?.message).toMatch(/^RESUME_TARGET_LOST/);
206+
expect(err?.message).toContain('no longer exists');
207+
await expectNothingRecorded();
208+
});
209+
210+
it('a probe that cannot read the store fails OPEN, as the existence check does: the decision proceeds', async () => {
211+
vi.spyOn(automation, 'unresumableSuspensionCause').mockRejectedValue(new Error('store connection reset'));
212+
213+
const out = await service.decide(requestId, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX);
214+
215+
expect(out).toMatchObject({ finalized: true, decision: 'approve', resumed: true });
216+
expect(marks).toEqual(['on_approved']);
217+
});
218+
219+
it('the refusal\'s remedy holds: a recall releases the request and settles the orphaned run', async () => {
220+
automation.unregisterFlow('scratch_approval');
221+
expect((await approve())?.message).toMatch(/^RESUME_TARGET_LOST/);
222+
223+
const recalled = await service.recall(requestId, { actorId: 'submitter' }, SYSTEM_CTX);
224+
225+
expect(recalled.resumed, 'the recall could not resume a run whose flow is gone').toBe(false);
226+
expect((await data.find('sys_approval_request', { where: { id: requestId } }))[0].status).toBe('recalled');
227+
expect(await automation.hasSuspendedRun(runId), 'the recall\'s resume settled the orphan (#23963)').toBe(false);
228+
});
229+
});

0 commit comments

Comments
 (0)