Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/28183-batch-item-recall-resumes-batch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/plugin-approvals': patch
---

fix(plugin-approvals): a revise-window recall on a batch sign-off item resumes the batch, which records the item as cancelled and goes on

Clause-②: no (a batch run that nothing could wake after the recall now continues, in the shape the cancelled-item ruling already set; no schema, accept set, error code or result field changes)
Metadata-protocol: none

A batch sign-off is a `map` node over the items, and each item's run parks on its own `approval`. When the submitter recalled an item from the revise window, the recall cancelled the item's run with `cancelRun`, which never touches the parent. The batch run stayed parked on that item with no request open for the next one, and nothing woke it short of an operator resuming the batch run by hand.

- **Now.** After the cancel, the recall resumes the batch run parked on that item. The map records the item's slot as `{ $status: 'cancelled', $itemRunId }`, leaves it out of `count`, and opens the next item, or runs on past the map when it was the last one.
- **Only the built-in `map`.** A run is woken only when it is parked at a `map` node on that item's run. Any other node parked on such a link is left alone, because nothing records the cancelled item for it.
- **Failures are told the way the recall tells its own.** A batch resume the engine refuses or strands does not fail the recall: the withdrawal stands, `resumeError` says which run did not move and why, and an engine-stranded batch is reported on `resumeFailure` with code `RESUME_FAILED`.
- **Unchanged.** A recall while the request is still `pending` (the item resumes down `reject` and the map records its output), a revise-window recall with no batch above it, and a batch whose items are all decided.
6 changes: 5 additions & 1 deletion content/docs/automation/approvals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -735,7 +735,11 @@ flow's `revise` edge. The submitter reworks the record and `resubmit`s (a fresh
round opens for all approvers) or `recall`s (abandons it). Past the node's
`maxRevisions` budget (default 3) a send-back **auto-rejects** instead. The
flow's approval node must declare a `revise` edge, and that edge must target an
`approval_revise` node, for send-back to be available.
`approval_revise` node, for send-back to be available. A recall from the revise
window cancels the run; when that run is an item of a batch sign-off (a `map`
over approval items), the recall also wakes the batch, which records the item's
slot as the cancelled marker `{ $status: 'cancelled', $itemRunId }` and moves on
to its next item or its end.

The window is deliberately **not** an ordinary `wait`: `resubmit` is what
authorizes (submitter-only), orders (latest round) and records (an audit row) the
Expand Down
5 changes: 4 additions & 1 deletion content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1525,7 +1525,10 @@ success: the node's `count` is the items that ran. A downstream step reads it
where it reads the results — `signoffResults[i]['$status'] == 'cancelled'` (a
child flow's output keys never start with `$`, so no real output looks like
it). An item whose run is gone *without* a `cancelled` entry in its run history
is refused with the same 409: the map has nothing true to record for it.
is refused with the same 409: the map has nothing true to record for it. A
submitter's recall of a batch item from its revise window does this itself: the
recall cancels the item's run and resumes the batch, so the item's slot holds
the cancelled marker and the next item opens.

v1 is sequential and
fail-fast (the first item whose subflow fails fails the map). Concurrent fan-out (all items at once) is the
Expand Down
67 changes: 66 additions & 1 deletion packages/plugins/plugin-approvals/src/approval-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5277,14 +5277,18 @@ export class ApprovalService implements IApprovalService {
if (runId) {
resumeError = this.missingRunCapability(runId, requestId, 'the recall', 'cancelRun');
if (!resumeError) {
let cancelled: unknown;
try {
await this.automation!.cancelRun!(runId, `approval request ${requestId} recalled during revision`);
cancelled = await this.automation!.cancelRun!(runId, `approval request ${requestId} recalled during revision`);
} catch (err: any) {
resumeError = err?.message ?? String(err);
this.logger?.error?.('[approvals] cancelRun after revise-window recall failed — the run may be stranded', {
request: requestId, run: runId, error: resumeError,
});
}
// [#28183] `cancelRun` never touches a parent, so a batch whose
// `map` item this was would stay parked with nothing to wake it.
if (cancelled === true) ({ resumeError, resumeFailure } = await this.resumeBatchOfRecalledItem(runId, requestId));
}
}
} else if (runId) {
Expand Down Expand Up @@ -5366,6 +5370,67 @@ export class ApprovalService implements IApprovalService {
};
}

/**
* [#28183] Wake the batch above a run a revise-window recall just cancelled.
*
* On a batch sign-off the cancelled run is a `map` item, and its batch run is
* parked on `map:<itemRunId>`. `cancelRun` never touches that parent, so
* before this the batch waited for an operator's parent-id resume that
* nothing prompted. This issues that same resume: the engine reads the
* item's `cancelled` run history, records the item's slot as its cancelled
* marker (outside `count`) and moves the map on to its next item or its end
* — the shape the cancelled-item ruling (6523833308) set. No new shape and
* no new code: a resume the engine refuses or strands is told exactly as
* this door's own resume is.
*
* Only a run parked at the built-in `map` node on that item is woken: the
* engine records the marker for no other node, and re-running any other
* node would advance it past the item with nothing recorded. Whatever is
* missing to prove that — no `listSuspendedRunsDurable`, no `getFlow`, no
* such parent — leaves things exactly as they were before, which for a
* recall with no batch above it is the whole answer.
*/
private async resumeBatchOfRecalledItem(
itemRunId: string,
requestId: string,
): Promise<{ resumeError?: string; resumeFailure?: ResumeFailureReport }> {
if (typeof this.automation?.listSuspendedRunsDurable !== 'function') return {};
if (typeof this.automation.getFlow !== 'function') return {};
let batchRunId: string | undefined;
try {
const parent = (await this.automation.listSuspendedRunsDurable())
.find(r => r.correlation === `map:${itemRunId}`);
if (!parent) return {};
const flow: any = await this.automation.getFlow(parent.flowName);
const node = Array.isArray(flow?.nodes) ? flow.nodes.find((n: any) => n?.id === parent.nodeId) : undefined;
if (node?.type !== 'map') return {};
batchRunId = parent.runId;
const bubbleStrand = await this.serviceResume(batchRunId, {});
if (!bubbleStrand) return {};
// The batch advanced; a run above it (a subflow parent) did not.
return {
resumeError:
`RESUME_FAILED: the recall was recorded on request ${requestId} and cancelled its run '${itemRunId}', ` +
`and the batch run '${batchRunId}' above it resumed, but the subflow parent above that — run ` +
`'${bubbleStrand.runId}' — consumed its suspension and is now stranded: ${bubbleStrand.error}`,
resumeFailure: { code: 'RESUME_FAILED', runId: bubbleStrand.runId, status: 'stranded', repairable: bubbleStrand.repairable },
};
} catch (err: any) {
const message = err?.message ?? String(err);
this.logger?.error?.('[approvals] resuming the batch after a revise-window recall of its item failed — the batch may stay parked', {
request: requestId, run: itemRunId, batchRun: batchRunId, error: message,
});
const at = batchRunId ? `resuming the batch run '${batchRunId}' above it` : 'looking up the batch run above it';
return {
resumeError: `the recall was recorded on request ${requestId} and cancelled its run '${itemRunId}', but ${at} failed: ${message}`,
// Only the engine's own stranded exit is told in fields, as in `recall`.
...(batchRunId && ApprovalService.resumeStatusOf(err) === 'stranded'
? { resumeFailure: { code: 'RESUME_FAILED' as const, runId: batchRunId, status: 'stranded' as const, repairable: true } }
: {}),
};
}
}

// ── Record-delete lifecycle linkage (#13568) ─────────────────

/**
Expand Down
Loading
Loading