@fabricorg/platform-host 0.3.1 → 0.4.0
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 +8 -0
- package/README.md +47 -2
- package/dist/index.cjs +298 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +71 -5
- package/dist/index.d.ts +71 -5
- package/dist/index.js +298 -22
- package/dist/index.js.map +1 -1
- package/package.json +66 -62
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# @fabricorg/platform-host
|
|
2
2
|
|
|
3
|
+
## 0.4.0 — 2026-07-19
|
|
4
|
+
|
|
5
|
+
- Add an optional agent-only HITL evaluator between schema validation and ordinary policies.
|
|
6
|
+
- Persist HITL route, risk tier, reason, and ruleset version as durable invocation evidence.
|
|
7
|
+
- Add authorized, atomic approval and rejection decisions with governed resume execution.
|
|
8
|
+
- Keep parked invocations out of worker claims and report worker-parked work separately from failures.
|
|
9
|
+
- Preserve existing behavior for hosts that do not configure HITL.
|
|
10
|
+
|
|
3
11
|
## 0.3.1 — 2026-07-19
|
|
4
12
|
|
|
5
13
|
- 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.0
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
Vertical packages register `FabricModule` definitions. Host applications provide tenant authorization,
|
|
@@ -11,7 +11,7 @@ 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
|
|
@@ -29,6 +29,51 @@ adapter that already reached `succeeded`, and event, policy, and adapter writes
|
|
|
29
29
|
stale action that was not declared idempotent fails terminally for manual reconciliation instead of
|
|
30
30
|
silently rerunning unknown side effects.
|
|
31
31
|
|
|
32
|
+
## Agent HITL
|
|
33
|
+
|
|
34
|
+
Hosts can inject a vertical-owned `hitlEvaluator`. It runs only for `actorType: "agent"`, after schema
|
|
35
|
+
validation and before ordinary policies. Omitting it preserves the pre-0.4 behavior. The host owns the
|
|
36
|
+
durable lifecycle; the vertical continues to own the rules and risk classification.
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
const host = createGovernedActionHost({
|
|
40
|
+
store,
|
|
41
|
+
authorization,
|
|
42
|
+
hitlPolicyVersion: "gtm-rules.v7",
|
|
43
|
+
hitlEvaluator: async (context) => ({
|
|
44
|
+
route: context.actionId === "gtm.send_message" ? "needs-approval" : "auto-execute",
|
|
45
|
+
riskTier: context.actionId === "gtm.send_message" ? "high" : "low",
|
|
46
|
+
reason: "Vertical-owned prospect-touching rule",
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The evaluator returns `auto-execute`, `needs-approval`, `escalate`, or `rejected`:
|
|
52
|
+
|
|
53
|
+
- `auto-execute` continues through policies, state validation, the handler, events, and adapters.
|
|
54
|
+
- `needs-approval` and `escalate` persist the route and risk evidence, clear any worker lease, and park
|
|
55
|
+
the invocation as `waiting_for_approval`. Polling workers never claim that status.
|
|
56
|
+
- `rejected` terminally fails before policies and mutation code run.
|
|
57
|
+
|
|
58
|
+
An approval workflow calls `resumeApprovedInvocation()` instead of invoking the handler directly. The
|
|
59
|
+
host authorizes the approver (using `authorizeApproval` when provided), then atomically persists the
|
|
60
|
+
decision and changes `waiting_for_approval` to either leased `running` or terminal `failed`. This keeps
|
|
61
|
+
the decision in the mutation ledger and prevents an approval/worker race.
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
await host.resumeApprovedInvocation(actionInvocationId, tenantId, spaceId, {
|
|
65
|
+
approved: true,
|
|
66
|
+
approverId: reviewer.id,
|
|
67
|
+
approverType: "natural_person",
|
|
68
|
+
reason: "Reviewed prospect-facing draft",
|
|
69
|
+
editsReference: "draft-revision-2",
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Custom stores remain source-compatible when HITL is unused. To enable HITL they must implement the
|
|
74
|
+
additive `ApprovalPlatformHostStore` capability. Both built-in stores implement it; Postgres users must
|
|
75
|
+
call `ensureSchema()` so the HITL evidence and approval-decision columns are added.
|
|
76
|
+
|
|
32
77
|
Sensitive values must not be passed as action parameters. Stage them in tenant-bound encrypted storage
|
|
33
78
|
and pass an opaque identifier instead; action parameters are intentionally durable audit evidence.
|
|
34
79
|
Hosts should additionally configure `redactActionParameters` as a fail-safe allowlist for actions
|
package/dist/index.cjs
CHANGED
|
@@ -47,7 +47,9 @@ function createGovernedActionHost(options) {
|
|
|
47
47
|
status: durableInvocation.status,
|
|
48
48
|
workflowId: durableWorkflowId,
|
|
49
49
|
result: durableInvocation.result,
|
|
50
|
-
...durableInvocation.error ? { error: durableInvocation.error } : {}
|
|
50
|
+
...durableInvocation.error ? { error: durableInvocation.error } : {},
|
|
51
|
+
...durableInvocation.hitlRoute ? { hitlRoute: durableInvocation.hitlRoute } : {},
|
|
52
|
+
...durableInvocation.hitlRiskTier ? { hitlRiskTier: durableInvocation.hitlRiskTier } : {}
|
|
51
53
|
};
|
|
52
54
|
}
|
|
53
55
|
if (options.dispatcher) {
|
|
@@ -62,7 +64,9 @@ function createGovernedActionHost(options) {
|
|
|
62
64
|
actionInvocationId: durableInvocation.id,
|
|
63
65
|
status: durableInvocation.status,
|
|
64
66
|
workflowId: dispatched.workflowId,
|
|
65
|
-
...dispatched.runId ? { runId: dispatched.runId } : {}
|
|
67
|
+
...dispatched.runId ? { runId: dispatched.runId } : {},
|
|
68
|
+
...durableInvocation.hitlRoute ? { hitlRoute: durableInvocation.hitlRoute } : {},
|
|
69
|
+
...durableInvocation.hitlRiskTier ? { hitlRiskTier: durableInvocation.hitlRiskTier } : {}
|
|
66
70
|
};
|
|
67
71
|
} catch (error) {
|
|
68
72
|
const message = errorMessage(error);
|
|
@@ -82,19 +86,23 @@ function createGovernedActionHost(options) {
|
|
|
82
86
|
);
|
|
83
87
|
return { ...executed, workflowId: durableWorkflowId };
|
|
84
88
|
}
|
|
85
|
-
async function executeInvocation(actionInvocationId, tenantId, spaceId) {
|
|
86
|
-
const
|
|
89
|
+
async function executeInvocation(actionInvocationId, tenantId, spaceId, executionOptions = {}) {
|
|
90
|
+
const loadedInvocation = await options.store.getActionInvocation(
|
|
87
91
|
actionInvocationId,
|
|
88
92
|
tenantId,
|
|
89
93
|
spaceId
|
|
90
94
|
);
|
|
91
|
-
if (!
|
|
92
|
-
|
|
95
|
+
if (!loadedInvocation) {
|
|
96
|
+
throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
|
|
97
|
+
}
|
|
98
|
+
let invocation = loadedInvocation;
|
|
99
|
+
if (isTerminal(invocation.status) || invocation.status === "waiting_for_approval") {
|
|
100
|
+
return actionResult(invocation);
|
|
101
|
+
}
|
|
102
|
+
if (invocation.status === "running" && invocation.leaseOwner && executionOptions.leaseOwner !== invocation.leaseOwner) {
|
|
93
103
|
return {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
result: invocation.result,
|
|
97
|
-
...invocation.error ? { error: invocation.error } : {}
|
|
104
|
+
...actionResult(invocation),
|
|
105
|
+
error: `Invocation is leased by ${invocation.leaseOwner}`
|
|
98
106
|
};
|
|
99
107
|
}
|
|
100
108
|
const action = platform.resolveAction(invocation.actionId);
|
|
@@ -108,15 +116,68 @@ function createGovernedActionHost(options) {
|
|
|
108
116
|
`Interrupted action ${invocation.actionId} is not declared idempotent; manual reconciliation is required.`
|
|
109
117
|
);
|
|
110
118
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
119
|
+
if (invocation.status !== "running") {
|
|
120
|
+
await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
|
|
121
|
+
status: "running"
|
|
122
|
+
});
|
|
123
|
+
}
|
|
114
124
|
try {
|
|
115
125
|
const parsed = action.schema.safeParse(invocation.parameters);
|
|
116
126
|
if (!parsed.success) {
|
|
117
127
|
const message = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
|
|
118
128
|
return fail(invocation, "validation_failed", message);
|
|
119
129
|
}
|
|
130
|
+
if (invocation.actorType === "agent" && options.hitlEvaluator) {
|
|
131
|
+
const hitlParameters = isRecord(parsed.data) ? parsed.data : invocation.parameters;
|
|
132
|
+
const approvalStore = asApprovalStore(options.store);
|
|
133
|
+
if (!approvalStore) {
|
|
134
|
+
return fail(
|
|
135
|
+
invocation,
|
|
136
|
+
"failed",
|
|
137
|
+
"The configured HITL evaluator requires a store with approval persistence support."
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (!invocation.hitlRoute) {
|
|
141
|
+
const decision = await options.hitlEvaluator({
|
|
142
|
+
actionId: action.actionId,
|
|
143
|
+
actorId: invocation.actorId,
|
|
144
|
+
actorType: invocation.actorType,
|
|
145
|
+
tenantId,
|
|
146
|
+
spaceId,
|
|
147
|
+
parameters: hitlParameters,
|
|
148
|
+
...numberValue(hitlParameters.confidence) !== void 0 ? { confidence: numberValue(hitlParameters.confidence) } : {},
|
|
149
|
+
...isRiskTier(hitlParameters.riskTier) ? { riskTier: hitlParameters.riskTier } : {},
|
|
150
|
+
...stringValue(hitlParameters.agentSessionId) ? { agentSessionId: stringValue(hitlParameters.agentSessionId) } : {},
|
|
151
|
+
...stringValue(hitlParameters.agentRunId) ? { agentRunId: stringValue(hitlParameters.agentRunId) } : {}
|
|
152
|
+
});
|
|
153
|
+
invocation = await approvalStore.recordHitlDecision(
|
|
154
|
+
actionInvocationId,
|
|
155
|
+
tenantId,
|
|
156
|
+
spaceId,
|
|
157
|
+
{
|
|
158
|
+
...decision,
|
|
159
|
+
...options.hitlPolicyVersion ? { policyVersion: options.hitlPolicyVersion } : {},
|
|
160
|
+
evaluatedAt: now()
|
|
161
|
+
}
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (invocation.hitlRoute === "rejected") {
|
|
165
|
+
return fail(
|
|
166
|
+
invocation,
|
|
167
|
+
"failed",
|
|
168
|
+
`HITL rejected: ${invocation.hitlReason ?? "no reason provided"}`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if ((invocation.hitlRoute === "needs-approval" || invocation.hitlRoute === "escalate") && !invocation.approvalDecision?.approved) {
|
|
172
|
+
await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
|
|
173
|
+
status: "waiting_for_approval"
|
|
174
|
+
});
|
|
175
|
+
return {
|
|
176
|
+
...actionResult(invocation),
|
|
177
|
+
status: "waiting_for_approval"
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
120
181
|
const authorizationInput = toAuthorizationInput(action, invocation);
|
|
121
182
|
const definitions = options.resolvePolicies ? await options.resolvePolicies({
|
|
122
183
|
...authorizationInput,
|
|
@@ -329,11 +390,80 @@ function createGovernedActionHost(options) {
|
|
|
329
390
|
status: "completed",
|
|
330
391
|
result
|
|
331
392
|
});
|
|
332
|
-
return {
|
|
393
|
+
return {
|
|
394
|
+
actionInvocationId,
|
|
395
|
+
status: "completed",
|
|
396
|
+
result,
|
|
397
|
+
...invocation.hitlRoute ? { hitlRoute: invocation.hitlRoute } : {},
|
|
398
|
+
...invocation.hitlRiskTier ? { hitlRiskTier: invocation.hitlRiskTier } : {}
|
|
399
|
+
};
|
|
333
400
|
} catch (error) {
|
|
334
401
|
return fail(invocation, "failed", errorMessage(error));
|
|
335
402
|
}
|
|
336
403
|
}
|
|
404
|
+
async function resumeApprovedInvocation(actionInvocationId, tenantId, spaceId, decision) {
|
|
405
|
+
if (!decision.approverId.trim()) throw new Error("approverId is required");
|
|
406
|
+
const invocation = await options.store.getActionInvocation(
|
|
407
|
+
actionInvocationId,
|
|
408
|
+
tenantId,
|
|
409
|
+
spaceId
|
|
410
|
+
);
|
|
411
|
+
if (!invocation) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
|
|
412
|
+
if (isTerminal(invocation.status)) return actionResult(invocation);
|
|
413
|
+
if (invocation.status !== "waiting_for_approval") {
|
|
414
|
+
return {
|
|
415
|
+
...actionResult(invocation),
|
|
416
|
+
error: `Invocation is not waiting for approval (status: ${invocation.status})`
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
const action = platform.resolveAction(invocation.actionId);
|
|
420
|
+
if (!action) return fail(invocation, "failed", `Unknown action: ${invocation.actionId}`);
|
|
421
|
+
const approvalAuthorizationInput = toAuthorizationInput(action, {
|
|
422
|
+
tenantId,
|
|
423
|
+
spaceId,
|
|
424
|
+
actorId: decision.approverId,
|
|
425
|
+
actorType: decision.approverType
|
|
426
|
+
});
|
|
427
|
+
const entitled = await options.authorization.checkEntitlement(
|
|
428
|
+
approvalAuthorizationInput
|
|
429
|
+
);
|
|
430
|
+
const authorized = options.authorization.authorizeApproval ? await options.authorization.authorizeApproval({
|
|
431
|
+
...approvalAuthorizationInput,
|
|
432
|
+
decision
|
|
433
|
+
}) : await options.authorization.authorize(approvalAuthorizationInput);
|
|
434
|
+
if (!entitled || !authorized) {
|
|
435
|
+
return {
|
|
436
|
+
...actionResult(invocation),
|
|
437
|
+
error: `Actor ${decision.approverId} is not authorized to decide this approval`
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
const approvalStore = asApprovalStore(options.store);
|
|
441
|
+
if (!approvalStore) {
|
|
442
|
+
return {
|
|
443
|
+
...actionResult(invocation),
|
|
444
|
+
error: "Approval resume requires a store with approval persistence support."
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
const transitionStartedAt = now();
|
|
448
|
+
const decidedAt = decision.decidedAt ?? transitionStartedAt;
|
|
449
|
+
const leaseOwner = `approval-resume-${platform.createFabricId("lease")}`;
|
|
450
|
+
const transition = await approvalStore.beginApprovalDecision({
|
|
451
|
+
actionInvocationId,
|
|
452
|
+
tenantId,
|
|
453
|
+
spaceId,
|
|
454
|
+
decision: { ...decision, decidedAt },
|
|
455
|
+
leaseOwner,
|
|
456
|
+
leaseDurationMs: options.approvalResumeLeaseDurationMs ?? 5 * 6e4,
|
|
457
|
+
now: transitionStartedAt
|
|
458
|
+
});
|
|
459
|
+
const transitioned = transition.invocation ?? await options.store.getActionInvocation(actionInvocationId, tenantId, spaceId);
|
|
460
|
+
if (!transition.applied || !transitioned) {
|
|
461
|
+
if (!transitioned) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
|
|
462
|
+
return actionResult(transitioned);
|
|
463
|
+
}
|
|
464
|
+
if (!decision.approved) return actionResult(transitioned);
|
|
465
|
+
return executeInvocation(actionInvocationId, tenantId, spaceId, { leaseOwner });
|
|
466
|
+
}
|
|
337
467
|
async function appendEvent(invocation, event, deduplicationKey) {
|
|
338
468
|
const timestamp = now();
|
|
339
469
|
const envelope = {
|
|
@@ -366,9 +496,38 @@ function createGovernedActionHost(options) {
|
|
|
366
496
|
invocation.spaceId,
|
|
367
497
|
{ status, error }
|
|
368
498
|
);
|
|
369
|
-
return {
|
|
499
|
+
return {
|
|
500
|
+
actionInvocationId: invocation.id,
|
|
501
|
+
status,
|
|
502
|
+
error,
|
|
503
|
+
...invocation.hitlRoute ? { hitlRoute: invocation.hitlRoute } : {},
|
|
504
|
+
...invocation.hitlRiskTier ? { hitlRiskTier: invocation.hitlRiskTier } : {}
|
|
505
|
+
};
|
|
370
506
|
}
|
|
371
|
-
return { submitAction, executeInvocation };
|
|
507
|
+
return { submitAction, executeInvocation, resumeApprovedInvocation };
|
|
508
|
+
}
|
|
509
|
+
function actionResult(invocation) {
|
|
510
|
+
return {
|
|
511
|
+
actionInvocationId: invocation.id,
|
|
512
|
+
status: invocation.status,
|
|
513
|
+
result: invocation.result,
|
|
514
|
+
...invocation.error ? { error: invocation.error } : {},
|
|
515
|
+
...invocation.hitlRoute ? { hitlRoute: invocation.hitlRoute } : {},
|
|
516
|
+
...invocation.hitlRiskTier ? { hitlRiskTier: invocation.hitlRiskTier } : {}
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
function asApprovalStore(store) {
|
|
520
|
+
const candidate = store;
|
|
521
|
+
return typeof candidate.recordHitlDecision === "function" && typeof candidate.beginApprovalDecision === "function" ? store : void 0;
|
|
522
|
+
}
|
|
523
|
+
function numberValue(value) {
|
|
524
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
525
|
+
}
|
|
526
|
+
function stringValue(value) {
|
|
527
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
528
|
+
}
|
|
529
|
+
function isRiskTier(value) {
|
|
530
|
+
return value === "low" || value === "medium" || value === "high";
|
|
372
531
|
}
|
|
373
532
|
function toAuthorizationInput(action, input) {
|
|
374
533
|
return {
|
|
@@ -446,6 +605,45 @@ var MemoryPlatformHostStore = class {
|
|
|
446
605
|
const record = await this.getActionInvocation(id, tenantId, spaceId);
|
|
447
606
|
if (!record) throw new Error(`ActionInvocation not found: ${id}`);
|
|
448
607
|
Object.assign(record, patch, { updatedAt: /* @__PURE__ */ new Date() });
|
|
608
|
+
if (patch.status === "waiting_for_approval" || patch.status === "completed" || patch.status === "failed" || patch.status === "blocked_by_policy" || patch.status === "validation_failed") {
|
|
609
|
+
delete record.leaseOwner;
|
|
610
|
+
delete record.leaseExpiresAt;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
async recordHitlDecision(actionInvocationId, tenantId, spaceId, decision) {
|
|
614
|
+
const record = await this.getActionInvocation(actionInvocationId, tenantId, spaceId);
|
|
615
|
+
if (!record) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
|
|
616
|
+
record.hitlRoute = decision.route;
|
|
617
|
+
record.hitlRiskTier = decision.riskTier;
|
|
618
|
+
record.hitlReason = decision.reason;
|
|
619
|
+
if (decision.policyVersion) record.hitlPolicyVersion = decision.policyVersion;
|
|
620
|
+
record.updatedAt = decision.evaluatedAt;
|
|
621
|
+
return record;
|
|
622
|
+
}
|
|
623
|
+
async beginApprovalDecision(input) {
|
|
624
|
+
const record = await this.getActionInvocation(
|
|
625
|
+
input.actionInvocationId,
|
|
626
|
+
input.tenantId,
|
|
627
|
+
input.spaceId
|
|
628
|
+
);
|
|
629
|
+
if (!record || record.status !== "waiting_for_approval") {
|
|
630
|
+
return { applied: false, ...record ? { invocation: record } : {} };
|
|
631
|
+
}
|
|
632
|
+
record.approvalDecision = input.decision;
|
|
633
|
+
record.updatedAt = input.now;
|
|
634
|
+
if (!input.decision.approved) {
|
|
635
|
+
record.status = "failed";
|
|
636
|
+
record.error = `Approval rejected: ${input.decision.reason ?? "no reason provided"}`;
|
|
637
|
+
delete record.leaseOwner;
|
|
638
|
+
delete record.leaseExpiresAt;
|
|
639
|
+
return { applied: true, invocation: record };
|
|
640
|
+
}
|
|
641
|
+
record.status = "running";
|
|
642
|
+
record.error = void 0;
|
|
643
|
+
record.leaseOwner = input.leaseOwner;
|
|
644
|
+
record.leaseExpiresAt = new Date(input.now.getTime() + input.leaseDurationMs);
|
|
645
|
+
record.attemptCount = Math.max(record.attemptCount, 1);
|
|
646
|
+
return { applied: true, invocation: record };
|
|
449
647
|
}
|
|
450
648
|
async appendPolicyEvaluation(record) {
|
|
451
649
|
if (this.policyEvaluations.some((candidate) => candidate.id === record.id)) return;
|
|
@@ -523,14 +721,20 @@ var PostgresPlatformHostStore = class {
|
|
|
523
721
|
parameters jsonb NOT NULL, result jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
524
722
|
correlation_id text NOT NULL, causation_id text, idempotency_key text, error text,
|
|
525
723
|
attempt_count integer NOT NULL DEFAULT 0, lease_owner text,
|
|
526
|
-
lease_expires_at timestamptz,
|
|
724
|
+
lease_expires_at timestamptz, hitl_route text, hitl_risk_tier text,
|
|
725
|
+
hitl_reason text, hitl_policy_version text, approval_decision jsonb,
|
|
527
726
|
created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL
|
|
528
727
|
);
|
|
529
728
|
ALTER TABLE fabric_platform.action_invocations
|
|
530
729
|
ADD COLUMN IF NOT EXISTS idempotency_key text,
|
|
531
730
|
ADD COLUMN IF NOT EXISTS attempt_count integer NOT NULL DEFAULT 0,
|
|
532
731
|
ADD COLUMN IF NOT EXISTS lease_owner text,
|
|
533
|
-
ADD COLUMN IF NOT EXISTS lease_expires_at timestamptz
|
|
732
|
+
ADD COLUMN IF NOT EXISTS lease_expires_at timestamptz,
|
|
733
|
+
ADD COLUMN IF NOT EXISTS hitl_route text,
|
|
734
|
+
ADD COLUMN IF NOT EXISTS hitl_risk_tier text,
|
|
735
|
+
ADD COLUMN IF NOT EXISTS hitl_reason text,
|
|
736
|
+
ADD COLUMN IF NOT EXISTS hitl_policy_version text,
|
|
737
|
+
ADD COLUMN IF NOT EXISTS approval_decision jsonb;
|
|
534
738
|
CREATE UNIQUE INDEX IF NOT EXISTS action_invocations_idempotency_idx
|
|
535
739
|
ON fabric_platform.action_invocations
|
|
536
740
|
(tenant_id, space_id, action_id, idempotency_key)
|
|
@@ -614,9 +818,9 @@ var PostgresPlatformHostStore = class {
|
|
|
614
818
|
`UPDATE fabric_platform.action_invocations SET
|
|
615
819
|
status=COALESCE($4,status), result=COALESCE($5::jsonb,result),
|
|
616
820
|
error=CASE WHEN $6::boolean THEN $7 ELSE error END,
|
|
617
|
-
lease_owner=CASE WHEN $4 IN ('completed','failed','blocked_by_policy','validation_failed')
|
|
821
|
+
lease_owner=CASE WHEN $4 IN ('waiting_for_approval','completed','failed','blocked_by_policy','validation_failed')
|
|
618
822
|
THEN NULL ELSE lease_owner END,
|
|
619
|
-
lease_expires_at=CASE WHEN $4 IN ('completed','failed','blocked_by_policy','validation_failed')
|
|
823
|
+
lease_expires_at=CASE WHEN $4 IN ('waiting_for_approval','completed','failed','blocked_by_policy','validation_failed')
|
|
620
824
|
THEN NULL ELSE lease_expires_at END,
|
|
621
825
|
updated_at=now()
|
|
622
826
|
WHERE id=$1 AND tenant_id=$2 AND space_id=$3`,
|
|
@@ -631,6 +835,54 @@ var PostgresPlatformHostStore = class {
|
|
|
631
835
|
]
|
|
632
836
|
);
|
|
633
837
|
}
|
|
838
|
+
async recordHitlDecision(actionInvocationId, tenantId, spaceId, decision) {
|
|
839
|
+
const result = await this.sql.query(
|
|
840
|
+
`UPDATE fabric_platform.action_invocations SET
|
|
841
|
+
hitl_route=$4, hitl_risk_tier=$5, hitl_reason=$6,
|
|
842
|
+
hitl_policy_version=$7, updated_at=$8
|
|
843
|
+
WHERE id=$1 AND tenant_id=$2 AND space_id=$3
|
|
844
|
+
RETURNING *`,
|
|
845
|
+
[
|
|
846
|
+
actionInvocationId,
|
|
847
|
+
tenantId,
|
|
848
|
+
spaceId,
|
|
849
|
+
decision.route,
|
|
850
|
+
decision.riskTier,
|
|
851
|
+
decision.reason,
|
|
852
|
+
decision.policyVersion ?? null,
|
|
853
|
+
decision.evaluatedAt
|
|
854
|
+
]
|
|
855
|
+
);
|
|
856
|
+
const row = result.rows[0];
|
|
857
|
+
if (!row) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
|
|
858
|
+
return toActionRecord(row);
|
|
859
|
+
}
|
|
860
|
+
async beginApprovalDecision(input) {
|
|
861
|
+
const result = await this.sql.query(
|
|
862
|
+
`UPDATE fabric_platform.action_invocations SET
|
|
863
|
+
approval_decision=$4::jsonb,
|
|
864
|
+
status=CASE WHEN $5::boolean THEN 'running' ELSE 'failed' END,
|
|
865
|
+
error=CASE WHEN $5::boolean THEN NULL ELSE $6 END,
|
|
866
|
+
lease_owner=CASE WHEN $5::boolean THEN $7 ELSE NULL END,
|
|
867
|
+
lease_expires_at=CASE WHEN $5::boolean THEN $8 ELSE NULL END,
|
|
868
|
+
attempt_count=CASE WHEN $5::boolean THEN GREATEST(attempt_count,1) ELSE attempt_count END,
|
|
869
|
+
updated_at=$9
|
|
870
|
+
WHERE id=$1 AND tenant_id=$2 AND space_id=$3 AND status='waiting_for_approval'
|
|
871
|
+
RETURNING *`,
|
|
872
|
+
[
|
|
873
|
+
input.actionInvocationId,
|
|
874
|
+
input.tenantId,
|
|
875
|
+
input.spaceId,
|
|
876
|
+
JSON.stringify({ ...input.decision, decidedAt: input.decision.decidedAt.toISOString() }),
|
|
877
|
+
input.decision.approved,
|
|
878
|
+
`Approval rejected: ${input.decision.reason ?? "no reason provided"}`,
|
|
879
|
+
input.leaseOwner,
|
|
880
|
+
new Date(input.now.getTime() + input.leaseDurationMs),
|
|
881
|
+
input.now
|
|
882
|
+
]
|
|
883
|
+
);
|
|
884
|
+
return result.rows[0] ? { applied: true, invocation: toActionRecord(result.rows[0]) } : { applied: false };
|
|
885
|
+
}
|
|
634
886
|
async appendPolicyEvaluation(record) {
|
|
635
887
|
await this.sql.query(
|
|
636
888
|
`INSERT INTO fabric_platform.policy_evaluations
|
|
@@ -838,11 +1090,27 @@ function toActionRecord(row) {
|
|
|
838
1090
|
attemptCount: Number(row.attempt_count ?? 0),
|
|
839
1091
|
...row.lease_owner ? { leaseOwner: String(row.lease_owner) } : {},
|
|
840
1092
|
...row.lease_expires_at ? { leaseExpiresAt: new Date(row.lease_expires_at) } : {},
|
|
1093
|
+
...row.hitl_route ? { hitlRoute: String(row.hitl_route) } : {},
|
|
1094
|
+
...row.hitl_risk_tier ? { hitlRiskTier: String(row.hitl_risk_tier) } : {},
|
|
1095
|
+
...row.hitl_reason ? { hitlReason: String(row.hitl_reason) } : {},
|
|
1096
|
+
...row.hitl_policy_version ? { hitlPolicyVersion: String(row.hitl_policy_version) } : {},
|
|
1097
|
+
...row.approval_decision ? { approvalDecision: toApprovalDecision(row.approval_decision) } : {},
|
|
841
1098
|
...row.error ? { error: String(row.error) } : {},
|
|
842
1099
|
createdAt: new Date(row.created_at),
|
|
843
1100
|
updatedAt: new Date(row.updated_at)
|
|
844
1101
|
};
|
|
845
1102
|
}
|
|
1103
|
+
function toApprovalDecision(value) {
|
|
1104
|
+
const decision = value;
|
|
1105
|
+
return {
|
|
1106
|
+
approved: Boolean(decision.approved),
|
|
1107
|
+
approverId: String(decision.approverId),
|
|
1108
|
+
approverType: String(decision.approverType),
|
|
1109
|
+
...decision.reason ? { reason: String(decision.reason) } : {},
|
|
1110
|
+
...decision.editsReference ? { editsReference: String(decision.editsReference) } : {},
|
|
1111
|
+
decidedAt: new Date(decision.decidedAt)
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
846
1114
|
function toEventRecord(row) {
|
|
847
1115
|
return {
|
|
848
1116
|
id: String(row.id),
|
|
@@ -883,21 +1151,29 @@ async function runPlatformActionWorkerCycle(options) {
|
|
|
883
1151
|
});
|
|
884
1152
|
let completed = 0;
|
|
885
1153
|
let failed = 0;
|
|
1154
|
+
let waitingForApproval = 0;
|
|
886
1155
|
for (const invocation of claimed) {
|
|
887
1156
|
try {
|
|
888
1157
|
const result = await options.host.executeInvocation(
|
|
889
1158
|
invocation.id,
|
|
890
1159
|
invocation.tenantId,
|
|
891
|
-
invocation.spaceId
|
|
1160
|
+
invocation.spaceId,
|
|
1161
|
+
{ leaseOwner: options.workerId }
|
|
892
1162
|
);
|
|
893
1163
|
if (result.status === "completed") completed += 1;
|
|
1164
|
+
else if (result.status === "waiting_for_approval") waitingForApproval += 1;
|
|
894
1165
|
else failed += 1;
|
|
895
1166
|
} catch (error) {
|
|
896
1167
|
failed += 1;
|
|
897
1168
|
options.onError?.(error, invocation);
|
|
898
1169
|
}
|
|
899
1170
|
}
|
|
900
|
-
return {
|
|
1171
|
+
return {
|
|
1172
|
+
claimed: claimed.length,
|
|
1173
|
+
completed,
|
|
1174
|
+
failed,
|
|
1175
|
+
...waitingForApproval > 0 ? { waitingForApproval } : {}
|
|
1176
|
+
};
|
|
901
1177
|
}
|
|
902
1178
|
async function runPlatformActionWorker(options) {
|
|
903
1179
|
while (!options.signal?.aborted) {
|