|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#28786] A `save_notice` node ahead of an `approval` node, across a real |
| 5 | + * persisted suspension. |
| 6 | + * |
| 7 | + * ## What is claimed (#28251) |
| 8 | + * |
| 9 | + * The record-change trigger hands a record-triggered run the triggering |
| 10 | + * write's notice channel (`AutomationContext.saveNotice`). A `save_notice` |
| 11 | + * node BEFORE the approval node sets the sentence on that write's answer. The |
| 12 | + * approval node then suspends the run, the run state is persisted as JSON in |
| 13 | + * `sys_automation_run`, and the decision resumes it from there. JSON keeps the |
| 14 | + * channel's `locale` and drops its `set` function, so a `save_notice` node on |
| 15 | + * the resumed leg finds no channel and is refused by name: there is no write |
| 16 | + * left to answer. |
| 17 | + * |
| 18 | + * The executor's own suite pins that refusal with a hand-built context. This |
| 19 | + * suite pins it through the real round trip, so a store that started keeping |
| 20 | + * (or re-binding) the channel, or an engine that resumed from an in-memory copy |
| 21 | + * instead of the store, is caught here. |
| 22 | + * |
| 23 | + * ## The rig |
| 24 | + * |
| 25 | + * A real kernel: `ObjectQLPlugin` over `SqlDriver` (better-sqlite3, in memory, |
| 26 | + * real DDL), `AutomationServicePlugin` with its default (`'auto'`) durable store |
| 27 | + * (`ObjectStoreSuspendedRunStore` over `sys_automation_run`, which the plugin |
| 28 | + * registers), and `RecordChangeTriggerPlugin`. The real `ApprovalService` is |
| 29 | + * attached to the real automation engine behind the real approval node, so the |
| 30 | + * decision resumes the run the way a deployment does. A resumed leg that fails |
| 31 | + * makes the decision answer the approvals' stranded-decision failure (#4420), |
| 32 | + * which names the run's own refusal; that is existing behaviour, read here, |
| 33 | + * not a new refusal. |
| 34 | + * |
| 35 | + * ## Coverage pin |
| 36 | + * |
| 37 | + * Ablated: with the executor's no-channel refusal replaced by a success, the |
| 38 | + * resumed-leg assertions go red (`save-notice-node.ts`), and are green again |
| 39 | + * once it is restored. |
| 40 | + */ |
| 41 | + |
| 42 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 43 | +import { ObjectKernel } from '@objectstack/core'; |
| 44 | +import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; |
| 45 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 46 | +import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation'; |
| 47 | +import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change'; |
| 48 | +import type { EngineQueryOptions } from '@objectstack/spec/data'; |
| 49 | +import { strandedDecisionDetails } from '@objectstack/types'; |
| 50 | +import { ApprovalService } from './approval-service.js'; |
| 51 | +import { ApprovalsServicePlugin } from './approvals-plugin.js'; |
| 52 | +import { registerApprovalNode } from './approval-node.js'; |
| 53 | +import { installDefaultOrganization } from './default-organization.testkit.js'; |
| 54 | + |
| 55 | +const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; |
| 56 | +const asUser = (userId: string) => ({ userId, positions: [], permissions: [] }) as any; |
| 57 | +const silent = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as any; |
| 58 | + |
| 59 | +const SUBMITTER = 'u_sub'; |
| 60 | +const APPROVER = 'u_mgr'; |
| 61 | +const FLOW = 'deal_sent_for_approval'; |
| 62 | + |
| 63 | +const text = (name: string, label: string) => ({ name, label, type: 'text' as const }); |
| 64 | +const sysUser = { |
| 65 | + name: 'sys_user', |
| 66 | + label: 'User', |
| 67 | + fields: { id: { ...text('id', 'Id'), primaryKey: true }, name: text('name', 'Name'), email: text('email', 'Email') }, |
| 68 | +}; |
| 69 | +const deal = { |
| 70 | + name: 'snr_deal', |
| 71 | + label: 'Deal', |
| 72 | + fields: { name: text('name', 'Name') }, |
| 73 | +}; |
| 74 | + |
| 75 | +/** |
| 76 | + * The shape #28251 documents: tell the saver it went for approval, then ask. |
| 77 | + * The approve leg carries a second `save_notice`, which runs only after the |
| 78 | + * decision resumed the run from storage. |
| 79 | + */ |
| 80 | +const flow = { |
| 81 | + name: FLOW, |
| 82 | + label: 'Deal sent for approval', |
| 83 | + type: 'record_change', |
| 84 | + nodes: [ |
| 85 | + { id: 'start', type: 'start', label: 'Start', config: { objectName: deal.name, triggerType: 'record-after-create' } }, |
| 86 | + { id: 'tell', type: 'save_notice', label: 'Tell', config: { message: 'Sent {{ record.name }} to your manager for approval' } }, |
| 87 | + { id: 'ask', type: 'approval', label: 'Manager', config: { approvers: [{ type: 'user', value: APPROVER }] } }, |
| 88 | + { id: 'tell_after', type: 'save_notice', label: 'Tell again', config: { message: '{{ record.name }} was approved' } }, |
| 89 | + { id: 'ok', type: 'end', label: 'Approved' }, |
| 90 | + { id: 'no', type: 'end', label: 'Rejected' }, |
| 91 | + ], |
| 92 | + edges: [ |
| 93 | + { id: 'e1', source: 'start', target: 'tell' }, |
| 94 | + { id: 'e2', source: 'tell', target: 'ask' }, |
| 95 | + { id: 'e3', source: 'ask', target: 'tell_after', label: 'approve' }, |
| 96 | + { id: 'e4', source: 'ask', target: 'no', label: 'reject' }, |
| 97 | + { id: 'e5', source: 'tell_after', target: 'ok' }, |
| 98 | + ], |
| 99 | +}; |
| 100 | + |
| 101 | +/** The plugin's own object definitions, as its manifest registers them. */ |
| 102 | +async function packageObjects(): Promise<any[]> { |
| 103 | + let manifest: any; |
| 104 | + const ctx: any = { |
| 105 | + getService: (name: string) => { |
| 106 | + if (name === 'manifest') return { register: (m: any) => { manifest = m; } }; |
| 107 | + throw new Error(`no service '${name}'`); |
| 108 | + }, |
| 109 | + logger: silent, |
| 110 | + }; |
| 111 | + await new ApprovalsServicePlugin().init(ctx); |
| 112 | + return manifest.objects; |
| 113 | +} |
| 114 | + |
| 115 | +describe('[#28786] a save_notice before an approval answers the write, and one on the leg resumed from storage refuses by name', () => { |
| 116 | + let kernel: ObjectKernel; |
| 117 | + let ql: ObjectQL; |
| 118 | + let svc: ApprovalService; |
| 119 | + |
| 120 | + /** Every `sys_automation_run` row of the flow, live pause or terminal history. */ |
| 121 | + const runRows = async (): Promise<any[]> => |
| 122 | + ql.find('sys_automation_run', { where: { flow_name: FLOW }, context: SYSTEM } satisfies EngineQueryOptions); |
| 123 | + |
| 124 | + beforeAll(async () => { |
| 125 | + kernel = new ObjectKernel({ logger: { level: 'silent' } } as any); |
| 126 | + await kernel.use(new ObjectQLPlugin()); |
| 127 | + await kernel.use(new AutomationServicePlugin()); |
| 128 | + await kernel.use(new RecordChangeTriggerPlugin()); |
| 129 | + await kernel.bootstrap(); |
| 130 | + ql = kernel.getService<ObjectQL>('objectql'); |
| 131 | + const automation = kernel.getService<AutomationEngine>('automation'); |
| 132 | + |
| 133 | + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); |
| 134 | + await driver.connect(); |
| 135 | + ql.registerDriver(driver, true); |
| 136 | + for (const def of [sysUser, deal, ...(await packageObjects())]) { |
| 137 | + ql.registry.registerObject(def as any, 'save-notice-round-trip', 'save-notice-round-trip'); |
| 138 | + } |
| 139 | + await ql.syncSchemas(); |
| 140 | + await installDefaultOrganization(ql as never); |
| 141 | + for (const id of [SUBMITTER, APPROVER]) { |
| 142 | + await ql.insert('sys_user', { id, name: id, email: `${id}@example.test` }, { context: SYSTEM }); |
| 143 | + } |
| 144 | + |
| 145 | + svc = new ApprovalService({ engine: ql as any, logger: silent }); |
| 146 | + svc.attachAutomation(automation as any); |
| 147 | + registerApprovalNode(automation as any, svc, silent); |
| 148 | + automation.registerFlow(FLOW, flow as any); |
| 149 | + }); |
| 150 | + |
| 151 | + afterAll(async () => { |
| 152 | + try { await kernel?.shutdown(); } catch { /* noop */ } |
| 153 | + }); |
| 154 | + |
| 155 | + it('the create hears the sentence, the run is persisted without the channel, and the resumed save_notice refuses', async () => { |
| 156 | + // ── The write: the save_notice ahead of the approval answers it. |
| 157 | + const heard: unknown[] = []; |
| 158 | + const created: any = await ql.insert( |
| 159 | + deal.name, |
| 160 | + { name: 'Acme' }, |
| 161 | + { context: { userId: SUBMITTER }, onNotice: (message: unknown) => { heard.push(message); } } as any, |
| 162 | + ); |
| 163 | + expect(heard, "the create's own answer carried the notice").toEqual(['Sent Acme to your manager for approval']); |
| 164 | + |
| 165 | + // ── Suspended and persisted: one live row, its context stored as JSON. |
| 166 | + const [pause] = await runRows(); |
| 167 | + expect(pause, 'the run suspended at the approval node and was persisted').toBeDefined(); |
| 168 | + expect(pause.node_id).toBe('ask'); |
| 169 | + expect(pause.status, 'a live pause, not a terminal history row').toBe('paused'); |
| 170 | + const stored = JSON.parse(String(pause.context_json)); |
| 171 | + // What survives JSON: the channel object without its function. |
| 172 | + expect(stored.saveNotice === undefined || typeof stored.saveNotice === 'object').toBe(true); |
| 173 | + expect(typeof stored.saveNotice?.set, 'no function survives the persisted context').toBe('undefined'); |
| 174 | + |
| 175 | + // ── The decision resumes the run from the store. |
| 176 | + const [request] = await ql.find('sys_approval_request', { |
| 177 | + where: { record_id: created.id, status: 'pending' }, context: SYSTEM, |
| 178 | + } satisfies EngineQueryOptions); |
| 179 | + expect(request?.flow_node_id, 'the approval node opened its request').toBe('ask'); |
| 180 | + // The resumed leg fails at its save_notice, so the decision answers the |
| 181 | + // approvals' existing stranded-decision failure (#4420): the decision is |
| 182 | + // recorded and the run's own refusal is named in it. |
| 183 | + let thrown: unknown; |
| 184 | + try { |
| 185 | + await svc.decide(request.id, { decision: 'approve', actorId: APPROVER }, asUser(APPROVER)); |
| 186 | + } catch (err) { |
| 187 | + thrown = err; |
| 188 | + } |
| 189 | + expect(String((thrown as Error | undefined)?.message)).toContain( |
| 190 | + "save_notice 'tell_after': this run holds no record write to answer", |
| 191 | + ); |
| 192 | + expect(strandedDecisionDetails(thrown)).toMatchObject({ finalized: true, decision: 'approve', runId: pause.id }); |
| 193 | + |
| 194 | + // ── The resumed leg's save_notice is refused by name, and nothing reaches the write. |
| 195 | + const rows = await runRows(); |
| 196 | + expect(rows.some((r) => r.id === pause.id), 'the live pause was consumed').toBe(false); |
| 197 | + const terminal = rows.find((r) => String(r.id) === `run_${pause.id}`); |
| 198 | + expect(terminal, 'the resumed run left its history row').toBeDefined(); |
| 199 | + expect(terminal.status).toBe('failed'); |
| 200 | + expect(String(terminal.error)).toContain("save_notice 'tell_after': this run holds no record write to answer"); |
| 201 | + expect(heard, 'the resumed leg set nothing on the answer already sent').toEqual(['Sent Acme to your manager for approval']); |
| 202 | + }, 20000); |
| 203 | +}); |
0 commit comments