@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/dist/index.js
CHANGED
|
@@ -10,8 +10,10 @@ function createGovernedActionHost(options) {
|
|
|
10
10
|
for (const adapter of options.adapters ?? []) adapters.register(adapter);
|
|
11
11
|
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
12
12
|
const extractEvents = options.extractEvents ?? DEFAULT_EXTRACT_EVENTS;
|
|
13
|
+
const actionResolver = options.resolveAction ?? resolveAction;
|
|
14
|
+
const eventResultFields = options.eventResultFields ?? ["_events"];
|
|
13
15
|
async function submitAction(input) {
|
|
14
|
-
const action =
|
|
16
|
+
const action = actionResolver(input.actionId);
|
|
15
17
|
if (!action) throw new Error(`Unknown action: ${input.actionId}`);
|
|
16
18
|
const authorizationInput = toAuthorizationInput(action, input);
|
|
17
19
|
if (!await options.authorization.checkEntitlement(authorizationInput)) {
|
|
@@ -45,7 +47,9 @@ function createGovernedActionHost(options) {
|
|
|
45
47
|
status: durableInvocation.status,
|
|
46
48
|
workflowId: durableWorkflowId,
|
|
47
49
|
result: durableInvocation.result,
|
|
48
|
-
...durableInvocation.error ? { error: durableInvocation.error } : {}
|
|
50
|
+
...durableInvocation.error ? { error: durableInvocation.error } : {},
|
|
51
|
+
...durableInvocation.hitlRoute ? { hitlRoute: durableInvocation.hitlRoute } : {},
|
|
52
|
+
...durableInvocation.hitlRiskTier ? { hitlRiskTier: durableInvocation.hitlRiskTier } : {}
|
|
49
53
|
};
|
|
50
54
|
}
|
|
51
55
|
if (options.dispatcher) {
|
|
@@ -60,7 +64,9 @@ function createGovernedActionHost(options) {
|
|
|
60
64
|
actionInvocationId: durableInvocation.id,
|
|
61
65
|
status: durableInvocation.status,
|
|
62
66
|
workflowId: dispatched.workflowId,
|
|
63
|
-
...dispatched.runId ? { runId: dispatched.runId } : {}
|
|
67
|
+
...dispatched.runId ? { runId: dispatched.runId } : {},
|
|
68
|
+
...durableInvocation.hitlRoute ? { hitlRoute: durableInvocation.hitlRoute } : {},
|
|
69
|
+
...durableInvocation.hitlRiskTier ? { hitlRiskTier: durableInvocation.hitlRiskTier } : {}
|
|
64
70
|
};
|
|
65
71
|
} catch (error) {
|
|
66
72
|
const message = errorMessage(error);
|
|
@@ -80,22 +86,26 @@ function createGovernedActionHost(options) {
|
|
|
80
86
|
);
|
|
81
87
|
return { ...executed, workflowId: durableWorkflowId };
|
|
82
88
|
}
|
|
83
|
-
async function executeInvocation(actionInvocationId, tenantId, spaceId) {
|
|
84
|
-
const
|
|
89
|
+
async function executeInvocation(actionInvocationId, tenantId, spaceId, executionOptions = {}) {
|
|
90
|
+
const loadedInvocation = await options.store.getActionInvocation(
|
|
85
91
|
actionInvocationId,
|
|
86
92
|
tenantId,
|
|
87
93
|
spaceId
|
|
88
94
|
);
|
|
89
|
-
if (!
|
|
90
|
-
|
|
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) {
|
|
91
103
|
return {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
result: invocation.result,
|
|
95
|
-
...invocation.error ? { error: invocation.error } : {}
|
|
104
|
+
...actionResult(invocation),
|
|
105
|
+
error: `Invocation is leased by ${invocation.leaseOwner}`
|
|
96
106
|
};
|
|
97
107
|
}
|
|
98
|
-
const action =
|
|
108
|
+
const action = actionResolver(invocation.actionId);
|
|
99
109
|
if (!action) {
|
|
100
110
|
return fail(invocation, "failed", `Unknown action: ${invocation.actionId}`);
|
|
101
111
|
}
|
|
@@ -106,15 +116,68 @@ function createGovernedActionHost(options) {
|
|
|
106
116
|
`Interrupted action ${invocation.actionId} is not declared idempotent; manual reconciliation is required.`
|
|
107
117
|
);
|
|
108
118
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
119
|
+
if (invocation.status !== "running") {
|
|
120
|
+
await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
|
|
121
|
+
status: "running"
|
|
122
|
+
});
|
|
123
|
+
}
|
|
112
124
|
try {
|
|
113
125
|
const parsed = action.schema.safeParse(invocation.parameters);
|
|
114
126
|
if (!parsed.success) {
|
|
115
127
|
const message = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
|
|
116
128
|
return fail(invocation, "validation_failed", message);
|
|
117
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
|
+
}
|
|
118
181
|
const authorizationInput = toAuthorizationInput(action, invocation);
|
|
119
182
|
const definitions = options.resolvePolicies ? await options.resolvePolicies({
|
|
120
183
|
...authorizationInput,
|
|
@@ -130,7 +193,8 @@ function createGovernedActionHost(options) {
|
|
|
130
193
|
parameters: parsed.data,
|
|
131
194
|
db: options.store.db,
|
|
132
195
|
services: options.services,
|
|
133
|
-
mode: "execute"
|
|
196
|
+
mode: "execute",
|
|
197
|
+
now: now()
|
|
134
198
|
});
|
|
135
199
|
for (const outcome of outcomes) {
|
|
136
200
|
await options.store.appendPolicyEvaluation({
|
|
@@ -163,15 +227,17 @@ function createGovernedActionHost(options) {
|
|
|
163
227
|
entityId
|
|
164
228
|
) ?? initialState(binding.entityType) : initialState(binding.entityType);
|
|
165
229
|
const targetState = typeof binding.targetState === "function" ? binding.targetState(parsed.data) : binding.targetState;
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
230
|
+
if (targetState !== "") {
|
|
231
|
+
const transition = validateTransition(
|
|
232
|
+
binding.entityType,
|
|
233
|
+
currentState,
|
|
234
|
+
targetState,
|
|
235
|
+
action.actionId
|
|
236
|
+
);
|
|
237
|
+
const replayingAppliedTransition = action.idempotent && currentState === targetState;
|
|
238
|
+
if (!transition.valid && !replayingAppliedTransition) {
|
|
239
|
+
return fail(invocation, "failed", transition.error ?? "Invalid state transition");
|
|
240
|
+
}
|
|
175
241
|
}
|
|
176
242
|
}
|
|
177
243
|
let data;
|
|
@@ -200,7 +266,7 @@ function createGovernedActionHost(options) {
|
|
|
200
266
|
domainEvents = extractEvents(data);
|
|
201
267
|
if (action.eventPhase !== "after_adapters") {
|
|
202
268
|
for (const [index, event] of domainEvents.entries()) {
|
|
203
|
-
await appendEvent(invocation, event, `domain:${index}
|
|
269
|
+
await appendEvent(invocation, event, `domain:${index}`, action.version);
|
|
204
270
|
}
|
|
205
271
|
}
|
|
206
272
|
} catch (error) {
|
|
@@ -319,27 +385,96 @@ function createGovernedActionHost(options) {
|
|
|
319
385
|
}
|
|
320
386
|
if (action.eventPhase === "after_adapters") {
|
|
321
387
|
for (const [index, event] of domainEvents.entries()) {
|
|
322
|
-
await appendEvent(invocation, event, `domain:${index}
|
|
388
|
+
await appendEvent(invocation, event, `domain:${index}`, action.version);
|
|
323
389
|
}
|
|
324
390
|
}
|
|
325
|
-
const result = withoutPrivateHostFields(data);
|
|
391
|
+
const result = withoutPrivateHostFields(data, eventResultFields);
|
|
326
392
|
await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
|
|
327
393
|
status: "completed",
|
|
328
394
|
result
|
|
329
395
|
});
|
|
330
|
-
return {
|
|
396
|
+
return {
|
|
397
|
+
actionInvocationId,
|
|
398
|
+
status: "completed",
|
|
399
|
+
result,
|
|
400
|
+
...invocation.hitlRoute ? { hitlRoute: invocation.hitlRoute } : {},
|
|
401
|
+
...invocation.hitlRiskTier ? { hitlRiskTier: invocation.hitlRiskTier } : {}
|
|
402
|
+
};
|
|
331
403
|
} catch (error) {
|
|
332
404
|
return fail(invocation, "failed", errorMessage(error));
|
|
333
405
|
}
|
|
334
406
|
}
|
|
335
|
-
async function
|
|
407
|
+
async function resumeApprovedInvocation(actionInvocationId, tenantId, spaceId, decision) {
|
|
408
|
+
if (!decision.approverId.trim()) throw new Error("approverId is required");
|
|
409
|
+
const invocation = await options.store.getActionInvocation(
|
|
410
|
+
actionInvocationId,
|
|
411
|
+
tenantId,
|
|
412
|
+
spaceId
|
|
413
|
+
);
|
|
414
|
+
if (!invocation) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
|
|
415
|
+
if (isTerminal(invocation.status)) return actionResult(invocation);
|
|
416
|
+
if (invocation.status !== "waiting_for_approval") {
|
|
417
|
+
return {
|
|
418
|
+
...actionResult(invocation),
|
|
419
|
+
error: `Invocation is not waiting for approval (status: ${invocation.status})`
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
const action = actionResolver(invocation.actionId);
|
|
423
|
+
if (!action) return fail(invocation, "failed", `Unknown action: ${invocation.actionId}`);
|
|
424
|
+
const approvalAuthorizationInput = toAuthorizationInput(action, {
|
|
425
|
+
tenantId,
|
|
426
|
+
spaceId,
|
|
427
|
+
actorId: decision.approverId,
|
|
428
|
+
actorType: decision.approverType
|
|
429
|
+
});
|
|
430
|
+
const entitled = await options.authorization.checkEntitlement(
|
|
431
|
+
approvalAuthorizationInput
|
|
432
|
+
);
|
|
433
|
+
const authorized = options.authorization.authorizeApproval ? await options.authorization.authorizeApproval({
|
|
434
|
+
...approvalAuthorizationInput,
|
|
435
|
+
decision
|
|
436
|
+
}) : await options.authorization.authorize(approvalAuthorizationInput);
|
|
437
|
+
if (!entitled || !authorized) {
|
|
438
|
+
return {
|
|
439
|
+
...actionResult(invocation),
|
|
440
|
+
error: `Actor ${decision.approverId} is not authorized to decide this approval`
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
const approvalStore = asApprovalStore(options.store);
|
|
444
|
+
if (!approvalStore) {
|
|
445
|
+
return {
|
|
446
|
+
...actionResult(invocation),
|
|
447
|
+
error: "Approval resume requires a store with approval persistence support."
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
const transitionStartedAt = now();
|
|
451
|
+
const decidedAt = decision.decidedAt ?? transitionStartedAt;
|
|
452
|
+
const leaseOwner = `approval-resume-${createFabricId("lease")}`;
|
|
453
|
+
const transition = await approvalStore.beginApprovalDecision({
|
|
454
|
+
actionInvocationId,
|
|
455
|
+
tenantId,
|
|
456
|
+
spaceId,
|
|
457
|
+
decision: { ...decision, decidedAt },
|
|
458
|
+
leaseOwner,
|
|
459
|
+
leaseDurationMs: options.approvalResumeLeaseDurationMs ?? 5 * 6e4,
|
|
460
|
+
now: transitionStartedAt
|
|
461
|
+
});
|
|
462
|
+
const transitioned = transition.invocation ?? await options.store.getActionInvocation(actionInvocationId, tenantId, spaceId);
|
|
463
|
+
if (!transition.applied || !transitioned) {
|
|
464
|
+
if (!transitioned) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
|
|
465
|
+
return actionResult(transitioned);
|
|
466
|
+
}
|
|
467
|
+
if (!decision.approved) return actionResult(transitioned);
|
|
468
|
+
return executeInvocation(actionInvocationId, tenantId, spaceId, { leaseOwner });
|
|
469
|
+
}
|
|
470
|
+
async function appendEvent(invocation, event, deduplicationKey, defaultEventSchemaVersion = 1) {
|
|
336
471
|
const timestamp = now();
|
|
337
472
|
const envelope = {
|
|
338
473
|
id: lifecycleId("evt", invocation.id, deduplicationKey),
|
|
339
474
|
tenantId: invocation.tenantId,
|
|
340
475
|
spaceId: invocation.spaceId,
|
|
341
476
|
eventType: event.eventType,
|
|
342
|
-
eventSchemaVersion: event.eventSchemaVersion ??
|
|
477
|
+
eventSchemaVersion: event.eventSchemaVersion ?? defaultEventSchemaVersion,
|
|
343
478
|
subjectType: event.subjectType,
|
|
344
479
|
subjectId: event.subjectId,
|
|
345
480
|
actorId: invocation.actorId,
|
|
@@ -364,9 +499,38 @@ function createGovernedActionHost(options) {
|
|
|
364
499
|
invocation.spaceId,
|
|
365
500
|
{ status, error }
|
|
366
501
|
);
|
|
367
|
-
return {
|
|
502
|
+
return {
|
|
503
|
+
actionInvocationId: invocation.id,
|
|
504
|
+
status,
|
|
505
|
+
error,
|
|
506
|
+
...invocation.hitlRoute ? { hitlRoute: invocation.hitlRoute } : {},
|
|
507
|
+
...invocation.hitlRiskTier ? { hitlRiskTier: invocation.hitlRiskTier } : {}
|
|
508
|
+
};
|
|
368
509
|
}
|
|
369
|
-
return { submitAction, executeInvocation };
|
|
510
|
+
return { submitAction, executeInvocation, resumeApprovedInvocation };
|
|
511
|
+
}
|
|
512
|
+
function actionResult(invocation) {
|
|
513
|
+
return {
|
|
514
|
+
actionInvocationId: invocation.id,
|
|
515
|
+
status: invocation.status,
|
|
516
|
+
result: invocation.result,
|
|
517
|
+
...invocation.error ? { error: invocation.error } : {},
|
|
518
|
+
...invocation.hitlRoute ? { hitlRoute: invocation.hitlRoute } : {},
|
|
519
|
+
...invocation.hitlRiskTier ? { hitlRiskTier: invocation.hitlRiskTier } : {}
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
function asApprovalStore(store) {
|
|
523
|
+
const candidate = store;
|
|
524
|
+
return typeof candidate.recordHitlDecision === "function" && typeof candidate.beginApprovalDecision === "function" ? store : void 0;
|
|
525
|
+
}
|
|
526
|
+
function numberValue(value) {
|
|
527
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
528
|
+
}
|
|
529
|
+
function stringValue(value) {
|
|
530
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
531
|
+
}
|
|
532
|
+
function isRiskTier(value) {
|
|
533
|
+
return value === "low" || value === "medium" || value === "high";
|
|
370
534
|
}
|
|
371
535
|
function toAuthorizationInput(action, input) {
|
|
372
536
|
return {
|
|
@@ -395,9 +559,10 @@ function initialState(entityType) {
|
|
|
395
559
|
function isTerminal(status) {
|
|
396
560
|
return ["completed", "failed", "blocked_by_policy", "validation_failed"].includes(status);
|
|
397
561
|
}
|
|
398
|
-
function withoutPrivateHostFields(data) {
|
|
399
|
-
|
|
400
|
-
|
|
562
|
+
function withoutPrivateHostFields(data, eventResultFields) {
|
|
563
|
+
return Object.fromEntries(
|
|
564
|
+
Object.entries(data).filter(([key]) => !eventResultFields.includes(key))
|
|
565
|
+
);
|
|
401
566
|
}
|
|
402
567
|
function errorMessage(error) {
|
|
403
568
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -444,6 +609,45 @@ var MemoryPlatformHostStore = class {
|
|
|
444
609
|
const record = await this.getActionInvocation(id, tenantId, spaceId);
|
|
445
610
|
if (!record) throw new Error(`ActionInvocation not found: ${id}`);
|
|
446
611
|
Object.assign(record, patch, { updatedAt: /* @__PURE__ */ new Date() });
|
|
612
|
+
if (patch.status === "waiting_for_approval" || patch.status === "completed" || patch.status === "failed" || patch.status === "blocked_by_policy" || patch.status === "validation_failed") {
|
|
613
|
+
delete record.leaseOwner;
|
|
614
|
+
delete record.leaseExpiresAt;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
async recordHitlDecision(actionInvocationId, tenantId, spaceId, decision) {
|
|
618
|
+
const record = await this.getActionInvocation(actionInvocationId, tenantId, spaceId);
|
|
619
|
+
if (!record) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
|
|
620
|
+
record.hitlRoute = decision.route;
|
|
621
|
+
record.hitlRiskTier = decision.riskTier;
|
|
622
|
+
record.hitlReason = decision.reason;
|
|
623
|
+
if (decision.policyVersion) record.hitlPolicyVersion = decision.policyVersion;
|
|
624
|
+
record.updatedAt = decision.evaluatedAt;
|
|
625
|
+
return record;
|
|
626
|
+
}
|
|
627
|
+
async beginApprovalDecision(input) {
|
|
628
|
+
const record = await this.getActionInvocation(
|
|
629
|
+
input.actionInvocationId,
|
|
630
|
+
input.tenantId,
|
|
631
|
+
input.spaceId
|
|
632
|
+
);
|
|
633
|
+
if (!record || record.status !== "waiting_for_approval") {
|
|
634
|
+
return { applied: false, ...record ? { invocation: record } : {} };
|
|
635
|
+
}
|
|
636
|
+
record.approvalDecision = input.decision;
|
|
637
|
+
record.updatedAt = input.now;
|
|
638
|
+
if (!input.decision.approved) {
|
|
639
|
+
record.status = "failed";
|
|
640
|
+
record.error = `Approval rejected: ${input.decision.reason ?? "no reason provided"}`;
|
|
641
|
+
delete record.leaseOwner;
|
|
642
|
+
delete record.leaseExpiresAt;
|
|
643
|
+
return { applied: true, invocation: record };
|
|
644
|
+
}
|
|
645
|
+
record.status = "running";
|
|
646
|
+
record.error = void 0;
|
|
647
|
+
record.leaseOwner = input.leaseOwner;
|
|
648
|
+
record.leaseExpiresAt = new Date(input.now.getTime() + input.leaseDurationMs);
|
|
649
|
+
record.attemptCount = Math.max(record.attemptCount, 1);
|
|
650
|
+
return { applied: true, invocation: record };
|
|
447
651
|
}
|
|
448
652
|
async appendPolicyEvaluation(record) {
|
|
449
653
|
if (this.policyEvaluations.some((candidate) => candidate.id === record.id)) return;
|
|
@@ -521,14 +725,20 @@ var PostgresPlatformHostStore = class {
|
|
|
521
725
|
parameters jsonb NOT NULL, result jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
522
726
|
correlation_id text NOT NULL, causation_id text, idempotency_key text, error text,
|
|
523
727
|
attempt_count integer NOT NULL DEFAULT 0, lease_owner text,
|
|
524
|
-
lease_expires_at timestamptz,
|
|
728
|
+
lease_expires_at timestamptz, hitl_route text, hitl_risk_tier text,
|
|
729
|
+
hitl_reason text, hitl_policy_version text, approval_decision jsonb,
|
|
525
730
|
created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL
|
|
526
731
|
);
|
|
527
732
|
ALTER TABLE fabric_platform.action_invocations
|
|
528
733
|
ADD COLUMN IF NOT EXISTS idempotency_key text,
|
|
529
734
|
ADD COLUMN IF NOT EXISTS attempt_count integer NOT NULL DEFAULT 0,
|
|
530
735
|
ADD COLUMN IF NOT EXISTS lease_owner text,
|
|
531
|
-
ADD COLUMN IF NOT EXISTS lease_expires_at timestamptz
|
|
736
|
+
ADD COLUMN IF NOT EXISTS lease_expires_at timestamptz,
|
|
737
|
+
ADD COLUMN IF NOT EXISTS hitl_route text,
|
|
738
|
+
ADD COLUMN IF NOT EXISTS hitl_risk_tier text,
|
|
739
|
+
ADD COLUMN IF NOT EXISTS hitl_reason text,
|
|
740
|
+
ADD COLUMN IF NOT EXISTS hitl_policy_version text,
|
|
741
|
+
ADD COLUMN IF NOT EXISTS approval_decision jsonb;
|
|
532
742
|
CREATE UNIQUE INDEX IF NOT EXISTS action_invocations_idempotency_idx
|
|
533
743
|
ON fabric_platform.action_invocations
|
|
534
744
|
(tenant_id, space_id, action_id, idempotency_key)
|
|
@@ -612,9 +822,9 @@ var PostgresPlatformHostStore = class {
|
|
|
612
822
|
`UPDATE fabric_platform.action_invocations SET
|
|
613
823
|
status=COALESCE($4,status), result=COALESCE($5::jsonb,result),
|
|
614
824
|
error=CASE WHEN $6::boolean THEN $7 ELSE error END,
|
|
615
|
-
lease_owner=CASE WHEN $4 IN ('completed','failed','blocked_by_policy','validation_failed')
|
|
825
|
+
lease_owner=CASE WHEN $4 IN ('waiting_for_approval','completed','failed','blocked_by_policy','validation_failed')
|
|
616
826
|
THEN NULL ELSE lease_owner END,
|
|
617
|
-
lease_expires_at=CASE WHEN $4 IN ('completed','failed','blocked_by_policy','validation_failed')
|
|
827
|
+
lease_expires_at=CASE WHEN $4 IN ('waiting_for_approval','completed','failed','blocked_by_policy','validation_failed')
|
|
618
828
|
THEN NULL ELSE lease_expires_at END,
|
|
619
829
|
updated_at=now()
|
|
620
830
|
WHERE id=$1 AND tenant_id=$2 AND space_id=$3`,
|
|
@@ -629,6 +839,54 @@ var PostgresPlatformHostStore = class {
|
|
|
629
839
|
]
|
|
630
840
|
);
|
|
631
841
|
}
|
|
842
|
+
async recordHitlDecision(actionInvocationId, tenantId, spaceId, decision) {
|
|
843
|
+
const result = await this.sql.query(
|
|
844
|
+
`UPDATE fabric_platform.action_invocations SET
|
|
845
|
+
hitl_route=$4, hitl_risk_tier=$5, hitl_reason=$6,
|
|
846
|
+
hitl_policy_version=$7, updated_at=$8
|
|
847
|
+
WHERE id=$1 AND tenant_id=$2 AND space_id=$3
|
|
848
|
+
RETURNING *`,
|
|
849
|
+
[
|
|
850
|
+
actionInvocationId,
|
|
851
|
+
tenantId,
|
|
852
|
+
spaceId,
|
|
853
|
+
decision.route,
|
|
854
|
+
decision.riskTier,
|
|
855
|
+
decision.reason,
|
|
856
|
+
decision.policyVersion ?? null,
|
|
857
|
+
decision.evaluatedAt
|
|
858
|
+
]
|
|
859
|
+
);
|
|
860
|
+
const row = result.rows[0];
|
|
861
|
+
if (!row) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
|
|
862
|
+
return toActionRecord(row);
|
|
863
|
+
}
|
|
864
|
+
async beginApprovalDecision(input) {
|
|
865
|
+
const result = await this.sql.query(
|
|
866
|
+
`UPDATE fabric_platform.action_invocations SET
|
|
867
|
+
approval_decision=$4::jsonb,
|
|
868
|
+
status=CASE WHEN $5::boolean THEN 'running' ELSE 'failed' END,
|
|
869
|
+
error=CASE WHEN $5::boolean THEN NULL ELSE $6 END,
|
|
870
|
+
lease_owner=CASE WHEN $5::boolean THEN $7 ELSE NULL END,
|
|
871
|
+
lease_expires_at=CASE WHEN $5::boolean THEN $8 ELSE NULL END,
|
|
872
|
+
attempt_count=CASE WHEN $5::boolean THEN GREATEST(attempt_count,1) ELSE attempt_count END,
|
|
873
|
+
updated_at=$9
|
|
874
|
+
WHERE id=$1 AND tenant_id=$2 AND space_id=$3 AND status='waiting_for_approval'
|
|
875
|
+
RETURNING *`,
|
|
876
|
+
[
|
|
877
|
+
input.actionInvocationId,
|
|
878
|
+
input.tenantId,
|
|
879
|
+
input.spaceId,
|
|
880
|
+
JSON.stringify({ ...input.decision, decidedAt: input.decision.decidedAt.toISOString() }),
|
|
881
|
+
input.decision.approved,
|
|
882
|
+
`Approval rejected: ${input.decision.reason ?? "no reason provided"}`,
|
|
883
|
+
input.leaseOwner,
|
|
884
|
+
new Date(input.now.getTime() + input.leaseDurationMs),
|
|
885
|
+
input.now
|
|
886
|
+
]
|
|
887
|
+
);
|
|
888
|
+
return result.rows[0] ? { applied: true, invocation: toActionRecord(result.rows[0]) } : { applied: false };
|
|
889
|
+
}
|
|
632
890
|
async appendPolicyEvaluation(record) {
|
|
633
891
|
await this.sql.query(
|
|
634
892
|
`INSERT INTO fabric_platform.policy_evaluations
|
|
@@ -836,11 +1094,27 @@ function toActionRecord(row) {
|
|
|
836
1094
|
attemptCount: Number(row.attempt_count ?? 0),
|
|
837
1095
|
...row.lease_owner ? { leaseOwner: String(row.lease_owner) } : {},
|
|
838
1096
|
...row.lease_expires_at ? { leaseExpiresAt: new Date(row.lease_expires_at) } : {},
|
|
1097
|
+
...row.hitl_route ? { hitlRoute: String(row.hitl_route) } : {},
|
|
1098
|
+
...row.hitl_risk_tier ? { hitlRiskTier: String(row.hitl_risk_tier) } : {},
|
|
1099
|
+
...row.hitl_reason ? { hitlReason: String(row.hitl_reason) } : {},
|
|
1100
|
+
...row.hitl_policy_version ? { hitlPolicyVersion: String(row.hitl_policy_version) } : {},
|
|
1101
|
+
...row.approval_decision ? { approvalDecision: toApprovalDecision(row.approval_decision) } : {},
|
|
839
1102
|
...row.error ? { error: String(row.error) } : {},
|
|
840
1103
|
createdAt: new Date(row.created_at),
|
|
841
1104
|
updatedAt: new Date(row.updated_at)
|
|
842
1105
|
};
|
|
843
1106
|
}
|
|
1107
|
+
function toApprovalDecision(value) {
|
|
1108
|
+
const decision = value;
|
|
1109
|
+
return {
|
|
1110
|
+
approved: Boolean(decision.approved),
|
|
1111
|
+
approverId: String(decision.approverId),
|
|
1112
|
+
approverType: String(decision.approverType),
|
|
1113
|
+
...decision.reason ? { reason: String(decision.reason) } : {},
|
|
1114
|
+
...decision.editsReference ? { editsReference: String(decision.editsReference) } : {},
|
|
1115
|
+
decidedAt: new Date(decision.decidedAt)
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
844
1118
|
function toEventRecord(row) {
|
|
845
1119
|
return {
|
|
846
1120
|
id: String(row.id),
|
|
@@ -881,21 +1155,29 @@ async function runPlatformActionWorkerCycle(options) {
|
|
|
881
1155
|
});
|
|
882
1156
|
let completed = 0;
|
|
883
1157
|
let failed = 0;
|
|
1158
|
+
let waitingForApproval = 0;
|
|
884
1159
|
for (const invocation of claimed) {
|
|
885
1160
|
try {
|
|
886
1161
|
const result = await options.host.executeInvocation(
|
|
887
1162
|
invocation.id,
|
|
888
1163
|
invocation.tenantId,
|
|
889
|
-
invocation.spaceId
|
|
1164
|
+
invocation.spaceId,
|
|
1165
|
+
{ leaseOwner: options.workerId }
|
|
890
1166
|
);
|
|
891
1167
|
if (result.status === "completed") completed += 1;
|
|
1168
|
+
else if (result.status === "waiting_for_approval") waitingForApproval += 1;
|
|
892
1169
|
else failed += 1;
|
|
893
1170
|
} catch (error) {
|
|
894
1171
|
failed += 1;
|
|
895
1172
|
options.onError?.(error, invocation);
|
|
896
1173
|
}
|
|
897
1174
|
}
|
|
898
|
-
return {
|
|
1175
|
+
return {
|
|
1176
|
+
claimed: claimed.length,
|
|
1177
|
+
completed,
|
|
1178
|
+
failed,
|
|
1179
|
+
...waitingForApproval > 0 ? { waitingForApproval } : {}
|
|
1180
|
+
};
|
|
899
1181
|
}
|
|
900
1182
|
async function runPlatformActionWorker(options) {
|
|
901
1183
|
while (!options.signal?.aborted) {
|