From 4e1d96798dbb8a73bfb233a36f55372534570615 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:16:43 +0000 Subject: [PATCH 1/3] fix(plugin-approvals): a revise-window recall on a batch item resumes the batch A revise-window recall cancels the item's run with cancelRun, which never touches the parent, so a batch sign-off (a map over approval items) stayed parked on the recalled item with nothing to wake it. The recall now finds the run parked at the built-in map node on that item's run and resumes it; the engine records the item's slot as the cancelled marker, outside count, and the map goes on. A run parked on such a link at any other node is left alone. A refused or stranded batch resume is told on resumeError and, for the engine's stranded exit, resumeFailure, as the recall's own resume is. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4d13k5TVyAhqhiKKgN3 --- .../28183-batch-item-recall-resumes-batch.md | 15 + .../plugin-approvals/src/approval-service.ts | 67 +++- .../batch-item-recall-resumes-batch.test.ts | 312 ++++++++++++++++++ 3 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 .changeset/28183-batch-item-recall-resumes-batch.md create mode 100644 packages/plugins/plugin-approvals/src/batch-item-recall-resumes-batch.test.ts diff --git a/.changeset/28183-batch-item-recall-resumes-batch.md b/.changeset/28183-batch-item-recall-resumes-batch.md new file mode 100644 index 00000000000..9d7ac3a8136 --- /dev/null +++ b/.changeset/28183-batch-item-recall-resumes-batch.md @@ -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. diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index 215cc94cbf1..dc8e31a3319 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -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) { @@ -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:`. `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) ───────────────── /** diff --git a/packages/plugins/plugin-approvals/src/batch-item-recall-resumes-batch.test.ts b/packages/plugins/plugin-approvals/src/batch-item-recall-resumes-batch.test.ts new file mode 100644 index 00000000000..a8d61d7d250 --- /dev/null +++ b/packages/plugins/plugin-approvals/src/batch-item-recall-resumes-batch.test.ts @@ -0,0 +1,312 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A revise-window recall on a batch sign-off item resumes the batch, which + * records the item as cancelled and goes on (#28183). + * + * ## The composition + * + * `release_signoff` runs a `map` node over its tasks; each item runs + * `task_signoff`, which parks on an `approval` with a `revise` edge into the + * revise window (ADR-0044) — the shape of the showcase's + * `showcase_release_signoff` → `showcase_one_task_signoff`. The approver sends + * item 1 back; the submitter recalls it from the revise window. + * + * ## What was measured before this change + * + * The recall cancels the item's run through `cancelRun`, which never touches + * the parent, and nothing else wakes it: the batch run stayed parked on + * `map:` with no request open for item 2 and no row left for + * item 1, so nothing could ever advance it short of an operator's parent-id + * resume. That parent-id resume already does the right thing since the + * cancelled-item ruling (results keep the item's slot as + * `{ $status: 'cancelled', $itemRunId }`, outside `count`); the recall now + * issues it itself. + * + * ## The pins + * + * 1. the recall on a batch item resumes the batch: item 1's slot holds the + * marker, `count` leaves it out, and item 2's request opens; + * 2. a recall on an approval with no batch above it behaves as before, and a + * batch parked beside it is not touched; + * 3. a recall outside the revise window (`pending`) behaves as before: the + * item resumes down `reject` and the map records what it output; + * 4. a batch whose every item is decided behaves as before; + * 5. a run parked on a `map:` link at a node other than the built-in `map` + * is not woken: the engine records the marker for no other node, so + * re-running it would advance it past the item with nothing recorded. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { AutomationEngine, InMemorySuspendedRunStore, installBuiltinNodes } from '@objectstack/service-automation'; +import { APPROVAL_REVISE_NODE_TYPE, defineActionDescriptor } from '@objectstack/spec/automation'; +import { ApprovalService } from './approval-service.js'; +import { registerApprovalNode } from './approval-node.js'; + +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as any; +const asUser = (userId: string) => ({ isSystem: false, userId, positions: [], permissions: [] }) as any; + +/** The accounts the `user` approver slots route to (#27397). */ +const ROUTED_USERS = ['u1']; + +function silentLogger(): any { + return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } }; +} + +/** In-memory ObjectQL stand-in: equality/$in/$ne where, orderBy, limit. */ +function makeFakeEngine() { + const tables = new Map([['sys_user', ROUTED_USERS.map((id) => ({ id }))]]); + const rows = (o: string) => (tables.get(o) ?? (tables.set(o, []), tables.get(o)!)); + const matches = (row: any, where: any) => Object.entries(where ?? {}).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake engine: unsupported filter operator ${k}`); + if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]); + if (v && typeof v === 'object' && '$ne' in (v as any)) return row[k] !== (v as any).$ne; + return row[k] === v; + }); + return { + tables, + async find(object: string, opts: any = {}) { + const where = opts.where ?? opts.filter ?? {}; + let out = rows(object).filter(r => matches(r, where)); + for (const ord of [...(opts.orderBy ?? [])].reverse()) { + const dir = ord.order === 'desc' ? -1 : 1; + out = [...out].sort((a, b) => (a[ord.field] < b[ord.field] ? -dir : a[ord.field] > b[ord.field] ? dir : 0)); + } + if (opts.limit) out = out.slice(0, opts.limit); + return out.map(r => ({ ...r })); + }, + async insert(object: string, data: any) { rows(object).push({ ...data }); return { ...data }; }, + async update(object: string, idOrData: any) { + const row = rows(object).find(r => r.id === idOrData.id); + if (row) Object.assign(row, idOrData); + return row ? { ...row } : null; + }, + async delete(object: string, opts: any = {}) { + const where = opts.where ?? {}; + const list = rows(object); + for (let i = list.length - 1; i >= 0; i--) if (matches(list[i], where)) list.splice(i, 1); + return { affected: 1 }; + }, + }; +} + +/** One task's sign-off: approve / reject, or a revise window that re-enters the approval. */ +const TASK_SIGNOFF = { + name: 'task_signoff', + label: 'Task Sign-off', + type: 'autolaunched', + variables: [{ name: 'decision', type: 'text', isOutput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'review', type: 'approval', label: 'Task Sign-off', config: { approvers: [{ type: 'user', value: 'u1' }] } }, + { id: 'wait_revision', type: APPROVAL_REVISE_NODE_TYPE, label: 'Awaiting Revision' }, + { id: 'mark_ok', type: 'decided', label: 'Approved', config: { decision: 'approved' } }, + { id: 'mark_no', type: 'decided', label: 'Rejected', config: { decision: 'rejected' } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'review' }, + { id: 'e2', source: 'review', target: 'mark_ok', label: 'approve' }, + { id: 'e3', source: 'review', target: 'mark_no', label: 'reject' }, + { id: 'e4', source: 'review', target: 'wait_revision', label: 'revise' }, + { id: 'e5', source: 'wait_revision', target: 'review', label: 'resubmit', type: 'back' }, + { id: 'e6', source: 'mark_ok', target: 'end' }, + { id: 'e7', source: 'mark_no', target: 'end' }, + ], +}; + +/** The batch: a `map` over the tasks, then a downstream reader of what it recorded. */ +const RELEASE_SIGNOFF = { + name: 'release_signoff', + label: 'Release Sign-off', + type: 'autolaunched', + variables: [{ name: 'items', type: 'list', isInput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'signoffs', type: 'map', label: 'Sign off each task', + config: { + collection: { dialect: 'cel', source: 'items' }, flowName: 'task_signoff', + itemObject: 'rel_task', outputVariable: 'signoffResults', + }, + }, + { id: 'peek', type: 'peek', label: 'Read the results' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'b1', source: 'start', target: 'signoffs' }, + { id: 'b2', source: 'signoffs', target: 'peek' }, + { id: 'b3', source: 'peek', target: 'end' }, + ], +}; + +describe('a revise-window recall on a batch item resumes the batch (#28183)', () => { + let automation: AutomationEngine; + let service: ApprovalService; + let fake: ReturnType; + let seen: { results: unknown; count: unknown } | undefined; + + beforeEach(() => { + seen = undefined; + fake = makeFakeEngine(); + // Ticking clock: rounds of a revise loop must not share created_at. + let t = Date.parse('2026-10-30T00:00:00Z'); + service = new ApprovalService({ engine: fake as any, logger: silentLogger(), clock: { now: () => new Date((t += 1000)) } }); + automation = new AutomationEngine(silentLogger(), new InMemorySuspendedRunStore()); + installBuiltinNodes(automation, { logger: silentLogger(), getService() { throw new Error('none'); } } as any); + registerApprovalNode(automation, service, silentLogger()); + automation.registerNodeExecutor({ + type: 'decided', + async execute(node: any, variables: Map) { + variables.set('decision', node.config.decision); + return { success: true }; + }, + } as never); + automation.registerNodeExecutor({ + type: 'peek', + async execute(_node: any, variables: Map) { + seen = { results: variables.get('signoffResults'), count: variables.get('signoffs.count') }; + return { success: true }; + }, + } as never); + automation.registerFlow('task_signoff', TASK_SIGNOFF as never); + automation.registerFlow('release_signoff', RELEASE_SIGNOFF as never); + service.attachAutomation(automation); + }); + + const launchBatch = async (ids: string[]) => { + const paused = await automation.execute('release_signoff', { + userId: 'submitter', params: { items: ids.map(id => ({ id, title: `Task ${id}` })) }, + } as never); + expect(paused.status, 'the batch parks on its first item').toBe('paused'); + return paused.runId!; + }; + const requestFor = async (recordId: string, status: string) => + (await fake.find('sys_approval_request', { where: { record_id: recordId, status } }))[0]; + const parked = () => automation.listSuspendedRuns().map(r => ({ runId: r.runId, nodeId: r.nodeId, correlation: r.correlation })); + + it('the recall resumes the batch: the item keeps its slot as the cancelled marker, outside count, and the next item opens', async () => { + const batchRunId = await launchBatch(['t1', 't2']); + const first = await requestFor('t1', 'pending'); + await service.sendBack(first.id, { actorId: 'u1', comment: 'rework' }, asUser('u1')); + expect(parked()).toContainEqual({ runId: first.flow_run_id, nodeId: 'wait_revision', correlation: 'approval_revise:wait_revision' }); + + const out = await service.recall(first.id, { actorId: 'submitter' }, asUser('submitter')); + + expect(out.request.status).toBe('recalled'); + // The item's own run was cancelled, not resumed — as before. + expect(out.resumed).toBe(false); + expect(out.resumeError).toBeUndefined(); + expect((await automation.getRun(first.flow_run_id))?.status).toBe('cancelled'); + // The batch moved on: item 2's request is open and the batch waits on its run. + const second = await requestFor('t2', 'pending'); + expect(second, 'the batch opened the next item').toBeDefined(); + expect(parked()).toEqual(expect.arrayContaining([ + { runId: batchRunId, nodeId: 'signoffs', correlation: `map:${second.flow_run_id}` }, + ])); + + await service.decide(second.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX); + + const marker = { $status: 'cancelled', $itemRunId: first.flow_run_id }; + expect(seen).toEqual({ results: [marker, { decision: 'approved' }], count: 1 }); + expect((await automation.getRun(batchRunId))?.status).toBe('completed'); + expect(automation.listSuspendedRuns()).toEqual([]); + }); + + it('control: a recall on an approval with no batch above it behaves as before, and a parked batch beside it is untouched', async () => { + const batchRunId = await launchBatch(['t1']); + const batchItem = await requestFor('t1', 'pending'); + const lone = await automation.execute('task_signoff', { + object: 'rel_task', record: { id: 'solo' }, userId: 'submitter', + } as never); + const req = await requestFor('solo', 'pending'); + await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1')); + + const out = await service.recall(req.id, { actorId: 'submitter' }, asUser('submitter')); + + expect(out).toMatchObject({ runId: lone.runId, resumed: false }); + expect(out.resumeError).toBeUndefined(); + expect('resumeFailure' in out).toBe(false); + expect((await automation.getRun(lone.runId!))?.status).toBe('cancelled'); + const still = parked(); + expect(still).toHaveLength(2); + expect(still).toEqual(expect.arrayContaining([ + { runId: batchRunId, nodeId: 'signoffs', correlation: `map:${batchItem.flow_run_id}` }, + { runId: batchItem.flow_run_id, nodeId: 'review', correlation: batchItem.id }, + ])); + }); + + it('control: a recall outside the revise window resumes the item down reject, and the map records its output', async () => { + const batchRunId = await launchBatch(['t1', 't2']); + const first = await requestFor('t1', 'pending'); + + const out = await service.recall(first.id, { actorId: 'submitter' }, asUser('submitter')); + + expect(out).toMatchObject({ resumed: true }); + expect(out.resumeError).toBeUndefined(); + expect((await automation.getRun(first.flow_run_id))?.status).toBe('completed'); + const second = await requestFor('t2', 'pending'); + await service.decide(second.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX); + + expect(seen).toEqual({ results: [{ decision: 'rejected' }, { decision: 'approved' }], count: 2 }); + expect((await automation.getRun(batchRunId))?.status).toBe('completed'); + }); + + it('control: a batch whose every item is decided records each output, one through a revise round', async () => { + const batchRunId = await launchBatch(['t1', 't2']); + const first = await requestFor('t1', 'pending'); + await service.sendBack(first.id, { actorId: 'u1' }, asUser('u1')); + await service.resubmit(first.id, { actorId: 'submitter' }, asUser('submitter')); + const round2 = await requestFor('t1', 'pending'); + await service.decide(round2.id, { decision: 'reject', actorId: 'u1' }, SYSTEM_CTX); + const second = await requestFor('t2', 'pending'); + await service.decide(second.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX); + + expect(seen).toEqual({ results: [{ decision: 'rejected' }, { decision: 'approved' }], count: 2 }); + expect((await automation.getRun(batchRunId))?.status).toBe('completed'); + expect(automation.listSuspendedRuns()).toEqual([]); + }); + + it('control: a run parked on a map: link at a node other than the built-in map is not woken', async () => { + // A home-grown fan-out that parks on `map:` the way `map` does, but + // records nothing for a cancelled item: re-running it would open the next + // task with the recalled one silently dropped. + let entries = 0; + automation.registerNodeExecutor({ + type: 'fan_out', + descriptor: defineActionDescriptor({ + type: 'fan_out', version: '1.0.0', name: 'Fan out', supportsPause: true, resumeAuthority: 'any', + }), + async execute(_node: any, _variables: Map, context: any) { + entries++; + const child = await automation.execute('task_signoff', { + ...context, object: 'rel_task', record: { id: `f${entries}` }, + } as never); + return { success: true, suspend: true, correlation: `map:${child.runId}` }; + }, + } as never); + automation.registerFlow('fan_signoff', { + name: 'fan_signoff', label: 'Fan Sign-off', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'fan', type: 'fan_out', label: 'Fan out' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'f1', source: 'start', target: 'fan' }, + { id: 'f2', source: 'fan', target: 'end' }, + ], + } as never); + const fan = await automation.execute('fan_signoff', { userId: 'submitter' } as never); + const req = await requestFor('f1', 'pending'); + await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1')); + + const out = await service.recall(req.id, { actorId: 'submitter' }, asUser('submitter')); + + expect(out).toMatchObject({ resumed: false }); + expect(out.resumeError).toBeUndefined(); + expect(entries).toBe(1); + expect(await requestFor('f2', 'pending')).toBeUndefined(); + expect(parked()).toEqual([{ runId: fan.runId, nodeId: 'fan', correlation: `map:${req.flow_run_id}` }]); + }); +}); From acae674398598f03a892a74625dfffb732be990f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:53:13 +0000 Subject: [PATCH 2/3] test(plugin-approvals): route the batch recall test's engine double through the dispatch asserts The double's update/delete now open with assertEngineUpdateDispatch / assertEngineDeleteDispatch and its find holds the caller's numeric offset/limit, as check:engine-double-contract and check:objectql-double-limit require; the pinned ledger learns the two new rows (check-engine-double-contract --write). Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4d13k5TVyAhqhiKKgN3 --- .../batch-item-recall-resumes-batch.test.ts | 42 +++++++++++++------ scripts/engine-double-contract.pinned.json | 10 +++++ 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/packages/plugins/plugin-approvals/src/batch-item-recall-resumes-batch.test.ts b/packages/plugins/plugin-approvals/src/batch-item-recall-resumes-batch.test.ts index a8d61d7d250..630cc01d1fa 100644 --- a/packages/plugins/plugin-approvals/src/batch-item-recall-resumes-batch.test.ts +++ b/packages/plugins/plugin-approvals/src/batch-item-recall-resumes-batch.test.ts @@ -40,6 +40,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { AutomationEngine, InMemorySuspendedRunStore, installBuiltinNodes } from '@objectstack/service-automation'; import { APPROVAL_REVISE_NODE_TYPE, defineActionDescriptor } from '@objectstack/spec/automation'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql'; import { ApprovalService } from './approval-service.js'; import { registerApprovalNode } from './approval-node.js'; @@ -53,7 +54,7 @@ function silentLogger(): any { return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } }; } -/** In-memory ObjectQL stand-in: equality/$in/$ne where, orderBy, limit. */ +/** In-memory ObjectQL stand-in: equality/$in/$ne where, orderBy, offset/limit; update/delete dispatch as the engine does. */ function makeFakeEngine() { const tables = new Map([['sys_user', ROUTED_USERS.map((id) => ({ id }))]]); const rows = (o: string) => (tables.get(o) ?? (tables.set(o, []), tables.get(o)!)); @@ -72,20 +73,37 @@ function makeFakeEngine() { const dir = ord.order === 'desc' ? -1 : 1; out = [...out].sort((a, b) => (a[ord.field] < b[ord.field] ? -dir : a[ord.field] > b[ord.field] ? dir : 0)); } - if (opts.limit) out = out.slice(0, opts.limit); - return out.map(r => ({ ...r })); + const start = opts.offset ?? 0; + const page = typeof opts.limit === 'number' ? out.slice(start, start + opts.limit) : out.slice(start); + return page.map(r => ({ ...r })); }, async insert(object: string, data: any) { rows(object).push({ ...data }); return { ...data }; }, - async update(object: string, idOrData: any) { - const row = rows(object).find(r => r.id === idOrData.id); - if (row) Object.assign(row, idOrData); - return row ? { ...row } : null; + async update(object: string, data: any, options?: any) { + const dispatch = assertEngineUpdateDispatch(data, options); + const table = rows(object); + if (dispatch.kind === 'multi') { + let n = 0; + for (let i = 0; i < table.length; i++) { + if (matches(table[i], options?.where)) { table[i] = { ...table[i], ...data }; n++; } + } + return { updated: n }; + } + const i = table.findIndex(r => r.id === dispatch.id); + if (i >= 0) table[i] = { ...table[i], ...data }; + return i >= 0 ? { ...table[i] } : null; }, - async delete(object: string, opts: any = {}) { - const where = opts.where ?? {}; - const list = rows(object); - for (let i = list.length - 1; i >= 0; i--) if (matches(list[i], where)) list.splice(i, 1); - return { affected: 1 }; + async delete(object: string, options?: any) { + const dispatch = assertEngineDeleteDispatch(options); + const table = rows(object); + if (dispatch.kind === 'multi') { + const survivors = table.filter(r => !matches(r, options?.where)); + const deleted = table.length - survivors.length; + table.splice(0, table.length, ...survivors); + return { deleted }; + } + const i = table.findIndex(r => r.id === dispatch.id); + if (i >= 0) table.splice(i, 1); + return { id: dispatch.id }; }, }; } diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 3b60ed0b921..19ec75a16d9 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -2971,6 +2971,16 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/plugins/plugin-approvals/src/batch-item-recall-resumes-batch.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/plugins/plugin-approvals/src/batch-item-recall-resumes-batch.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/plugins/plugin-approvals/src/business-unit-member-org-screen.test.ts", "verb": "delete", From 4007cab0a1d634b87db5a3db7a91b2b81a9a70ca Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 18:05:20 +0000 Subject: [PATCH 3/3] docs(automation): a revise-window recall on a batch item wakes the batch Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4d13k5TVyAhqhiKKgN3 --- content/docs/automation/approvals.mdx | 6 +++++- content/docs/automation/flows.mdx | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/content/docs/automation/approvals.mdx b/content/docs/automation/approvals.mdx index 85c63d6d583..751dd7a18c9 100644 --- a/content/docs/automation/approvals.mdx +++ b/content/docs/automation/approvals.mdx @@ -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 diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 7eb5dfb1b42..5e796eeb7d9 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -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