-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathapproval-node.ts
More file actions
139 lines (127 loc) · 5.71 KB
/
Copy pathapproval-node.ts
File metadata and controls
139 lines (127 loc) · 5.71 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
/**
* Approval-as-flow-node provider (ADR-0019).
*
* Registers an `approval` node executor on the automation engine so an approval
* rides the one flow engine as a durable-pause node:
*
* 1. On entry the node opens a `sys_approval_request` (reusing the mature
* approver-resolution / audit / lock / status-mirror machinery) and returns
* `{ suspend: true }` — the engine persists the run and stops traversal.
* 2. A decision (`ApprovalService.decide`) finalizes the request and resumes
* the run down the matching `approve` / `reject` out-edge.
*
* The approval *state* (request/action rows) stays first-class and owned by this
* plugin — a flow-run log can't drive an inbox / recall / audit. Only the
* orchestration (when to pause, which branch to take) moves onto the engine.
*/
import {
defineActionDescriptor,
ApprovalNodeConfigSchema,
getApprovalNodeConfigJsonSchema,
APPROVAL_NODE_TYPE,
type ApprovalNodeConfig,
} from '@objectstack/spec/automation';
import type { SharingExecutionContext } from '@objectstack/spec/contracts';
import type { ApprovalService } from './approval-service.js';
/** Minimal surface of the automation engine this provider depends on. */
export interface ApprovalAutomationSurface {
registerNodeExecutor(executor: {
type: string;
descriptor?: unknown;
execute(node: any, variables: Map<string, unknown>, context: any): Promise<{
success: boolean;
output?: Record<string, unknown>;
error?: string;
suspend?: boolean;
correlation?: string;
}>;
}): void;
resume?(runId: string, signal?: { output?: Record<string, unknown>; branchLabel?: string }): Promise<unknown>;
}
interface MinimalLogger {
info?: (msg: any, ...rest: any[]) => void;
warn?: (msg: any, ...rest: any[]) => void;
}
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;
/**
* Register the `approval` node executor on the automation engine. Idempotent at
* the engine level (re-registering replaces). Safe to skip when no automation
* service is present.
*/
export function registerApprovalNode(
automation: ApprovalAutomationSurface,
service: ApprovalService,
logger?: MinimalLogger,
): void {
automation.registerNodeExecutor({
type: APPROVAL_NODE_TYPE,
descriptor: defineActionDescriptor({
type: APPROVAL_NODE_TYPE,
version: '1.0.0',
name: 'Approval',
description: 'Route a record for human approval; suspends the flow until a decision, '
+ 'then continues down the approve / reject branch.',
icon: 'check-circle',
category: 'human',
paradigms: ['flow'],
source: 'plugin',
// Human decision: the run suspends here awaiting an external reply.
supportsPause: true,
isAsync: true,
// Publish the node's config contract (ADR-0018 §configSchema) so the
// Studio flow designer renders the Approval property form from the engine
// rather than a hardcoded client form — the engine owns the shape.
configSchema: getApprovalNodeConfigJsonSchema(),
}),
async execute(node, variables, context) {
const parsed = ApprovalNodeConfigSchema.safeParse(node.config ?? {});
if (!parsed.success) {
const msg = parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ');
return { success: false, error: `Approval node '${node.id}' has invalid config: ${msg}` };
}
const config = parsed.data as ApprovalNodeConfig;
const runId = variables.get('$runId');
const record = (variables.get('$record') ?? context?.record ?? {}) as Record<string, unknown>;
const object = (context?.object ?? (record as any)?.object_name) as string | undefined;
const recordId = (record as any)?.id as string | undefined;
if (!runId) return { success: false, error: `Approval node '${node.id}': missing $runId` };
if (!object) return { success: false, error: `Approval node '${node.id}': no target object in context` };
if (!recordId) return { success: false, error: `Approval node '${node.id}': no record id in $record` };
// Flow identity comes from engine-seeded variables (`$flowName` /
// `$flowLabel`) so the request row can carry a human-readable origin;
// `context.flowName` is a legacy fallback for direct callers.
const flowName = (variables.get('$flowName') as string | undefined) ?? context?.flowName;
const flowLabel = variables.get('$flowLabel') as string | undefined;
try {
const request = await service.openNodeRequest({
object,
recordId: String(recordId),
runId: String(runId),
nodeId: node.id,
config,
flowName,
flowLabel,
nodeLabel: typeof node.label === 'string' ? node.label : undefined,
submitterId: context?.userId ?? null,
record,
organizationId: context?.organizationId ?? context?.tenantId ?? null,
}, {
...SYSTEM_CTX,
userId: context?.userId,
organizationId: context?.organizationId,
tenantId: context?.tenantId,
} as unknown as SharingExecutionContext);
logger?.info?.('[approvals] approval node suspended run', {
node: node.id, request: request.id, run: String(runId),
});
// Suspend the run; the request id is the correlation key surfaced on
// the suspended-run record for lookup.
return { success: true, suspend: true, correlation: request.id };
} catch (err: any) {
return { success: false, error: `Approval node '${node.id}': ${err?.message ?? String(err)}` };
}
},
});
logger?.info?.('[approvals] approval node executor registered');
}