@objectstack/plugin-approvals 17.0.0-rc.0 → 17.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +439 -0
  2. package/dist/index.d.mts +2304 -3491
  3. package/dist/index.d.ts +2304 -3491
  4. package/dist/index.js +116 -43
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +114 -41
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +16 -9
  9. package/.turbo/turbo-build.log +0 -22
  10. package/scripts/i18n-extract.config.ts +0 -38
  11. package/src/action-link-pages.ts +0 -102
  12. package/src/approval-actor-impersonation.test.ts +0 -330
  13. package/src/approval-node.test.ts +0 -356
  14. package/src/approval-node.ts +0 -196
  15. package/src/approval-revise.test.ts +0 -418
  16. package/src/approval-service.test.ts +0 -2858
  17. package/src/approval-service.ts +0 -3617
  18. package/src/approvals-plugin.ts +0 -294
  19. package/src/approver-cross-org.integration.test.ts +0 -206
  20. package/src/approver-org-scope.test.ts +0 -201
  21. package/src/approver-org-scope.ts +0 -261
  22. package/src/index.ts +0 -42
  23. package/src/lifecycle-hooks.ts +0 -201
  24. package/src/nav-contribution.test.ts +0 -50
  25. package/src/record-lock-schedule-run.integration.test.ts +0 -206
  26. package/src/status-mirror-cascade.integration.test.ts +0 -224
  27. package/src/sys-approval-action.object.ts +0 -149
  28. package/src/sys-approval-approver.object.ts +0 -85
  29. package/src/sys-approval-delegation.object.test.ts +0 -42
  30. package/src/sys-approval-delegation.object.ts +0 -142
  31. package/src/sys-approval-request.object.test.ts +0 -116
  32. package/src/sys-approval-request.object.ts +0 -413
  33. package/src/sys-approval-token.object.ts +0 -101
  34. package/src/translations/bundle-ownership.test.ts +0 -48
  35. package/src/translations/en.objects.generated.ts +0 -311
  36. package/src/translations/es-ES.objects.generated.ts +0 -311
  37. package/src/translations/index.ts +0 -23
  38. package/src/translations/ja-JP.objects.generated.ts +0 -311
  39. package/src/translations/zh-CN.objects.generated.ts +0 -311
  40. package/tsconfig.json +0 -10
@@ -1,356 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { describe, it, expect, beforeEach } from 'vitest';
4
- import { AutomationEngine } from '@objectstack/service-automation';
5
- import { ApprovalService } from './approval-service.js';
6
- import { registerApprovalNode } from './approval-node.js';
7
-
8
- const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as any;
9
-
10
- const noopLogger = {
11
- info() {}, warn() {}, error() {}, debug() {},
12
- };
13
-
14
- /**
15
- * Tiny in-memory ObjectQL stand-in — supports the `where`-equality + `$in`
16
- * queries the approval service issues, enough to drive the node bridge.
17
- */
18
- function makeFakeEngine() {
19
- const tables = new Map<string, any[]>();
20
- const rows = (o: string) => (tables.get(o) ?? (tables.set(o, []), tables.get(o)!));
21
- const matches = (row: any, where: any) => Object.entries(where ?? {}).every(([k, v]) => {
22
- if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]);
23
- if (v && typeof v === 'object' && '$ne' in (v as any)) return row[k] !== (v as any).$ne;
24
- return row[k] === v;
25
- });
26
- return {
27
- tables,
28
- async find(object: string, opts: any = {}) {
29
- const where = opts.where ?? opts.filter ?? {};
30
- let out = rows(object).filter(r => matches(r, where));
31
- if (opts.limit) out = out.slice(0, opts.limit);
32
- return out.map(r => ({ ...r }));
33
- },
34
- async insert(object: string, data: any) {
35
- rows(object).push({ ...data });
36
- return { ...data };
37
- },
38
- async update(object: string, idOrData: any) {
39
- const id = idOrData.id;
40
- const row = rows(object).find(r => r.id === id);
41
- if (row) Object.assign(row, idOrData);
42
- return row ? { ...row } : null;
43
- },
44
- async delete(object: string, opts: any = {}) {
45
- const where = opts.where ?? {};
46
- const list = rows(object);
47
- for (let i = list.length - 1; i >= 0; i--) if (matches(list[i], where)) list.splice(i, 1);
48
- return { affected: 1 };
49
- },
50
- };
51
- }
52
-
53
- function registerDecisionFlow(engine: AutomationEngine, approvers: Array<{ type: string; value?: string }>, behavior?: 'first_response' | 'unanimous') {
54
- engine.registerFlow('deal_approval', {
55
- name: 'deal_approval',
56
- label: 'Deal Approval',
57
- type: 'autolaunched',
58
- nodes: [
59
- { id: 'start', type: 'start', label: 'Start' },
60
- { id: 'approve_step', type: 'approval', label: 'Manager Approval', config: { approvers, behavior } },
61
- { id: 'on_approved', type: 'mark', label: 'Approved' },
62
- { id: 'on_rejected', type: 'mark', label: 'Rejected' },
63
- { id: 'end', type: 'end', label: 'End' },
64
- ],
65
- edges: [
66
- { id: 'e1', source: 'start', target: 'approve_step' },
67
- { id: 'e2', source: 'approve_step', target: 'on_approved', label: 'approve' },
68
- { id: 'e3', source: 'approve_step', target: 'on_rejected', label: 'reject' },
69
- { id: 'e4', source: 'on_approved', target: 'end' },
70
- { id: 'e5', source: 'on_rejected', target: 'end' },
71
- ],
72
- });
73
- }
74
-
75
- describe('Approval node bridge (ADR-0019)', () => {
76
- let automation: AutomationEngine;
77
- let service: ApprovalService;
78
- let fake: ReturnType<typeof makeFakeEngine>;
79
- const marks: string[] = [];
80
-
81
- beforeEach(() => {
82
- marks.length = 0;
83
- automation = new AutomationEngine(noopLogger as any);
84
- fake = makeFakeEngine();
85
- service = new ApprovalService({ engine: fake as any, logger: noopLogger });
86
- // The contract `decide()` resumes via the attached automation surface.
87
- service.attachAutomation(automation);
88
- registerApprovalNode(automation, service, noopLogger);
89
- // A terminal "mark" node records which branch ran.
90
- automation.registerNodeExecutor({
91
- type: 'mark',
92
- async execute(node: any) { marks.push(node.id); return { success: true }; },
93
- });
94
- });
95
-
96
- it('publishes an approval action descriptor that supports pause', () => {
97
- const descriptors = automation.getActionDescriptors();
98
- const approval = descriptors.find(d => d.type === 'approval');
99
- expect(approval).toBeDefined();
100
- expect(approval!.supportsPause).toBe(true);
101
- expect(approval!.category).toBe('human');
102
- });
103
-
104
- it('suspends the run on entry and opens a pending request', async () => {
105
- registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]);
106
- const result = await automation.execute('deal_approval', {
107
- object: 'crm_deal',
108
- record: { id: 'd1', amount: 100 },
109
- userId: 'submitter',
110
- });
111
- expect(result.status).toBe('paused');
112
- expect(result.runId).toBeDefined();
113
- expect(marks).toHaveLength(0);
114
-
115
- const requests = await fake.find('sys_approval_request', { where: { status: 'pending' } });
116
- expect(requests).toHaveLength(1);
117
- expect(requests[0]).toMatchObject({
118
- object_name: 'crm_deal', record_id: 'd1', flow_run_id: result.runId, flow_node_id: 'approve_step',
119
- });
120
- // Surfaced as a suspended run with the request id as correlation.
121
- const suspended = automation.listSuspendedRuns();
122
- expect(suspended[0]).toMatchObject({ nodeId: 'approve_step', correlation: requests[0].id });
123
- });
124
-
125
- it('carries the flow name + authored labels onto the request row', async () => {
126
- registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]);
127
- await automation.execute('deal_approval', {
128
- object: 'crm_deal', record: { id: 'd1', amount: 100 }, userId: 'submitter',
129
- });
130
- const [raw] = await fake.find('sys_approval_request', { where: { status: 'pending' } });
131
- // Engine-seeded `$flowName` (not the node id) names the source…
132
- expect(raw.process_name).toBe('flow:deal_approval');
133
- // …and authored labels ride the config snapshot for inbox display.
134
- const req = (await service.listRequests({ status: 'pending' }, { isSystem: true } as any))[0];
135
- expect(req.process_label).toBe('Deal Approval');
136
- expect(req.step_label).toBe('Manager Approval');
137
- });
138
-
139
- it('resumes down the approve branch on approval', async () => {
140
- registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]);
141
- const paused = await automation.execute('deal_approval', {
142
- object: 'crm_deal', record: { id: 'd1' }, userId: 'submitter',
143
- });
144
- const request = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0];
145
-
146
- const out = await service.decide(request.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX);
147
-
148
- expect(out).toMatchObject({ finalized: true, decision: 'approve', resumed: true });
149
- expect(marks).toEqual(['on_approved']);
150
- expect(automation.listSuspendedRuns()).toHaveLength(0);
151
-
152
- const finalReq = (await fake.find('sys_approval_request', { where: { id: request.id } }))[0];
153
- expect(finalReq.status).toBe('approved');
154
- expect(paused.runId).toBeDefined();
155
- });
156
-
157
- // ── resume authorization gate (#3801) ───────────────────────────────
158
- //
159
- // `POST /automation/:name/runs/:runId/resume` reaches
160
- // `AutomationEngine.resume` with a caller-supplied signal. Before the gate,
161
- // the only thing between that route and the approvals rules was convention —
162
- // a comment in the showcase. These pin the enforcement.
163
-
164
- it('declares the approval node resumable only by its owning service', () => {
165
- const approval = automation.getActionDescriptors().find(d => d.type === 'approval');
166
- expect(approval!.resumeAuthority).toBe('service');
167
- });
168
-
169
- it('refuses a raw engine resume of an approval pause, leaving the request untouched', async () => {
170
- registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]);
171
- const paused = await automation.execute('deal_approval', {
172
- object: 'crm_deal', record: { id: 'd1' }, userId: 'submitter',
173
- });
174
- const request = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0];
175
-
176
- // Exactly the signal the resume route builds from `{ branchLabel }`.
177
- const refused = await automation.resume(paused.runId!, {
178
- branchLabel: 'approve', output: { decision: 'approve' },
179
- });
180
-
181
- expect(refused).toMatchObject({ success: false, code: 'forbidden' });
182
- // The approve branch did NOT run…
183
- expect(marks).toHaveLength(0);
184
- // …the request is still pending, with no decision recorded…
185
- const stillPending = (await fake.find('sys_approval_request', { where: { id: request.id } }))[0];
186
- expect(stillPending.status).toBe('pending');
187
- const actions = await fake.find('sys_approval_action', { where: { request_id: request.id } });
188
- expect(actions.map((a: any) => a.action)).toEqual(['submit']);
189
- // …and the run is still parked, so the real decision can still land.
190
- expect(automation.listSuspendedRuns()).toHaveLength(1);
191
-
192
- const out = await service.decide(request.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX);
193
- expect(out).toMatchObject({ finalized: true, resumed: true });
194
- expect(marks).toEqual(['on_approved']);
195
- });
196
-
197
- it('resumes down the reject branch on rejection', async () => {
198
- registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]);
199
- await automation.execute('deal_approval', { object: 'crm_deal', record: { id: 'd1' } });
200
- const request = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0];
201
-
202
- const out = await service.decide(request.id, { decision: 'reject', actorId: 'u1' }, SYSTEM_CTX);
203
-
204
- expect(out).toMatchObject({ finalized: true, decision: 'reject', resumed: true });
205
- expect(marks).toEqual(['on_rejected']);
206
- });
207
-
208
- it('holds a unanimous step until every approver acts, then resumes', async () => {
209
- registerDecisionFlow(automation, [
210
- { type: 'user', value: 'u1' },
211
- { type: 'user', value: 'u2' },
212
- ], 'unanimous');
213
- await automation.execute('deal_approval', { object: 'crm_deal', record: { id: 'd1' } });
214
- const request = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0];
215
-
216
- const first = await service.decide(request.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX);
217
- expect(first.finalized).toBe(false);
218
- expect(first.resumed).toBe(false);
219
- expect(marks).toHaveLength(0);
220
-
221
- const second = await service.decide(request.id, { decision: 'approve', actorId: 'u2' }, SYSTEM_CTX);
222
- expect(second.finalized).toBe(true);
223
- expect(second.resumed).toBe(true);
224
- expect(marks).toEqual(['on_approved']);
225
- });
226
-
227
- it('rejects a decision from a non-approver', async () => {
228
- registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]);
229
- await automation.execute('deal_approval', { object: 'crm_deal', record: { id: 'd1' } });
230
- const request = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0];
231
-
232
- await expect(
233
- service.decideNode(request.id, { decision: 'approve', actorId: 'intruder' }, { isSystem: false, positions: [], permissions: [] } as any),
234
- ).rejects.toThrow(/FORBIDDEN/);
235
- });
236
-
237
- // ── #3447 P2: dynamic approvers end-to-end ────────────────────────
238
- //
239
- // The issue's headline scenario as one flow: the first approver PICKS the
240
- // next step's approvers in their decision, the next approval node resolves
241
- // them from `vars.*` at entry — no record-field detour, no snapshot staleness.
242
-
243
- it('decide outputs feed the NEXT approval node via vars.<nodeId>.<key> (#3447 P2)', async () => {
244
- automation.registerFlow('two_stage', {
245
- name: 'two_stage',
246
- label: 'Two Stage',
247
- type: 'autolaunched',
248
- nodes: [
249
- { id: 'start', type: 'start', label: 'Start' },
250
- {
251
- id: 'lead_review', type: 'approval', label: 'Lead Review',
252
- config: { approvers: [{ type: 'user', value: 'lead' }], decisionOutputs: ['next_reviewers'] },
253
- },
254
- {
255
- id: 'co_sign', type: 'approval', label: 'Co-sign',
256
- config: {
257
- approvers: [{ type: 'expression', value: 'vars.lead_review.next_reviewers' }],
258
- behavior: 'unanimous',
259
- },
260
- },
261
- { id: 'on_approved', type: 'mark', label: 'Approved' },
262
- { id: 'on_rejected', type: 'mark', label: 'Rejected' },
263
- ],
264
- edges: [
265
- { id: 'e1', source: 'start', target: 'lead_review' },
266
- { id: 'e2', source: 'lead_review', target: 'co_sign', label: 'approve' },
267
- { id: 'e3', source: 'lead_review', target: 'on_rejected', label: 'reject' },
268
- { id: 'e4', source: 'co_sign', target: 'on_approved', label: 'approve' },
269
- { id: 'e5', source: 'co_sign', target: 'on_rejected', label: 'reject' },
270
- ],
271
- });
272
-
273
- const paused = await automation.execute('two_stage', {
274
- object: 'crm_deal', record: { id: 'd1' }, userId: 'submitter',
275
- });
276
- expect(paused.status).toBe('paused');
277
-
278
- // The lead approves AND hands the co-reviewers to the flow.
279
- const first = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0];
280
- await service.decide(first.id, {
281
- decision: 'approve', actorId: 'lead', outputs: { next_reviewers: ['u2', 'u3'] },
282
- }, SYSTEM_CTX);
283
-
284
- // The co-sign node resolved its slate from the lead's decision outputs.
285
- const second = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0];
286
- expect(second).toBeDefined();
287
- expect(second.flow_node_id).toBe('co_sign');
288
- expect(String(second.pending_approvers).split(',').sort()).toEqual(['u2', 'u3']);
289
- expect(marks).toHaveLength(0);
290
-
291
- // Both picked reviewers sign off → the run completes down `approve`.
292
- await service.decide(second.id, { decision: 'approve', actorId: 'u2' }, SYSTEM_CTX);
293
- await service.decide(second.id, { decision: 'approve', actorId: 'u3' }, SYSTEM_CTX);
294
- expect(marks).toEqual(['on_approved']);
295
- expect(automation.listSuspendedRuns()).toHaveLength(0);
296
- });
297
-
298
- it('expression current.* resolves against the LIVE row at node entry (#3447 P2)', async () => {
299
- fake.tables.set('crm_deal', [{ id: 'd1', reviewers: ['u7', 'u8'] }]);
300
- registerDecisionFlow(automation, [{ type: 'expression', value: 'current.reviewers' }], 'unanimous');
301
- // The trigger snapshot carries an EMPTY reviewers field — only the live
302
- // row names them, exactly the mid-flow-written-field shape of the issue.
303
- await automation.execute('deal_approval', {
304
- object: 'crm_deal', record: { id: 'd1', reviewers: [] }, userId: 'submitter',
305
- });
306
- const request = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0];
307
- expect(String(request.pending_approvers).split(',').sort()).toEqual(['u7', 'u8']);
308
- });
309
-
310
- it("onEmptyApprovers 'auto_approve' completes down the approve edge without suspending (#3447 P2)", async () => {
311
- automation.registerFlow('auto_ok', {
312
- name: 'auto_ok',
313
- label: 'Auto OK',
314
- type: 'autolaunched',
315
- nodes: [
316
- { id: 'start', type: 'start', label: 'Start' },
317
- {
318
- id: 'gate', type: 'approval', label: 'Gate',
319
- config: {
320
- // Present-but-empty (a missing key would fail loudly instead).
321
- approvers: [{ type: 'expression', value: 'trigger.reviewers' }],
322
- onEmptyApprovers: 'auto_approve',
323
- },
324
- },
325
- { id: 'on_approved', type: 'mark', label: 'Approved' },
326
- { id: 'on_rejected', type: 'mark', label: 'Rejected' },
327
- ],
328
- edges: [
329
- { id: 'e1', source: 'start', target: 'gate' },
330
- { id: 'e2', source: 'gate', target: 'on_approved', label: 'approve' },
331
- { id: 'e3', source: 'gate', target: 'on_rejected', label: 'reject' },
332
- ],
333
- });
334
-
335
- const result = await automation.execute('auto_ok', {
336
- object: 'crm_deal', record: { id: 'd1', reviewers: [] }, userId: 'submitter',
337
- });
338
-
339
- // No pause, no request row — the empty slate waved through, down `approve`
340
- // ONLY (the branchLabel wiring; unlabelled traversal would hit both marks).
341
- expect(result.status).not.toBe('paused');
342
- expect(marks).toEqual(['on_approved']);
343
- expect(await fake.find('sys_approval_request', {})).toHaveLength(0);
344
- expect(automation.listSuspendedRuns()).toHaveLength(0);
345
- });
346
-
347
- it('an expression referencing `record` fails the node loudly, not as an empty slate (#3447 P2)', async () => {
348
- registerDecisionFlow(automation, [{ type: 'expression', value: 'record.reviewers' }]);
349
- const result = await automation.execute('deal_approval', {
350
- object: 'crm_deal', record: { id: 'd1', reviewers: ['u1'] }, userId: 'submitter',
351
- });
352
- expect(result.success).toBe(false);
353
- expect(String(result.error ?? '')).toMatch(/current\.<field>|current\./);
354
- expect(await fake.find('sys_approval_request', {})).toHaveLength(0);
355
- });
356
- });
@@ -1,196 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- /**
4
- * Approval-as-flow-node provider (ADR-0019).
5
- *
6
- * Registers an `approval` node executor on the automation engine so an approval
7
- * rides the one flow engine as a durable-pause node:
8
- *
9
- * 1. On entry the node opens a `sys_approval_request` (reusing the mature
10
- * approver-resolution / audit / lock / status-mirror machinery) and returns
11
- * `{ suspend: true }` — the engine persists the run and stops traversal.
12
- * 2. A decision (`ApprovalService.decide`) finalizes the request and resumes
13
- * the run down the matching `approve` / `reject` out-edge.
14
- *
15
- * The approval *state* (request/action rows) stays first-class and owned by this
16
- * plugin — a flow-run log can't drive an inbox / recall / audit. Only the
17
- * orchestration (when to pause, which branch to take) moves onto the engine.
18
- */
19
-
20
- import {
21
- defineActionDescriptor,
22
- ApprovalNodeConfigSchema,
23
- getApprovalNodeConfigJsonSchema,
24
- APPROVAL_NODE_TYPE,
25
- type ApprovalNodeConfig,
26
- } from '@objectstack/spec/automation';
27
- import type { SharingExecutionContext } from '@objectstack/spec/contracts';
28
- import type { ApprovalService } from './approval-service.js';
29
-
30
- /** Minimal surface of the automation engine this provider depends on. */
31
- export interface ApprovalAutomationSurface {
32
- registerNodeExecutor(executor: {
33
- type: string;
34
- descriptor?: unknown;
35
- execute(node: any, variables: Map<string, unknown>, context: any): Promise<{
36
- success: boolean;
37
- output?: Record<string, unknown>;
38
- error?: string;
39
- suspend?: boolean;
40
- correlation?: string;
41
- /**
42
- * #3447 P2: walk this labelled out-edge on normal (non-suspend)
43
- * completion — how an `onEmptyApprovers: 'auto_approve'` node continues
44
- * down `approve` without a decision. Mirrors NodeExecutionResult.
45
- */
46
- branchLabel?: string;
47
- }>;
48
- }): void;
49
- resume?(runId: string, signal?: { output?: Record<string, unknown>; branchLabel?: string }): Promise<unknown>;
50
- }
51
-
52
- interface MinimalLogger {
53
- info?: (msg: any, ...rest: any[]) => void;
54
- warn?: (msg: any, ...rest: any[]) => void;
55
- }
56
-
57
- const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;
58
-
59
- /**
60
- * Rebuild the nested object the engine's CEL conditions see from the flow's
61
- * flat variable Map — dotted keys (`get_rec.record`) become nested paths, so an
62
- * `expression` approver's `vars.get_rec.record.owner_id` reads exactly like a
63
- * condition's. Mirrors the engine's own evaluateCondition rebuild; kept local
64
- * because this plugin deliberately does not depend on service-automation.
65
- */
66
- function nestVariables(variables: Map<string, unknown>): Record<string, unknown> {
67
- const vars: Record<string, unknown> = {};
68
- for (const [key, value] of variables) {
69
- const segs = key.split('.');
70
- let cursor = vars;
71
- for (let i = 0; i < segs.length - 1; i++) {
72
- if (typeof cursor[segs[i]] !== 'object' || cursor[segs[i]] === null) {
73
- cursor[segs[i]] = {};
74
- }
75
- cursor = cursor[segs[i]] as Record<string, unknown>;
76
- }
77
- cursor[segs[segs.length - 1]] = value;
78
- }
79
- return vars;
80
- }
81
-
82
- /**
83
- * Register the `approval` node executor on the automation engine. Idempotent at
84
- * the engine level (re-registering replaces). Safe to skip when no automation
85
- * service is present.
86
- */
87
- export function registerApprovalNode(
88
- automation: ApprovalAutomationSurface,
89
- service: ApprovalService,
90
- logger?: MinimalLogger,
91
- ): void {
92
- automation.registerNodeExecutor({
93
- type: APPROVAL_NODE_TYPE,
94
- descriptor: defineActionDescriptor({
95
- type: APPROVAL_NODE_TYPE,
96
- version: '1.0.0',
97
- name: 'Approval',
98
- description: 'Route a record for human approval; suspends the flow until a decision, '
99
- + 'then continues down the approve / reject branch.',
100
- icon: 'check-circle',
101
- category: 'human',
102
- paradigms: ['flow'],
103
- source: 'plugin',
104
- // Human decision: the run suspends here awaiting an external reply.
105
- supportsPause: true,
106
- isAsync: true,
107
- // #3801: this pause is NOT resumable through the generic run-resume
108
- // route. Continuing an approval is a side effect of a DECISION, and the
109
- // decision is the thing that must be authorized (the approver slate),
110
- // recorded (`sys_approval_action`) and mirrored (the status field) —
111
- // all of which lives in `ApprovalService.decide`. The engine now refuses
112
- // any resume of an approval suspension that does not carry the service's
113
- // in-process marker, so "decide via the approvals API, never a raw engine
114
- // resume" is enforced rather than merely documented.
115
- resumeAuthority: 'service',
116
- // Publish the node's config contract (ADR-0018 §configSchema) so the
117
- // Studio flow designer renders the Approval property form from the engine
118
- // rather than a hardcoded client form — the engine owns the shape.
119
- configSchema: getApprovalNodeConfigJsonSchema(),
120
- }),
121
- async execute(node, variables, context) {
122
- const parsed = ApprovalNodeConfigSchema.safeParse(node.config ?? {});
123
- if (!parsed.success) {
124
- const msg = parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ');
125
- return { success: false, error: `Approval node '${node.id}' has invalid config: ${msg}` };
126
- }
127
- const config = parsed.data as ApprovalNodeConfig;
128
-
129
- const runId = variables.get('$runId');
130
- const record = (variables.get('$record') ?? context?.record ?? {}) as Record<string, unknown>;
131
- const object = (context?.object ?? (record as any)?.object_name) as string | undefined;
132
- const recordId = (record as any)?.id as string | undefined;
133
-
134
- if (!runId) return { success: false, error: `Approval node '${node.id}': missing $runId` };
135
- if (!object) return { success: false, error: `Approval node '${node.id}': no target object in context` };
136
- if (!recordId) return { success: false, error: `Approval node '${node.id}': no record id in $record` };
137
-
138
- // Flow identity comes from engine-seeded variables (`$flowName` /
139
- // `$flowLabel`) so the request row can carry a human-readable origin;
140
- // `context.flowName` is a legacy fallback for direct callers.
141
- const flowName = (variables.get('$flowName') as string | undefined) ?? context?.flowName;
142
- const flowLabel = variables.get('$flowLabel') as string | undefined;
143
-
144
- try {
145
- const request = await service.openNodeRequest({
146
- object,
147
- recordId: String(recordId),
148
- runId: String(runId),
149
- nodeId: node.id,
150
- config,
151
- flowName,
152
- flowLabel,
153
- nodeLabel: typeof node.label === 'string' ? node.label : undefined,
154
- submitterId: context?.userId ?? null,
155
- record,
156
- organizationId: context?.organizationId ?? context?.tenantId ?? null,
157
- // #3447 P2: flow variables (nested, as CEL conditions see them) — the
158
- // `vars.*` root for `expression` approvers; `record` above doubles as
159
- // their `trigger.*` snapshot root.
160
- variables: nestVariables(variables),
161
- }, {
162
- ...SYSTEM_CTX,
163
- userId: context?.userId,
164
- organizationId: context?.organizationId,
165
- tenantId: context?.tenantId,
166
- } as unknown as SharingExecutionContext);
167
-
168
- // #3447 P2: empty slate + onEmptyApprovers: 'auto_approve' — nobody to
169
- // ask, no request row. Complete (don't suspend) straight down the
170
- // `approve` edge; `autoApproved` on the output keeps the waved-through
171
- // hop distinguishable from a human decision in the run trace.
172
- if ('autoApproved' in request) {
173
- logger?.info?.('[approvals] approval node auto-approved (empty approver slate)', {
174
- node: node.id, run: String(runId),
175
- });
176
- return {
177
- success: true,
178
- branchLabel: 'approve',
179
- output: { decision: 'approve', autoApproved: true },
180
- };
181
- }
182
-
183
- logger?.info?.('[approvals] approval node suspended run', {
184
- node: node.id, request: request.id, run: String(runId),
185
- });
186
- // Suspend the run; the request id is the correlation key surfaced on
187
- // the suspended-run record for lookup.
188
- return { success: true, suspend: true, correlation: request.id };
189
- } catch (err: any) {
190
- return { success: false, error: `Approval node '${node.id}': ${err?.message ?? String(err)}` };
191
- }
192
- },
193
- });
194
-
195
- logger?.info?.('[approvals] approval node executor registered');
196
- }