@fabricorg/platform-host 0.3.1 → 0.4.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.
- package/CHANGELOG.md +16 -0
- package/README.md +53 -2
- package/dist/index.cjs +324 -42
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +75 -5
- package/dist/index.d.ts +75 -5
- package/dist/index.js +324 -42
- package/dist/index.js.map +1 -1
- package/package.json +66 -62
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @fabricorg/platform-host
|
|
2
2
|
|
|
3
|
+
## 0.4.1 — 2026-07-20
|
|
4
|
+
|
|
5
|
+
- Add runtime-local action resolution without process-wide registry mutation.
|
|
6
|
+
- Pass the injected host clock to policy evaluators.
|
|
7
|
+
- Treat empty state-machine targets as no-op transitions.
|
|
8
|
+
- Default domain events to the action schema version while preserving explicit event versions.
|
|
9
|
+
- Omit custom extracted-event carrier fields from durable invocation results.
|
|
10
|
+
|
|
11
|
+
## 0.4.0 — 2026-07-19
|
|
12
|
+
|
|
13
|
+
- Add an optional agent-only HITL evaluator between schema validation and ordinary policies.
|
|
14
|
+
- Persist HITL route, risk tier, reason, and ruleset version as durable invocation evidence.
|
|
15
|
+
- Add authorized, atomic approval and rejection decisions with governed resume execution.
|
|
16
|
+
- Keep parked invocations out of worker claims and report worker-parked work separately from failures.
|
|
17
|
+
- Preserve existing behavior for hosts that do not configure HITL.
|
|
18
|
+
|
|
3
19
|
## 0.3.1 — 2026-07-19
|
|
4
20
|
|
|
5
21
|
- Make policy, event, and adapter checkpoints deterministic and replay-safe.
|
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
The canonical host for the `@fabricorg/platform` mutation pipeline.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
pnpm add @fabricorg/platform@^0.7.0 @fabricorg/platform-host@^0.
|
|
6
|
+
pnpm add @fabricorg/platform@^0.7.0 @fabricorg/platform-host@^0.4.1
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
Vertical packages register `FabricModule` definitions. Host applications provide tenant authorization,
|
|
@@ -11,13 +11,19 @@ module entitlements, persistence, projections, and an optional durable dispatche
|
|
|
11
11
|
ordering and lifecycle invariant:
|
|
12
12
|
|
|
13
13
|
```text
|
|
14
|
-
Actor → ActionInvocation → PolicyEvaluation → StateMachine → Handler → AssetEvent → AdapterInvocation → Projection
|
|
14
|
+
Actor → ActionInvocation → Schema → Agent HITL → PolicyEvaluation → StateMachine → Handler → AssetEvent → AdapterInvocation → Projection
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
`submitAction()` creates the durable `ActionInvocation` before dispatch. `executeInvocation()` is the
|
|
18
18
|
worker entry point. With no dispatcher the host executes inline, which is intended for tests and local
|
|
19
19
|
development only.
|
|
20
20
|
|
|
21
|
+
Applications that assemble action catalogs per runtime can provide `resolveAction` instead of
|
|
22
|
+
mutating the process-wide platform registry. A custom `extractEvents` implementation can pair with
|
|
23
|
+
`eventResultFields` so its event carrier is removed from the durable invocation result. Domain events
|
|
24
|
+
without an explicit `eventSchemaVersion` inherit the action version, while host lifecycle events stay
|
|
25
|
+
at version 1. The injected `now` clock is also passed to ordinary policy evaluation.
|
|
26
|
+
|
|
21
27
|
Production polling workers can use `createStoreBackedActionDispatcher()` plus
|
|
22
28
|
`runPlatformActionWorker()`. The pending invocation row is the durable queue item; workers claim
|
|
23
29
|
bounded batches with an atomic lease and `FOR UPDATE SKIP LOCKED`, and an expired `running` lease is
|
|
@@ -29,6 +35,51 @@ adapter that already reached `succeeded`, and event, policy, and adapter writes
|
|
|
29
35
|
stale action that was not declared idempotent fails terminally for manual reconciliation instead of
|
|
30
36
|
silently rerunning unknown side effects.
|
|
31
37
|
|
|
38
|
+
## Agent HITL
|
|
39
|
+
|
|
40
|
+
Hosts can inject a vertical-owned `hitlEvaluator`. It runs only for `actorType: "agent"`, after schema
|
|
41
|
+
validation and before ordinary policies. Omitting it preserves the pre-0.4 behavior. The host owns the
|
|
42
|
+
durable lifecycle; the vertical continues to own the rules and risk classification.
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
const host = createGovernedActionHost({
|
|
46
|
+
store,
|
|
47
|
+
authorization,
|
|
48
|
+
hitlPolicyVersion: "gtm-rules.v7",
|
|
49
|
+
hitlEvaluator: async (context) => ({
|
|
50
|
+
route: context.actionId === "gtm.send_message" ? "needs-approval" : "auto-execute",
|
|
51
|
+
riskTier: context.actionId === "gtm.send_message" ? "high" : "low",
|
|
52
|
+
reason: "Vertical-owned prospect-touching rule",
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The evaluator returns `auto-execute`, `needs-approval`, `escalate`, or `rejected`:
|
|
58
|
+
|
|
59
|
+
- `auto-execute` continues through policies, state validation, the handler, events, and adapters.
|
|
60
|
+
- `needs-approval` and `escalate` persist the route and risk evidence, clear any worker lease, and park
|
|
61
|
+
the invocation as `waiting_for_approval`. Polling workers never claim that status.
|
|
62
|
+
- `rejected` terminally fails before policies and mutation code run.
|
|
63
|
+
|
|
64
|
+
An approval workflow calls `resumeApprovedInvocation()` instead of invoking the handler directly. The
|
|
65
|
+
host authorizes the approver (using `authorizeApproval` when provided), then atomically persists the
|
|
66
|
+
decision and changes `waiting_for_approval` to either leased `running` or terminal `failed`. This keeps
|
|
67
|
+
the decision in the mutation ledger and prevents an approval/worker race.
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
await host.resumeApprovedInvocation(actionInvocationId, tenantId, spaceId, {
|
|
71
|
+
approved: true,
|
|
72
|
+
approverId: reviewer.id,
|
|
73
|
+
approverType: "natural_person",
|
|
74
|
+
reason: "Reviewed prospect-facing draft",
|
|
75
|
+
editsReference: "draft-revision-2",
|
|
76
|
+
});
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Custom stores remain source-compatible when HITL is unused. To enable HITL they must implement the
|
|
80
|
+
additive `ApprovalPlatformHostStore` capability. Both built-in stores implement it; Postgres users must
|
|
81
|
+
call `ensureSchema()` so the HITL evidence and approval-decision columns are added.
|
|
82
|
+
|
|
32
83
|
Sensitive values must not be passed as action parameters. Stage them in tenant-bound encrypted storage
|
|
33
84
|
and pass an opaque identifier instead; action parameters are intentionally durable audit evidence.
|
|
34
85
|
Hosts should additionally configure `redactActionParameters` as a fail-safe allowlist for actions
|
package/dist/index.cjs
CHANGED
|
@@ -12,8 +12,10 @@ function createGovernedActionHost(options) {
|
|
|
12
12
|
for (const adapter of options.adapters ?? []) adapters.register(adapter);
|
|
13
13
|
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
14
14
|
const extractEvents = options.extractEvents ?? DEFAULT_EXTRACT_EVENTS;
|
|
15
|
+
const actionResolver = options.resolveAction ?? platform.resolveAction;
|
|
16
|
+
const eventResultFields = options.eventResultFields ?? ["_events"];
|
|
15
17
|
async function submitAction(input) {
|
|
16
|
-
const action =
|
|
18
|
+
const action = actionResolver(input.actionId);
|
|
17
19
|
if (!action) throw new Error(`Unknown action: ${input.actionId}`);
|
|
18
20
|
const authorizationInput = toAuthorizationInput(action, input);
|
|
19
21
|
if (!await options.authorization.checkEntitlement(authorizationInput)) {
|
|
@@ -47,7 +49,9 @@ function createGovernedActionHost(options) {
|
|
|
47
49
|
status: durableInvocation.status,
|
|
48
50
|
workflowId: durableWorkflowId,
|
|
49
51
|
result: durableInvocation.result,
|
|
50
|
-
...durableInvocation.error ? { error: durableInvocation.error } : {}
|
|
52
|
+
...durableInvocation.error ? { error: durableInvocation.error } : {},
|
|
53
|
+
...durableInvocation.hitlRoute ? { hitlRoute: durableInvocation.hitlRoute } : {},
|
|
54
|
+
...durableInvocation.hitlRiskTier ? { hitlRiskTier: durableInvocation.hitlRiskTier } : {}
|
|
51
55
|
};
|
|
52
56
|
}
|
|
53
57
|
if (options.dispatcher) {
|
|
@@ -62,7 +66,9 @@ function createGovernedActionHost(options) {
|
|
|
62
66
|
actionInvocationId: durableInvocation.id,
|
|
63
67
|
status: durableInvocation.status,
|
|
64
68
|
workflowId: dispatched.workflowId,
|
|
65
|
-
...dispatched.runId ? { runId: dispatched.runId } : {}
|
|
69
|
+
...dispatched.runId ? { runId: dispatched.runId } : {},
|
|
70
|
+
...durableInvocation.hitlRoute ? { hitlRoute: durableInvocation.hitlRoute } : {},
|
|
71
|
+
...durableInvocation.hitlRiskTier ? { hitlRiskTier: durableInvocation.hitlRiskTier } : {}
|
|
66
72
|
};
|
|
67
73
|
} catch (error) {
|
|
68
74
|
const message = errorMessage(error);
|
|
@@ -82,22 +88,26 @@ function createGovernedActionHost(options) {
|
|
|
82
88
|
);
|
|
83
89
|
return { ...executed, workflowId: durableWorkflowId };
|
|
84
90
|
}
|
|
85
|
-
async function executeInvocation(actionInvocationId, tenantId, spaceId) {
|
|
86
|
-
const
|
|
91
|
+
async function executeInvocation(actionInvocationId, tenantId, spaceId, executionOptions = {}) {
|
|
92
|
+
const loadedInvocation = await options.store.getActionInvocation(
|
|
87
93
|
actionInvocationId,
|
|
88
94
|
tenantId,
|
|
89
95
|
spaceId
|
|
90
96
|
);
|
|
91
|
-
if (!
|
|
92
|
-
|
|
97
|
+
if (!loadedInvocation) {
|
|
98
|
+
throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
|
|
99
|
+
}
|
|
100
|
+
let invocation = loadedInvocation;
|
|
101
|
+
if (isTerminal(invocation.status) || invocation.status === "waiting_for_approval") {
|
|
102
|
+
return actionResult(invocation);
|
|
103
|
+
}
|
|
104
|
+
if (invocation.status === "running" && invocation.leaseOwner && executionOptions.leaseOwner !== invocation.leaseOwner) {
|
|
93
105
|
return {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
result: invocation.result,
|
|
97
|
-
...invocation.error ? { error: invocation.error } : {}
|
|
106
|
+
...actionResult(invocation),
|
|
107
|
+
error: `Invocation is leased by ${invocation.leaseOwner}`
|
|
98
108
|
};
|
|
99
109
|
}
|
|
100
|
-
const action =
|
|
110
|
+
const action = actionResolver(invocation.actionId);
|
|
101
111
|
if (!action) {
|
|
102
112
|
return fail(invocation, "failed", `Unknown action: ${invocation.actionId}`);
|
|
103
113
|
}
|
|
@@ -108,15 +118,68 @@ function createGovernedActionHost(options) {
|
|
|
108
118
|
`Interrupted action ${invocation.actionId} is not declared idempotent; manual reconciliation is required.`
|
|
109
119
|
);
|
|
110
120
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
121
|
+
if (invocation.status !== "running") {
|
|
122
|
+
await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
|
|
123
|
+
status: "running"
|
|
124
|
+
});
|
|
125
|
+
}
|
|
114
126
|
try {
|
|
115
127
|
const parsed = action.schema.safeParse(invocation.parameters);
|
|
116
128
|
if (!parsed.success) {
|
|
117
129
|
const message = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
|
|
118
130
|
return fail(invocation, "validation_failed", message);
|
|
119
131
|
}
|
|
132
|
+
if (invocation.actorType === "agent" && options.hitlEvaluator) {
|
|
133
|
+
const hitlParameters = isRecord(parsed.data) ? parsed.data : invocation.parameters;
|
|
134
|
+
const approvalStore = asApprovalStore(options.store);
|
|
135
|
+
if (!approvalStore) {
|
|
136
|
+
return fail(
|
|
137
|
+
invocation,
|
|
138
|
+
"failed",
|
|
139
|
+
"The configured HITL evaluator requires a store with approval persistence support."
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (!invocation.hitlRoute) {
|
|
143
|
+
const decision = await options.hitlEvaluator({
|
|
144
|
+
actionId: action.actionId,
|
|
145
|
+
actorId: invocation.actorId,
|
|
146
|
+
actorType: invocation.actorType,
|
|
147
|
+
tenantId,
|
|
148
|
+
spaceId,
|
|
149
|
+
parameters: hitlParameters,
|
|
150
|
+
...numberValue(hitlParameters.confidence) !== void 0 ? { confidence: numberValue(hitlParameters.confidence) } : {},
|
|
151
|
+
...isRiskTier(hitlParameters.riskTier) ? { riskTier: hitlParameters.riskTier } : {},
|
|
152
|
+
...stringValue(hitlParameters.agentSessionId) ? { agentSessionId: stringValue(hitlParameters.agentSessionId) } : {},
|
|
153
|
+
...stringValue(hitlParameters.agentRunId) ? { agentRunId: stringValue(hitlParameters.agentRunId) } : {}
|
|
154
|
+
});
|
|
155
|
+
invocation = await approvalStore.recordHitlDecision(
|
|
156
|
+
actionInvocationId,
|
|
157
|
+
tenantId,
|
|
158
|
+
spaceId,
|
|
159
|
+
{
|
|
160
|
+
...decision,
|
|
161
|
+
...options.hitlPolicyVersion ? { policyVersion: options.hitlPolicyVersion } : {},
|
|
162
|
+
evaluatedAt: now()
|
|
163
|
+
}
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
if (invocation.hitlRoute === "rejected") {
|
|
167
|
+
return fail(
|
|
168
|
+
invocation,
|
|
169
|
+
"failed",
|
|
170
|
+
`HITL rejected: ${invocation.hitlReason ?? "no reason provided"}`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
if ((invocation.hitlRoute === "needs-approval" || invocation.hitlRoute === "escalate") && !invocation.approvalDecision?.approved) {
|
|
174
|
+
await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
|
|
175
|
+
status: "waiting_for_approval"
|
|
176
|
+
});
|
|
177
|
+
return {
|
|
178
|
+
...actionResult(invocation),
|
|
179
|
+
status: "waiting_for_approval"
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
120
183
|
const authorizationInput = toAuthorizationInput(action, invocation);
|
|
121
184
|
const definitions = options.resolvePolicies ? await options.resolvePolicies({
|
|
122
185
|
...authorizationInput,
|
|
@@ -132,7 +195,8 @@ function createGovernedActionHost(options) {
|
|
|
132
195
|
parameters: parsed.data,
|
|
133
196
|
db: options.store.db,
|
|
134
197
|
services: options.services,
|
|
135
|
-
mode: "execute"
|
|
198
|
+
mode: "execute",
|
|
199
|
+
now: now()
|
|
136
200
|
});
|
|
137
201
|
for (const outcome of outcomes) {
|
|
138
202
|
await options.store.appendPolicyEvaluation({
|
|
@@ -165,15 +229,17 @@ function createGovernedActionHost(options) {
|
|
|
165
229
|
entityId
|
|
166
230
|
) ?? initialState(binding.entityType) : initialState(binding.entityType);
|
|
167
231
|
const targetState = typeof binding.targetState === "function" ? binding.targetState(parsed.data) : binding.targetState;
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
232
|
+
if (targetState !== "") {
|
|
233
|
+
const transition = platform.validateTransition(
|
|
234
|
+
binding.entityType,
|
|
235
|
+
currentState,
|
|
236
|
+
targetState,
|
|
237
|
+
action.actionId
|
|
238
|
+
);
|
|
239
|
+
const replayingAppliedTransition = action.idempotent && currentState === targetState;
|
|
240
|
+
if (!transition.valid && !replayingAppliedTransition) {
|
|
241
|
+
return fail(invocation, "failed", transition.error ?? "Invalid state transition");
|
|
242
|
+
}
|
|
177
243
|
}
|
|
178
244
|
}
|
|
179
245
|
let data;
|
|
@@ -202,7 +268,7 @@ function createGovernedActionHost(options) {
|
|
|
202
268
|
domainEvents = extractEvents(data);
|
|
203
269
|
if (action.eventPhase !== "after_adapters") {
|
|
204
270
|
for (const [index, event] of domainEvents.entries()) {
|
|
205
|
-
await appendEvent(invocation, event, `domain:${index}
|
|
271
|
+
await appendEvent(invocation, event, `domain:${index}`, action.version);
|
|
206
272
|
}
|
|
207
273
|
}
|
|
208
274
|
} catch (error) {
|
|
@@ -321,27 +387,96 @@ function createGovernedActionHost(options) {
|
|
|
321
387
|
}
|
|
322
388
|
if (action.eventPhase === "after_adapters") {
|
|
323
389
|
for (const [index, event] of domainEvents.entries()) {
|
|
324
|
-
await appendEvent(invocation, event, `domain:${index}
|
|
390
|
+
await appendEvent(invocation, event, `domain:${index}`, action.version);
|
|
325
391
|
}
|
|
326
392
|
}
|
|
327
|
-
const result = withoutPrivateHostFields(data);
|
|
393
|
+
const result = withoutPrivateHostFields(data, eventResultFields);
|
|
328
394
|
await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
|
|
329
395
|
status: "completed",
|
|
330
396
|
result
|
|
331
397
|
});
|
|
332
|
-
return {
|
|
398
|
+
return {
|
|
399
|
+
actionInvocationId,
|
|
400
|
+
status: "completed",
|
|
401
|
+
result,
|
|
402
|
+
...invocation.hitlRoute ? { hitlRoute: invocation.hitlRoute } : {},
|
|
403
|
+
...invocation.hitlRiskTier ? { hitlRiskTier: invocation.hitlRiskTier } : {}
|
|
404
|
+
};
|
|
333
405
|
} catch (error) {
|
|
334
406
|
return fail(invocation, "failed", errorMessage(error));
|
|
335
407
|
}
|
|
336
408
|
}
|
|
337
|
-
async function
|
|
409
|
+
async function resumeApprovedInvocation(actionInvocationId, tenantId, spaceId, decision) {
|
|
410
|
+
if (!decision.approverId.trim()) throw new Error("approverId is required");
|
|
411
|
+
const invocation = await options.store.getActionInvocation(
|
|
412
|
+
actionInvocationId,
|
|
413
|
+
tenantId,
|
|
414
|
+
spaceId
|
|
415
|
+
);
|
|
416
|
+
if (!invocation) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
|
|
417
|
+
if (isTerminal(invocation.status)) return actionResult(invocation);
|
|
418
|
+
if (invocation.status !== "waiting_for_approval") {
|
|
419
|
+
return {
|
|
420
|
+
...actionResult(invocation),
|
|
421
|
+
error: `Invocation is not waiting for approval (status: ${invocation.status})`
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
const action = actionResolver(invocation.actionId);
|
|
425
|
+
if (!action) return fail(invocation, "failed", `Unknown action: ${invocation.actionId}`);
|
|
426
|
+
const approvalAuthorizationInput = toAuthorizationInput(action, {
|
|
427
|
+
tenantId,
|
|
428
|
+
spaceId,
|
|
429
|
+
actorId: decision.approverId,
|
|
430
|
+
actorType: decision.approverType
|
|
431
|
+
});
|
|
432
|
+
const entitled = await options.authorization.checkEntitlement(
|
|
433
|
+
approvalAuthorizationInput
|
|
434
|
+
);
|
|
435
|
+
const authorized = options.authorization.authorizeApproval ? await options.authorization.authorizeApproval({
|
|
436
|
+
...approvalAuthorizationInput,
|
|
437
|
+
decision
|
|
438
|
+
}) : await options.authorization.authorize(approvalAuthorizationInput);
|
|
439
|
+
if (!entitled || !authorized) {
|
|
440
|
+
return {
|
|
441
|
+
...actionResult(invocation),
|
|
442
|
+
error: `Actor ${decision.approverId} is not authorized to decide this approval`
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
const approvalStore = asApprovalStore(options.store);
|
|
446
|
+
if (!approvalStore) {
|
|
447
|
+
return {
|
|
448
|
+
...actionResult(invocation),
|
|
449
|
+
error: "Approval resume requires a store with approval persistence support."
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
const transitionStartedAt = now();
|
|
453
|
+
const decidedAt = decision.decidedAt ?? transitionStartedAt;
|
|
454
|
+
const leaseOwner = `approval-resume-${platform.createFabricId("lease")}`;
|
|
455
|
+
const transition = await approvalStore.beginApprovalDecision({
|
|
456
|
+
actionInvocationId,
|
|
457
|
+
tenantId,
|
|
458
|
+
spaceId,
|
|
459
|
+
decision: { ...decision, decidedAt },
|
|
460
|
+
leaseOwner,
|
|
461
|
+
leaseDurationMs: options.approvalResumeLeaseDurationMs ?? 5 * 6e4,
|
|
462
|
+
now: transitionStartedAt
|
|
463
|
+
});
|
|
464
|
+
const transitioned = transition.invocation ?? await options.store.getActionInvocation(actionInvocationId, tenantId, spaceId);
|
|
465
|
+
if (!transition.applied || !transitioned) {
|
|
466
|
+
if (!transitioned) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
|
|
467
|
+
return actionResult(transitioned);
|
|
468
|
+
}
|
|
469
|
+
if (!decision.approved) return actionResult(transitioned);
|
|
470
|
+
return executeInvocation(actionInvocationId, tenantId, spaceId, { leaseOwner });
|
|
471
|
+
}
|
|
472
|
+
async function appendEvent(invocation, event, deduplicationKey, defaultEventSchemaVersion = 1) {
|
|
338
473
|
const timestamp = now();
|
|
339
474
|
const envelope = {
|
|
340
475
|
id: lifecycleId("evt", invocation.id, deduplicationKey),
|
|
341
476
|
tenantId: invocation.tenantId,
|
|
342
477
|
spaceId: invocation.spaceId,
|
|
343
478
|
eventType: event.eventType,
|
|
344
|
-
eventSchemaVersion: event.eventSchemaVersion ??
|
|
479
|
+
eventSchemaVersion: event.eventSchemaVersion ?? defaultEventSchemaVersion,
|
|
345
480
|
subjectType: event.subjectType,
|
|
346
481
|
subjectId: event.subjectId,
|
|
347
482
|
actorId: invocation.actorId,
|
|
@@ -366,9 +501,38 @@ function createGovernedActionHost(options) {
|
|
|
366
501
|
invocation.spaceId,
|
|
367
502
|
{ status, error }
|
|
368
503
|
);
|
|
369
|
-
return {
|
|
504
|
+
return {
|
|
505
|
+
actionInvocationId: invocation.id,
|
|
506
|
+
status,
|
|
507
|
+
error,
|
|
508
|
+
...invocation.hitlRoute ? { hitlRoute: invocation.hitlRoute } : {},
|
|
509
|
+
...invocation.hitlRiskTier ? { hitlRiskTier: invocation.hitlRiskTier } : {}
|
|
510
|
+
};
|
|
370
511
|
}
|
|
371
|
-
return { submitAction, executeInvocation };
|
|
512
|
+
return { submitAction, executeInvocation, resumeApprovedInvocation };
|
|
513
|
+
}
|
|
514
|
+
function actionResult(invocation) {
|
|
515
|
+
return {
|
|
516
|
+
actionInvocationId: invocation.id,
|
|
517
|
+
status: invocation.status,
|
|
518
|
+
result: invocation.result,
|
|
519
|
+
...invocation.error ? { error: invocation.error } : {},
|
|
520
|
+
...invocation.hitlRoute ? { hitlRoute: invocation.hitlRoute } : {},
|
|
521
|
+
...invocation.hitlRiskTier ? { hitlRiskTier: invocation.hitlRiskTier } : {}
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
function asApprovalStore(store) {
|
|
525
|
+
const candidate = store;
|
|
526
|
+
return typeof candidate.recordHitlDecision === "function" && typeof candidate.beginApprovalDecision === "function" ? store : void 0;
|
|
527
|
+
}
|
|
528
|
+
function numberValue(value) {
|
|
529
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
530
|
+
}
|
|
531
|
+
function stringValue(value) {
|
|
532
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
533
|
+
}
|
|
534
|
+
function isRiskTier(value) {
|
|
535
|
+
return value === "low" || value === "medium" || value === "high";
|
|
372
536
|
}
|
|
373
537
|
function toAuthorizationInput(action, input) {
|
|
374
538
|
return {
|
|
@@ -397,9 +561,10 @@ function initialState(entityType) {
|
|
|
397
561
|
function isTerminal(status) {
|
|
398
562
|
return ["completed", "failed", "blocked_by_policy", "validation_failed"].includes(status);
|
|
399
563
|
}
|
|
400
|
-
function withoutPrivateHostFields(data) {
|
|
401
|
-
|
|
402
|
-
|
|
564
|
+
function withoutPrivateHostFields(data, eventResultFields) {
|
|
565
|
+
return Object.fromEntries(
|
|
566
|
+
Object.entries(data).filter(([key]) => !eventResultFields.includes(key))
|
|
567
|
+
);
|
|
403
568
|
}
|
|
404
569
|
function errorMessage(error) {
|
|
405
570
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -446,6 +611,45 @@ var MemoryPlatformHostStore = class {
|
|
|
446
611
|
const record = await this.getActionInvocation(id, tenantId, spaceId);
|
|
447
612
|
if (!record) throw new Error(`ActionInvocation not found: ${id}`);
|
|
448
613
|
Object.assign(record, patch, { updatedAt: /* @__PURE__ */ new Date() });
|
|
614
|
+
if (patch.status === "waiting_for_approval" || patch.status === "completed" || patch.status === "failed" || patch.status === "blocked_by_policy" || patch.status === "validation_failed") {
|
|
615
|
+
delete record.leaseOwner;
|
|
616
|
+
delete record.leaseExpiresAt;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
async recordHitlDecision(actionInvocationId, tenantId, spaceId, decision) {
|
|
620
|
+
const record = await this.getActionInvocation(actionInvocationId, tenantId, spaceId);
|
|
621
|
+
if (!record) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
|
|
622
|
+
record.hitlRoute = decision.route;
|
|
623
|
+
record.hitlRiskTier = decision.riskTier;
|
|
624
|
+
record.hitlReason = decision.reason;
|
|
625
|
+
if (decision.policyVersion) record.hitlPolicyVersion = decision.policyVersion;
|
|
626
|
+
record.updatedAt = decision.evaluatedAt;
|
|
627
|
+
return record;
|
|
628
|
+
}
|
|
629
|
+
async beginApprovalDecision(input) {
|
|
630
|
+
const record = await this.getActionInvocation(
|
|
631
|
+
input.actionInvocationId,
|
|
632
|
+
input.tenantId,
|
|
633
|
+
input.spaceId
|
|
634
|
+
);
|
|
635
|
+
if (!record || record.status !== "waiting_for_approval") {
|
|
636
|
+
return { applied: false, ...record ? { invocation: record } : {} };
|
|
637
|
+
}
|
|
638
|
+
record.approvalDecision = input.decision;
|
|
639
|
+
record.updatedAt = input.now;
|
|
640
|
+
if (!input.decision.approved) {
|
|
641
|
+
record.status = "failed";
|
|
642
|
+
record.error = `Approval rejected: ${input.decision.reason ?? "no reason provided"}`;
|
|
643
|
+
delete record.leaseOwner;
|
|
644
|
+
delete record.leaseExpiresAt;
|
|
645
|
+
return { applied: true, invocation: record };
|
|
646
|
+
}
|
|
647
|
+
record.status = "running";
|
|
648
|
+
record.error = void 0;
|
|
649
|
+
record.leaseOwner = input.leaseOwner;
|
|
650
|
+
record.leaseExpiresAt = new Date(input.now.getTime() + input.leaseDurationMs);
|
|
651
|
+
record.attemptCount = Math.max(record.attemptCount, 1);
|
|
652
|
+
return { applied: true, invocation: record };
|
|
449
653
|
}
|
|
450
654
|
async appendPolicyEvaluation(record) {
|
|
451
655
|
if (this.policyEvaluations.some((candidate) => candidate.id === record.id)) return;
|
|
@@ -523,14 +727,20 @@ var PostgresPlatformHostStore = class {
|
|
|
523
727
|
parameters jsonb NOT NULL, result jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
524
728
|
correlation_id text NOT NULL, causation_id text, idempotency_key text, error text,
|
|
525
729
|
attempt_count integer NOT NULL DEFAULT 0, lease_owner text,
|
|
526
|
-
lease_expires_at timestamptz,
|
|
730
|
+
lease_expires_at timestamptz, hitl_route text, hitl_risk_tier text,
|
|
731
|
+
hitl_reason text, hitl_policy_version text, approval_decision jsonb,
|
|
527
732
|
created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL
|
|
528
733
|
);
|
|
529
734
|
ALTER TABLE fabric_platform.action_invocations
|
|
530
735
|
ADD COLUMN IF NOT EXISTS idempotency_key text,
|
|
531
736
|
ADD COLUMN IF NOT EXISTS attempt_count integer NOT NULL DEFAULT 0,
|
|
532
737
|
ADD COLUMN IF NOT EXISTS lease_owner text,
|
|
533
|
-
ADD COLUMN IF NOT EXISTS lease_expires_at timestamptz
|
|
738
|
+
ADD COLUMN IF NOT EXISTS lease_expires_at timestamptz,
|
|
739
|
+
ADD COLUMN IF NOT EXISTS hitl_route text,
|
|
740
|
+
ADD COLUMN IF NOT EXISTS hitl_risk_tier text,
|
|
741
|
+
ADD COLUMN IF NOT EXISTS hitl_reason text,
|
|
742
|
+
ADD COLUMN IF NOT EXISTS hitl_policy_version text,
|
|
743
|
+
ADD COLUMN IF NOT EXISTS approval_decision jsonb;
|
|
534
744
|
CREATE UNIQUE INDEX IF NOT EXISTS action_invocations_idempotency_idx
|
|
535
745
|
ON fabric_platform.action_invocations
|
|
536
746
|
(tenant_id, space_id, action_id, idempotency_key)
|
|
@@ -614,9 +824,9 @@ var PostgresPlatformHostStore = class {
|
|
|
614
824
|
`UPDATE fabric_platform.action_invocations SET
|
|
615
825
|
status=COALESCE($4,status), result=COALESCE($5::jsonb,result),
|
|
616
826
|
error=CASE WHEN $6::boolean THEN $7 ELSE error END,
|
|
617
|
-
lease_owner=CASE WHEN $4 IN ('completed','failed','blocked_by_policy','validation_failed')
|
|
827
|
+
lease_owner=CASE WHEN $4 IN ('waiting_for_approval','completed','failed','blocked_by_policy','validation_failed')
|
|
618
828
|
THEN NULL ELSE lease_owner END,
|
|
619
|
-
lease_expires_at=CASE WHEN $4 IN ('completed','failed','blocked_by_policy','validation_failed')
|
|
829
|
+
lease_expires_at=CASE WHEN $4 IN ('waiting_for_approval','completed','failed','blocked_by_policy','validation_failed')
|
|
620
830
|
THEN NULL ELSE lease_expires_at END,
|
|
621
831
|
updated_at=now()
|
|
622
832
|
WHERE id=$1 AND tenant_id=$2 AND space_id=$3`,
|
|
@@ -631,6 +841,54 @@ var PostgresPlatformHostStore = class {
|
|
|
631
841
|
]
|
|
632
842
|
);
|
|
633
843
|
}
|
|
844
|
+
async recordHitlDecision(actionInvocationId, tenantId, spaceId, decision) {
|
|
845
|
+
const result = await this.sql.query(
|
|
846
|
+
`UPDATE fabric_platform.action_invocations SET
|
|
847
|
+
hitl_route=$4, hitl_risk_tier=$5, hitl_reason=$6,
|
|
848
|
+
hitl_policy_version=$7, updated_at=$8
|
|
849
|
+
WHERE id=$1 AND tenant_id=$2 AND space_id=$3
|
|
850
|
+
RETURNING *`,
|
|
851
|
+
[
|
|
852
|
+
actionInvocationId,
|
|
853
|
+
tenantId,
|
|
854
|
+
spaceId,
|
|
855
|
+
decision.route,
|
|
856
|
+
decision.riskTier,
|
|
857
|
+
decision.reason,
|
|
858
|
+
decision.policyVersion ?? null,
|
|
859
|
+
decision.evaluatedAt
|
|
860
|
+
]
|
|
861
|
+
);
|
|
862
|
+
const row = result.rows[0];
|
|
863
|
+
if (!row) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
|
|
864
|
+
return toActionRecord(row);
|
|
865
|
+
}
|
|
866
|
+
async beginApprovalDecision(input) {
|
|
867
|
+
const result = await this.sql.query(
|
|
868
|
+
`UPDATE fabric_platform.action_invocations SET
|
|
869
|
+
approval_decision=$4::jsonb,
|
|
870
|
+
status=CASE WHEN $5::boolean THEN 'running' ELSE 'failed' END,
|
|
871
|
+
error=CASE WHEN $5::boolean THEN NULL ELSE $6 END,
|
|
872
|
+
lease_owner=CASE WHEN $5::boolean THEN $7 ELSE NULL END,
|
|
873
|
+
lease_expires_at=CASE WHEN $5::boolean THEN $8 ELSE NULL END,
|
|
874
|
+
attempt_count=CASE WHEN $5::boolean THEN GREATEST(attempt_count,1) ELSE attempt_count END,
|
|
875
|
+
updated_at=$9
|
|
876
|
+
WHERE id=$1 AND tenant_id=$2 AND space_id=$3 AND status='waiting_for_approval'
|
|
877
|
+
RETURNING *`,
|
|
878
|
+
[
|
|
879
|
+
input.actionInvocationId,
|
|
880
|
+
input.tenantId,
|
|
881
|
+
input.spaceId,
|
|
882
|
+
JSON.stringify({ ...input.decision, decidedAt: input.decision.decidedAt.toISOString() }),
|
|
883
|
+
input.decision.approved,
|
|
884
|
+
`Approval rejected: ${input.decision.reason ?? "no reason provided"}`,
|
|
885
|
+
input.leaseOwner,
|
|
886
|
+
new Date(input.now.getTime() + input.leaseDurationMs),
|
|
887
|
+
input.now
|
|
888
|
+
]
|
|
889
|
+
);
|
|
890
|
+
return result.rows[0] ? { applied: true, invocation: toActionRecord(result.rows[0]) } : { applied: false };
|
|
891
|
+
}
|
|
634
892
|
async appendPolicyEvaluation(record) {
|
|
635
893
|
await this.sql.query(
|
|
636
894
|
`INSERT INTO fabric_platform.policy_evaluations
|
|
@@ -838,11 +1096,27 @@ function toActionRecord(row) {
|
|
|
838
1096
|
attemptCount: Number(row.attempt_count ?? 0),
|
|
839
1097
|
...row.lease_owner ? { leaseOwner: String(row.lease_owner) } : {},
|
|
840
1098
|
...row.lease_expires_at ? { leaseExpiresAt: new Date(row.lease_expires_at) } : {},
|
|
1099
|
+
...row.hitl_route ? { hitlRoute: String(row.hitl_route) } : {},
|
|
1100
|
+
...row.hitl_risk_tier ? { hitlRiskTier: String(row.hitl_risk_tier) } : {},
|
|
1101
|
+
...row.hitl_reason ? { hitlReason: String(row.hitl_reason) } : {},
|
|
1102
|
+
...row.hitl_policy_version ? { hitlPolicyVersion: String(row.hitl_policy_version) } : {},
|
|
1103
|
+
...row.approval_decision ? { approvalDecision: toApprovalDecision(row.approval_decision) } : {},
|
|
841
1104
|
...row.error ? { error: String(row.error) } : {},
|
|
842
1105
|
createdAt: new Date(row.created_at),
|
|
843
1106
|
updatedAt: new Date(row.updated_at)
|
|
844
1107
|
};
|
|
845
1108
|
}
|
|
1109
|
+
function toApprovalDecision(value) {
|
|
1110
|
+
const decision = value;
|
|
1111
|
+
return {
|
|
1112
|
+
approved: Boolean(decision.approved),
|
|
1113
|
+
approverId: String(decision.approverId),
|
|
1114
|
+
approverType: String(decision.approverType),
|
|
1115
|
+
...decision.reason ? { reason: String(decision.reason) } : {},
|
|
1116
|
+
...decision.editsReference ? { editsReference: String(decision.editsReference) } : {},
|
|
1117
|
+
decidedAt: new Date(decision.decidedAt)
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
846
1120
|
function toEventRecord(row) {
|
|
847
1121
|
return {
|
|
848
1122
|
id: String(row.id),
|
|
@@ -883,21 +1157,29 @@ async function runPlatformActionWorkerCycle(options) {
|
|
|
883
1157
|
});
|
|
884
1158
|
let completed = 0;
|
|
885
1159
|
let failed = 0;
|
|
1160
|
+
let waitingForApproval = 0;
|
|
886
1161
|
for (const invocation of claimed) {
|
|
887
1162
|
try {
|
|
888
1163
|
const result = await options.host.executeInvocation(
|
|
889
1164
|
invocation.id,
|
|
890
1165
|
invocation.tenantId,
|
|
891
|
-
invocation.spaceId
|
|
1166
|
+
invocation.spaceId,
|
|
1167
|
+
{ leaseOwner: options.workerId }
|
|
892
1168
|
);
|
|
893
1169
|
if (result.status === "completed") completed += 1;
|
|
1170
|
+
else if (result.status === "waiting_for_approval") waitingForApproval += 1;
|
|
894
1171
|
else failed += 1;
|
|
895
1172
|
} catch (error) {
|
|
896
1173
|
failed += 1;
|
|
897
1174
|
options.onError?.(error, invocation);
|
|
898
1175
|
}
|
|
899
1176
|
}
|
|
900
|
-
return {
|
|
1177
|
+
return {
|
|
1178
|
+
claimed: claimed.length,
|
|
1179
|
+
completed,
|
|
1180
|
+
failed,
|
|
1181
|
+
...waitingForApproval > 0 ? { waitingForApproval } : {}
|
|
1182
|
+
};
|
|
901
1183
|
}
|
|
902
1184
|
async function runPlatformActionWorker(options) {
|
|
903
1185
|
while (!options.signal?.aborted) {
|