Skip to content

Commit 4bb09e8

Browse files
claude[bot]claude
andauthored
feat(service-automation): an operator can put back a suspension a failed resume consumed (#13951)
* wip: #13909 slice 2 operator exit verb * fix(automation): restore the dropped forgetSuspendedRun call * feat(service-automation): an operator can put back a suspension a failed resume consumed (#13909) * fix(service-automation): publish the restore vocabulary at the barrel and true up the sys_automation_run declaration Contract-review rework (ruling recorded on PR 13951; behaviour unchanged): - index.ts: export type SuspensionRestoreResult / SuspensionRestoreRefusal in the engine.js type block. The verb was already barrel-reachable, so the eight refusal values were published de facto while the union was unnameable de jure — no exhaustive switch, no annotated result, no typed handler parameter. ConsumedSuspension deliberately stays unexported (it appears in no barrel-reachable signature). - consumed-suspension-restore.test.ts: barrel-import pin. The existing suite imports from './engine.js', which is why the gap had no witness. The pin annotates a real result with the BARREL type and writes the exhaustive switch (never-typed default), so removing the export breaks tsc; the runtime half pins the verb on the class the barrel itself exports. - sys-automation-run.object.ts: the declaration asserted invariants this PR falsified. One-class corrections only — the node_type description and the trigger-attribution comment now carve out the consumed-suspension class of terminal row (the same correction shape this PR already applied in plugin-approvals' backfill), and variables_json's description names the presence-discriminator the store's deserializer keys off. - engine.ts: the NO_CONSUMED_SUSPENSION docblock no longer claims "exists and is terminal" — the arm also answers on a non-terminal logged status, and the reason string was already honest about that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs * fix(service-automation): keep tracker ids out of the declaration's runtime strings check:doc-authoring is right: the two descriptions the rework corrected carried '#13909' inside runtime string prose, which reaches operators who cannot resolve a tracker id. The carve-out text stays; the ids move to adjacent comments (the reader who can resolve them reads the source). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4bf15db commit 4bb09e8

7 files changed

Lines changed: 1395 additions & 10 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/service-automation": minor
3+
"@objectstack/plugin-approvals": patch
4+
---
5+
6+
feat(service-automation): an operator can put back a suspension a failed resume consumed (#13909)
7+
8+
A run that was resumed and whose downstream node merely **threw** was
9+
terminally unresumable, and nothing anywhere could move it. The engine consumes
10+
the suspension *before* running downstream nodes, so such a node throws with
11+
the pause already gone and the catch arm records the run `failed`: `resume`
12+
then answers `RUN_NOT_FOUND`, `cancelRun` is a no-op, and none of the engine's
13+
other public methods takes the run anywhere. A deployment could enter that
14+
state and never leave it.
15+
16+
`AutomationEngine.restoreConsumedSuspension(runId, { requestedBy, reason })`
17+
is the exit. It puts the consumed suspension back — verbatim, as it stood at
18+
the pause — so the run is resumable again through an ordinary `resume`, with
19+
the same authority gate, the same screen validation and the same idempotency
20+
guard as any other.
21+
22+
- **Deliberate, never automatic.** Nothing calls it on its own: no retry, no
23+
sweeper. An operator asks for one run, by id.
24+
- **Safe to refuse, with the reason named.** A run that is still suspended, one
25+
whose resume is *in flight*, one that completed, one that was cancelled, one
26+
that never suspended, and an unknown id each get their own refusal — as does
27+
an unreadable store, which is refused rather than guessed at.
28+
- **Idempotent.** A suspension is keyed by run id, so however many operators
29+
ask there is one resumable pause and no extra traversal — the verb re-arms
30+
and stops. Two racing callers in one process get one restore and one refusal.
31+
- **It leaves a trace.** The restore is logged with the run, flow, node, when
32+
the suspension was consumed, who asked and why, and the run is recorded
33+
`paused` again so the repair is not invisible. Across a restart the exit
34+
still works: the consumed suspension rides the run's own terminal history row
35+
(in `sys_automation_run` columns that already existed), and a run that is
36+
restored and then finishes clears it.
37+
38+
⚠️ A repair, not a prevention. The failed attempt's side effects are **not**
39+
undone and the original resume signal is **not** replayed — the continuation
40+
must be re-issued. Whether the pause should survive a downstream throw at all
41+
is a separate, unruled decision (#13937); this change leaves the resume
42+
ordering, `forgetSuspendedRun` and `traverseNext` exactly as they are, mints no
43+
new run status, and works whichever way that is ruled — the runs already stuck
44+
today are not released by changing what future resumes do.
45+
46+
`plugin-approvals` carries a comment correction only: its organization backfill
47+
documented `context_json` as never written on terminal rows, which this change
48+
makes false for that one class of row. No behaviour change there.

packages/plugins/plugin-approvals/src/backfill-platform-row-organizations.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,9 +168,12 @@ export const BACKFILL_TARGETS: readonly BackfillTarget[] = [
168168
subjectObjectField: 'trigger_object',
169169
subjectIdField: 'trigger_record_id',
170170
// `context_json` is the serialized AutomationContext; the trigger record
171-
// sits at `.record`. Written on paused rows only — `recordTerminal` does
172-
// not persist it, so terminal rows resolve from the live subject or not
173-
// at all.
171+
// sits at `.record`. Written on every paused row, and — since #13909 —
172+
// on the one class of TERMINAL row that carries a restorable suspension
173+
// (a run whose resume consumed its pause and then failed downstream).
174+
// Every other terminal row still has none, so those still resolve from the
175+
// live subject or not at all. Nothing here needs to branch on which: a row
176+
// that HAS the snapshot uses it, exactly as a paused row does.
174177
snapshotField: 'context_json',
175178
snapshotPath: ['record'],
176179
statusField: 'status',

0 commit comments

Comments
 (0)