@objectstack/plugin-approvals 17.0.0-rc.0 → 17.0.0-rc.2
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.
- package/CHANGELOG.md +863 -0
- package/dist/index.d.mts +2236 -2688
- package/dist/index.d.ts +2236 -2688
- package/dist/index.js +591 -128
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +590 -127
- package/dist/index.mjs.map +1 -1
- package/package.json +17 -10
- package/.turbo/turbo-build.log +0 -22
- package/scripts/i18n-extract.config.ts +0 -38
- package/src/action-link-pages.ts +0 -102
- package/src/approval-actor-impersonation.test.ts +0 -330
- package/src/approval-node.test.ts +0 -356
- package/src/approval-node.ts +0 -196
- package/src/approval-revise.test.ts +0 -418
- package/src/approval-service.test.ts +0 -2858
- package/src/approval-service.ts +0 -3617
- package/src/approvals-plugin.ts +0 -294
- package/src/approver-cross-org.integration.test.ts +0 -206
- package/src/approver-org-scope.test.ts +0 -201
- package/src/approver-org-scope.ts +0 -261
- package/src/index.ts +0 -42
- package/src/lifecycle-hooks.ts +0 -201
- package/src/nav-contribution.test.ts +0 -50
- package/src/record-lock-schedule-run.integration.test.ts +0 -206
- package/src/status-mirror-cascade.integration.test.ts +0 -224
- package/src/sys-approval-action.object.ts +0 -149
- package/src/sys-approval-approver.object.ts +0 -85
- package/src/sys-approval-delegation.object.test.ts +0 -42
- package/src/sys-approval-delegation.object.ts +0 -142
- package/src/sys-approval-request.object.test.ts +0 -116
- package/src/sys-approval-request.object.ts +0 -413
- package/src/sys-approval-token.object.ts +0 -101
- package/src/translations/bundle-ownership.test.ts +0 -48
- package/src/translations/en.objects.generated.ts +0 -311
- package/src/translations/es-ES.objects.generated.ts +0 -311
- package/src/translations/index.ts +0 -23
- package/src/translations/ja-JP.objects.generated.ts +0 -311
- package/src/translations/zh-CN.objects.generated.ts +0 -311
- 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
|
-
});
|
package/src/approval-node.ts
DELETED
|
@@ -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
|
-
}
|