@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/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ActorType, ActionId, ActionStatus, AgentActionRoute, AgentActionRiskTier, PolicyOutcome, AssetEventEnvelope, AdapterImplementation, FabricRuntimeServices, AgentActionPolicyEvaluator, RuntimePolicyDefinition } from '@fabricorg/platform';
|
|
2
2
|
|
|
3
3
|
interface PendingAssetEvent {
|
|
4
4
|
eventType: string;
|
|
@@ -24,10 +24,33 @@ interface ActionInvocationRecord {
|
|
|
24
24
|
attemptCount: number;
|
|
25
25
|
leaseOwner?: string;
|
|
26
26
|
leaseExpiresAt?: Date;
|
|
27
|
+
hitlRoute?: AgentActionRoute;
|
|
28
|
+
hitlRiskTier?: AgentActionRiskTier;
|
|
29
|
+
hitlReason?: string;
|
|
30
|
+
hitlPolicyVersion?: string;
|
|
31
|
+
approvalDecision?: PersistedActionApprovalDecision;
|
|
27
32
|
error?: string;
|
|
28
33
|
createdAt: Date;
|
|
29
34
|
updatedAt: Date;
|
|
30
35
|
}
|
|
36
|
+
interface HitlDecisionEvidence {
|
|
37
|
+
route: AgentActionRoute;
|
|
38
|
+
riskTier: AgentActionRiskTier;
|
|
39
|
+
reason: string;
|
|
40
|
+
policyVersion?: string;
|
|
41
|
+
evaluatedAt: Date;
|
|
42
|
+
}
|
|
43
|
+
interface ActionApprovalDecision {
|
|
44
|
+
approved: boolean;
|
|
45
|
+
approverId: string;
|
|
46
|
+
approverType: ActorType;
|
|
47
|
+
reason?: string;
|
|
48
|
+
editsReference?: string;
|
|
49
|
+
decidedAt?: Date;
|
|
50
|
+
}
|
|
51
|
+
interface PersistedActionApprovalDecision extends Omit<ActionApprovalDecision, "decidedAt"> {
|
|
52
|
+
decidedAt: Date;
|
|
53
|
+
}
|
|
31
54
|
interface PolicyEvaluationRecord {
|
|
32
55
|
id: string;
|
|
33
56
|
actionInvocationId: string;
|
|
@@ -69,6 +92,24 @@ interface PlatformHostStore<TDb> {
|
|
|
69
92
|
nextEventSequence(tenantId: string, spaceId: string): Promise<number>;
|
|
70
93
|
getEntityState(tenantId: string, spaceId: string, entityType: string, entityId: string): Promise<string | undefined>;
|
|
71
94
|
}
|
|
95
|
+
interface BeginApprovalDecisionInput {
|
|
96
|
+
actionInvocationId: string;
|
|
97
|
+
tenantId: string;
|
|
98
|
+
spaceId: string;
|
|
99
|
+
decision: PersistedActionApprovalDecision;
|
|
100
|
+
leaseOwner: string;
|
|
101
|
+
leaseDurationMs: number;
|
|
102
|
+
now: Date;
|
|
103
|
+
}
|
|
104
|
+
interface ApprovalDecisionTransitionResult {
|
|
105
|
+
applied: boolean;
|
|
106
|
+
invocation?: ActionInvocationRecord;
|
|
107
|
+
}
|
|
108
|
+
/** Optional store capability required only when agent HITL or approval resume is used. */
|
|
109
|
+
interface ApprovalPlatformHostStore<TDb> extends PlatformHostStore<TDb> {
|
|
110
|
+
recordHitlDecision(actionInvocationId: string, tenantId: string, spaceId: string, decision: HitlDecisionEvidence): Promise<ActionInvocationRecord>;
|
|
111
|
+
beginApprovalDecision(input: BeginApprovalDecisionInput): Promise<ApprovalDecisionTransitionResult>;
|
|
112
|
+
}
|
|
72
113
|
interface ListActionInvocationsInput {
|
|
73
114
|
tenantId?: string;
|
|
74
115
|
spaceId?: string;
|
|
@@ -103,6 +144,10 @@ interface ActionAuthorizationInput {
|
|
|
103
144
|
interface PlatformHostAuthorization {
|
|
104
145
|
checkEntitlement(input: ActionAuthorizationInput): Promise<boolean>;
|
|
105
146
|
authorize(input: ActionAuthorizationInput): Promise<boolean>;
|
|
147
|
+
/** Optional distinct authorization boundary for human/system approval decisions. */
|
|
148
|
+
authorizeApproval?(input: ActionAuthorizationInput & {
|
|
149
|
+
decision: ActionApprovalDecision;
|
|
150
|
+
}): Promise<boolean>;
|
|
106
151
|
}
|
|
107
152
|
interface DispatchActionInput {
|
|
108
153
|
actionInvocationId: string;
|
|
@@ -136,12 +181,20 @@ interface SubmitActionResult {
|
|
|
136
181
|
runId?: string;
|
|
137
182
|
result?: Record<string, unknown>;
|
|
138
183
|
error?: string;
|
|
184
|
+
hitlRoute?: AgentActionRoute;
|
|
185
|
+
hitlRiskTier?: AgentActionRiskTier;
|
|
139
186
|
}
|
|
140
187
|
interface ExecuteActionResult {
|
|
141
188
|
actionInvocationId: string;
|
|
142
189
|
status: ActionStatus;
|
|
143
190
|
result?: Record<string, unknown>;
|
|
144
191
|
error?: string;
|
|
192
|
+
hitlRoute?: AgentActionRoute;
|
|
193
|
+
hitlRiskTier?: AgentActionRiskTier;
|
|
194
|
+
}
|
|
195
|
+
interface ExecuteInvocationOptions {
|
|
196
|
+
/** Required when executing a row already protected by a worker or approval-resume lease. */
|
|
197
|
+
leaseOwner?: string;
|
|
145
198
|
}
|
|
146
199
|
interface PlatformActionWorkerOptions<TDb> {
|
|
147
200
|
host: GovernedActionHost;
|
|
@@ -159,6 +212,8 @@ interface PlatformActionWorkerCycleResult {
|
|
|
159
212
|
claimed: number;
|
|
160
213
|
completed: number;
|
|
161
214
|
failed: number;
|
|
215
|
+
/** Present when claimed agent work was durably parked instead of failing. */
|
|
216
|
+
waitingForApproval?: number;
|
|
162
217
|
}
|
|
163
218
|
interface PlatformHostOptions<TDb> {
|
|
164
219
|
store: PlatformHostStore<TDb>;
|
|
@@ -166,6 +221,12 @@ interface PlatformHostOptions<TDb> {
|
|
|
166
221
|
adapters?: readonly AdapterImplementation[];
|
|
167
222
|
dispatcher?: PlatformActionDispatcher;
|
|
168
223
|
services?: FabricRuntimeServices;
|
|
224
|
+
/** Optional vertical-owned evaluator. When absent, agent behavior is unchanged. */
|
|
225
|
+
hitlEvaluator?: AgentActionPolicyEvaluator;
|
|
226
|
+
/** Durable evaluator/ruleset version recorded with every HITL decision. */
|
|
227
|
+
hitlPolicyVersion?: string;
|
|
228
|
+
/** Lease held while an approved invocation resumes; defaults to five minutes. */
|
|
229
|
+
approvalResumeLeaseDurationMs?: number;
|
|
169
230
|
/** Remove forbidden/sensitive fields before durable invocation persistence. */
|
|
170
231
|
redactActionParameters?: (actionId: ActionId, parameters: Record<string, unknown>) => Record<string, unknown>;
|
|
171
232
|
resolvePolicies?: (input: ActionAuthorizationInput & {
|
|
@@ -181,12 +242,13 @@ interface PlatformHostOptions<TDb> {
|
|
|
181
242
|
}
|
|
182
243
|
interface GovernedActionHost {
|
|
183
244
|
submitAction(input: SubmitActionInput): Promise<SubmitActionResult>;
|
|
184
|
-
executeInvocation(actionInvocationId: string, tenantId: string, spaceId: string): Promise<ExecuteActionResult>;
|
|
245
|
+
executeInvocation(actionInvocationId: string, tenantId: string, spaceId: string, options?: ExecuteInvocationOptions): Promise<ExecuteActionResult>;
|
|
246
|
+
resumeApprovedInvocation(actionInvocationId: string, tenantId: string, spaceId: string, decision: ActionApprovalDecision): Promise<ExecuteActionResult>;
|
|
185
247
|
}
|
|
186
248
|
|
|
187
249
|
declare function createGovernedActionHost<TDb>(options: PlatformHostOptions<TDb>): GovernedActionHost;
|
|
188
250
|
|
|
189
|
-
declare class MemoryPlatformHostStore<TDb> implements
|
|
251
|
+
declare class MemoryPlatformHostStore<TDb> implements RecoverablePlatformHostStore<TDb>, ApprovalPlatformHostStore<TDb> {
|
|
190
252
|
readonly db: TDb;
|
|
191
253
|
readonly invocations: ActionInvocationRecord[];
|
|
192
254
|
readonly policyEvaluations: PolicyEvaluationRecord[];
|
|
@@ -197,6 +259,8 @@ declare class MemoryPlatformHostStore<TDb> implements PlatformHostStore<TDb> {
|
|
|
197
259
|
createActionInvocation(input: CreateActionInvocationInput): Promise<ActionInvocationRecord>;
|
|
198
260
|
getActionInvocation(id: string, tenantId: string, spaceId: string): Promise<ActionInvocationRecord | undefined>;
|
|
199
261
|
updateActionInvocation(id: string, tenantId: string, spaceId: string, patch: Partial<Pick<ActionInvocationRecord, "status" | "result" | "error">>): Promise<void>;
|
|
262
|
+
recordHitlDecision(actionInvocationId: string, tenantId: string, spaceId: string, decision: HitlDecisionEvidence): Promise<ActionInvocationRecord>;
|
|
263
|
+
beginApprovalDecision(input: BeginApprovalDecisionInput): Promise<ApprovalDecisionTransitionResult>;
|
|
200
264
|
appendPolicyEvaluation(record: PolicyEvaluationRecord): Promise<void>;
|
|
201
265
|
createAdapterInvocation(record: AdapterInvocationRecord): Promise<void>;
|
|
202
266
|
getAdapterInvocation(id: string): Promise<AdapterInvocationRecord | undefined>;
|
|
@@ -217,7 +281,7 @@ interface PlatformHostSqlClient {
|
|
|
217
281
|
query<Row = Record<string, unknown>>(sql: string, values?: unknown[]): Promise<PlatformHostSqlResult<Row>>;
|
|
218
282
|
}
|
|
219
283
|
/** Durable mutation-pipeline ledger for Databricks Lakebase or standard Postgres. */
|
|
220
|
-
declare class PostgresPlatformHostStore<TDb> implements
|
|
284
|
+
declare class PostgresPlatformHostStore<TDb> implements RecoverablePlatformHostStore<TDb>, ApprovalPlatformHostStore<TDb> {
|
|
221
285
|
readonly db: TDb;
|
|
222
286
|
private readonly sql;
|
|
223
287
|
constructor(db: TDb, sql: PlatformHostSqlClient);
|
|
@@ -226,6 +290,8 @@ declare class PostgresPlatformHostStore<TDb> implements PlatformHostStore<TDb> {
|
|
|
226
290
|
createActionInvocation(input: CreateActionInvocationInput): Promise<ActionInvocationRecord>;
|
|
227
291
|
getActionInvocation(id: string, tenantId: string, spaceId: string): Promise<ActionInvocationRecord | undefined>;
|
|
228
292
|
updateActionInvocation(id: string, tenantId: string, spaceId: string, patch: Partial<Pick<ActionInvocationRecord, "status" | "result" | "error">>): Promise<void>;
|
|
293
|
+
recordHitlDecision(actionInvocationId: string, tenantId: string, spaceId: string, decision: HitlDecisionEvidence): Promise<ActionInvocationRecord>;
|
|
294
|
+
beginApprovalDecision(input: BeginApprovalDecisionInput): Promise<ApprovalDecisionTransitionResult>;
|
|
229
295
|
appendPolicyEvaluation(record: PolicyEvaluationRecord): Promise<void>;
|
|
230
296
|
createAdapterInvocation(record: AdapterInvocationRecord): Promise<void>;
|
|
231
297
|
getAdapterInvocation(id: string): Promise<AdapterInvocationRecord | undefined>;
|
|
@@ -248,4 +314,4 @@ declare function runPlatformActionWorkerCycle<TDb>(options: PlatformActionWorker
|
|
|
248
314
|
/** Poll until aborted. Each iteration is bounded and waits without busy-spinning. */
|
|
249
315
|
declare function runPlatformActionWorker<TDb>(options: PlatformActionWorkerOptions<TDb>): Promise<void>;
|
|
250
316
|
|
|
251
|
-
export { type ActionAuthorizationInput, type ActionInvocationRecord, type AdapterInvocationRecord, type AdapterInvocationStatus, type ClaimActionInvocationsInput, type CreateActionInvocationInput, type DispatchActionInput, type DispatchActionResult, type ExecuteActionResult, type GovernedActionHost, type ListActionInvocationsInput, MemoryPlatformHostStore, type PendingAssetEvent, type PlatformActionDispatcher, type PlatformActionWorkerCycleResult, type PlatformActionWorkerOptions, type PlatformHostAuthorization, type PlatformHostOptions, type PlatformHostSqlClient, type PlatformHostSqlResult, type PlatformHostStore, type PolicyEvaluationRecord, PostgresPlatformHostStore, type RecoverablePlatformHostStore, type SubmitActionInput, type SubmitActionResult, createGovernedActionHost, createStoreBackedActionDispatcher, runPlatformActionWorker, runPlatformActionWorkerCycle };
|
|
317
|
+
export { type ActionApprovalDecision, type ActionAuthorizationInput, type ActionInvocationRecord, type AdapterInvocationRecord, type AdapterInvocationStatus, type ApprovalDecisionTransitionResult, type ApprovalPlatformHostStore, type BeginApprovalDecisionInput, type ClaimActionInvocationsInput, type CreateActionInvocationInput, type DispatchActionInput, type DispatchActionResult, type ExecuteActionResult, type ExecuteInvocationOptions, type GovernedActionHost, type HitlDecisionEvidence, type ListActionInvocationsInput, MemoryPlatformHostStore, type PendingAssetEvent, type PersistedActionApprovalDecision, type PlatformActionDispatcher, type PlatformActionWorkerCycleResult, type PlatformActionWorkerOptions, type PlatformHostAuthorization, type PlatformHostOptions, type PlatformHostSqlClient, type PlatformHostSqlResult, type PlatformHostStore, type PolicyEvaluationRecord, PostgresPlatformHostStore, type RecoverablePlatformHostStore, type SubmitActionInput, type SubmitActionResult, createGovernedActionHost, createStoreBackedActionDispatcher, runPlatformActionWorker, runPlatformActionWorkerCycle };
|
package/dist/index.js
CHANGED
|
@@ -45,7 +45,9 @@ function createGovernedActionHost(options) {
|
|
|
45
45
|
status: durableInvocation.status,
|
|
46
46
|
workflowId: durableWorkflowId,
|
|
47
47
|
result: durableInvocation.result,
|
|
48
|
-
...durableInvocation.error ? { error: durableInvocation.error } : {}
|
|
48
|
+
...durableInvocation.error ? { error: durableInvocation.error } : {},
|
|
49
|
+
...durableInvocation.hitlRoute ? { hitlRoute: durableInvocation.hitlRoute } : {},
|
|
50
|
+
...durableInvocation.hitlRiskTier ? { hitlRiskTier: durableInvocation.hitlRiskTier } : {}
|
|
49
51
|
};
|
|
50
52
|
}
|
|
51
53
|
if (options.dispatcher) {
|
|
@@ -60,7 +62,9 @@ function createGovernedActionHost(options) {
|
|
|
60
62
|
actionInvocationId: durableInvocation.id,
|
|
61
63
|
status: durableInvocation.status,
|
|
62
64
|
workflowId: dispatched.workflowId,
|
|
63
|
-
...dispatched.runId ? { runId: dispatched.runId } : {}
|
|
65
|
+
...dispatched.runId ? { runId: dispatched.runId } : {},
|
|
66
|
+
...durableInvocation.hitlRoute ? { hitlRoute: durableInvocation.hitlRoute } : {},
|
|
67
|
+
...durableInvocation.hitlRiskTier ? { hitlRiskTier: durableInvocation.hitlRiskTier } : {}
|
|
64
68
|
};
|
|
65
69
|
} catch (error) {
|
|
66
70
|
const message = errorMessage(error);
|
|
@@ -80,19 +84,23 @@ function createGovernedActionHost(options) {
|
|
|
80
84
|
);
|
|
81
85
|
return { ...executed, workflowId: durableWorkflowId };
|
|
82
86
|
}
|
|
83
|
-
async function executeInvocation(actionInvocationId, tenantId, spaceId) {
|
|
84
|
-
const
|
|
87
|
+
async function executeInvocation(actionInvocationId, tenantId, spaceId, executionOptions = {}) {
|
|
88
|
+
const loadedInvocation = await options.store.getActionInvocation(
|
|
85
89
|
actionInvocationId,
|
|
86
90
|
tenantId,
|
|
87
91
|
spaceId
|
|
88
92
|
);
|
|
89
|
-
if (!
|
|
90
|
-
|
|
93
|
+
if (!loadedInvocation) {
|
|
94
|
+
throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
|
|
95
|
+
}
|
|
96
|
+
let invocation = loadedInvocation;
|
|
97
|
+
if (isTerminal(invocation.status) || invocation.status === "waiting_for_approval") {
|
|
98
|
+
return actionResult(invocation);
|
|
99
|
+
}
|
|
100
|
+
if (invocation.status === "running" && invocation.leaseOwner && executionOptions.leaseOwner !== invocation.leaseOwner) {
|
|
91
101
|
return {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
result: invocation.result,
|
|
95
|
-
...invocation.error ? { error: invocation.error } : {}
|
|
102
|
+
...actionResult(invocation),
|
|
103
|
+
error: `Invocation is leased by ${invocation.leaseOwner}`
|
|
96
104
|
};
|
|
97
105
|
}
|
|
98
106
|
const action = resolveAction(invocation.actionId);
|
|
@@ -106,15 +114,68 @@ function createGovernedActionHost(options) {
|
|
|
106
114
|
`Interrupted action ${invocation.actionId} is not declared idempotent; manual reconciliation is required.`
|
|
107
115
|
);
|
|
108
116
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
117
|
+
if (invocation.status !== "running") {
|
|
118
|
+
await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
|
|
119
|
+
status: "running"
|
|
120
|
+
});
|
|
121
|
+
}
|
|
112
122
|
try {
|
|
113
123
|
const parsed = action.schema.safeParse(invocation.parameters);
|
|
114
124
|
if (!parsed.success) {
|
|
115
125
|
const message = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
|
|
116
126
|
return fail(invocation, "validation_failed", message);
|
|
117
127
|
}
|
|
128
|
+
if (invocation.actorType === "agent" && options.hitlEvaluator) {
|
|
129
|
+
const hitlParameters = isRecord(parsed.data) ? parsed.data : invocation.parameters;
|
|
130
|
+
const approvalStore = asApprovalStore(options.store);
|
|
131
|
+
if (!approvalStore) {
|
|
132
|
+
return fail(
|
|
133
|
+
invocation,
|
|
134
|
+
"failed",
|
|
135
|
+
"The configured HITL evaluator requires a store with approval persistence support."
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (!invocation.hitlRoute) {
|
|
139
|
+
const decision = await options.hitlEvaluator({
|
|
140
|
+
actionId: action.actionId,
|
|
141
|
+
actorId: invocation.actorId,
|
|
142
|
+
actorType: invocation.actorType,
|
|
143
|
+
tenantId,
|
|
144
|
+
spaceId,
|
|
145
|
+
parameters: hitlParameters,
|
|
146
|
+
...numberValue(hitlParameters.confidence) !== void 0 ? { confidence: numberValue(hitlParameters.confidence) } : {},
|
|
147
|
+
...isRiskTier(hitlParameters.riskTier) ? { riskTier: hitlParameters.riskTier } : {},
|
|
148
|
+
...stringValue(hitlParameters.agentSessionId) ? { agentSessionId: stringValue(hitlParameters.agentSessionId) } : {},
|
|
149
|
+
...stringValue(hitlParameters.agentRunId) ? { agentRunId: stringValue(hitlParameters.agentRunId) } : {}
|
|
150
|
+
});
|
|
151
|
+
invocation = await approvalStore.recordHitlDecision(
|
|
152
|
+
actionInvocationId,
|
|
153
|
+
tenantId,
|
|
154
|
+
spaceId,
|
|
155
|
+
{
|
|
156
|
+
...decision,
|
|
157
|
+
...options.hitlPolicyVersion ? { policyVersion: options.hitlPolicyVersion } : {},
|
|
158
|
+
evaluatedAt: now()
|
|
159
|
+
}
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (invocation.hitlRoute === "rejected") {
|
|
163
|
+
return fail(
|
|
164
|
+
invocation,
|
|
165
|
+
"failed",
|
|
166
|
+
`HITL rejected: ${invocation.hitlReason ?? "no reason provided"}`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if ((invocation.hitlRoute === "needs-approval" || invocation.hitlRoute === "escalate") && !invocation.approvalDecision?.approved) {
|
|
170
|
+
await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
|
|
171
|
+
status: "waiting_for_approval"
|
|
172
|
+
});
|
|
173
|
+
return {
|
|
174
|
+
...actionResult(invocation),
|
|
175
|
+
status: "waiting_for_approval"
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
118
179
|
const authorizationInput = toAuthorizationInput(action, invocation);
|
|
119
180
|
const definitions = options.resolvePolicies ? await options.resolvePolicies({
|
|
120
181
|
...authorizationInput,
|
|
@@ -327,11 +388,80 @@ function createGovernedActionHost(options) {
|
|
|
327
388
|
status: "completed",
|
|
328
389
|
result
|
|
329
390
|
});
|
|
330
|
-
return {
|
|
391
|
+
return {
|
|
392
|
+
actionInvocationId,
|
|
393
|
+
status: "completed",
|
|
394
|
+
result,
|
|
395
|
+
...invocation.hitlRoute ? { hitlRoute: invocation.hitlRoute } : {},
|
|
396
|
+
...invocation.hitlRiskTier ? { hitlRiskTier: invocation.hitlRiskTier } : {}
|
|
397
|
+
};
|
|
331
398
|
} catch (error) {
|
|
332
399
|
return fail(invocation, "failed", errorMessage(error));
|
|
333
400
|
}
|
|
334
401
|
}
|
|
402
|
+
async function resumeApprovedInvocation(actionInvocationId, tenantId, spaceId, decision) {
|
|
403
|
+
if (!decision.approverId.trim()) throw new Error("approverId is required");
|
|
404
|
+
const invocation = await options.store.getActionInvocation(
|
|
405
|
+
actionInvocationId,
|
|
406
|
+
tenantId,
|
|
407
|
+
spaceId
|
|
408
|
+
);
|
|
409
|
+
if (!invocation) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
|
|
410
|
+
if (isTerminal(invocation.status)) return actionResult(invocation);
|
|
411
|
+
if (invocation.status !== "waiting_for_approval") {
|
|
412
|
+
return {
|
|
413
|
+
...actionResult(invocation),
|
|
414
|
+
error: `Invocation is not waiting for approval (status: ${invocation.status})`
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
const action = resolveAction(invocation.actionId);
|
|
418
|
+
if (!action) return fail(invocation, "failed", `Unknown action: ${invocation.actionId}`);
|
|
419
|
+
const approvalAuthorizationInput = toAuthorizationInput(action, {
|
|
420
|
+
tenantId,
|
|
421
|
+
spaceId,
|
|
422
|
+
actorId: decision.approverId,
|
|
423
|
+
actorType: decision.approverType
|
|
424
|
+
});
|
|
425
|
+
const entitled = await options.authorization.checkEntitlement(
|
|
426
|
+
approvalAuthorizationInput
|
|
427
|
+
);
|
|
428
|
+
const authorized = options.authorization.authorizeApproval ? await options.authorization.authorizeApproval({
|
|
429
|
+
...approvalAuthorizationInput,
|
|
430
|
+
decision
|
|
431
|
+
}) : await options.authorization.authorize(approvalAuthorizationInput);
|
|
432
|
+
if (!entitled || !authorized) {
|
|
433
|
+
return {
|
|
434
|
+
...actionResult(invocation),
|
|
435
|
+
error: `Actor ${decision.approverId} is not authorized to decide this approval`
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
const approvalStore = asApprovalStore(options.store);
|
|
439
|
+
if (!approvalStore) {
|
|
440
|
+
return {
|
|
441
|
+
...actionResult(invocation),
|
|
442
|
+
error: "Approval resume requires a store with approval persistence support."
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
const transitionStartedAt = now();
|
|
446
|
+
const decidedAt = decision.decidedAt ?? transitionStartedAt;
|
|
447
|
+
const leaseOwner = `approval-resume-${createFabricId("lease")}`;
|
|
448
|
+
const transition = await approvalStore.beginApprovalDecision({
|
|
449
|
+
actionInvocationId,
|
|
450
|
+
tenantId,
|
|
451
|
+
spaceId,
|
|
452
|
+
decision: { ...decision, decidedAt },
|
|
453
|
+
leaseOwner,
|
|
454
|
+
leaseDurationMs: options.approvalResumeLeaseDurationMs ?? 5 * 6e4,
|
|
455
|
+
now: transitionStartedAt
|
|
456
|
+
});
|
|
457
|
+
const transitioned = transition.invocation ?? await options.store.getActionInvocation(actionInvocationId, tenantId, spaceId);
|
|
458
|
+
if (!transition.applied || !transitioned) {
|
|
459
|
+
if (!transitioned) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
|
|
460
|
+
return actionResult(transitioned);
|
|
461
|
+
}
|
|
462
|
+
if (!decision.approved) return actionResult(transitioned);
|
|
463
|
+
return executeInvocation(actionInvocationId, tenantId, spaceId, { leaseOwner });
|
|
464
|
+
}
|
|
335
465
|
async function appendEvent(invocation, event, deduplicationKey) {
|
|
336
466
|
const timestamp = now();
|
|
337
467
|
const envelope = {
|
|
@@ -364,9 +494,38 @@ function createGovernedActionHost(options) {
|
|
|
364
494
|
invocation.spaceId,
|
|
365
495
|
{ status, error }
|
|
366
496
|
);
|
|
367
|
-
return {
|
|
497
|
+
return {
|
|
498
|
+
actionInvocationId: invocation.id,
|
|
499
|
+
status,
|
|
500
|
+
error,
|
|
501
|
+
...invocation.hitlRoute ? { hitlRoute: invocation.hitlRoute } : {},
|
|
502
|
+
...invocation.hitlRiskTier ? { hitlRiskTier: invocation.hitlRiskTier } : {}
|
|
503
|
+
};
|
|
368
504
|
}
|
|
369
|
-
return { submitAction, executeInvocation };
|
|
505
|
+
return { submitAction, executeInvocation, resumeApprovedInvocation };
|
|
506
|
+
}
|
|
507
|
+
function actionResult(invocation) {
|
|
508
|
+
return {
|
|
509
|
+
actionInvocationId: invocation.id,
|
|
510
|
+
status: invocation.status,
|
|
511
|
+
result: invocation.result,
|
|
512
|
+
...invocation.error ? { error: invocation.error } : {},
|
|
513
|
+
...invocation.hitlRoute ? { hitlRoute: invocation.hitlRoute } : {},
|
|
514
|
+
...invocation.hitlRiskTier ? { hitlRiskTier: invocation.hitlRiskTier } : {}
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
function asApprovalStore(store) {
|
|
518
|
+
const candidate = store;
|
|
519
|
+
return typeof candidate.recordHitlDecision === "function" && typeof candidate.beginApprovalDecision === "function" ? store : void 0;
|
|
520
|
+
}
|
|
521
|
+
function numberValue(value) {
|
|
522
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
523
|
+
}
|
|
524
|
+
function stringValue(value) {
|
|
525
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
526
|
+
}
|
|
527
|
+
function isRiskTier(value) {
|
|
528
|
+
return value === "low" || value === "medium" || value === "high";
|
|
370
529
|
}
|
|
371
530
|
function toAuthorizationInput(action, input) {
|
|
372
531
|
return {
|
|
@@ -444,6 +603,45 @@ var MemoryPlatformHostStore = class {
|
|
|
444
603
|
const record = await this.getActionInvocation(id, tenantId, spaceId);
|
|
445
604
|
if (!record) throw new Error(`ActionInvocation not found: ${id}`);
|
|
446
605
|
Object.assign(record, patch, { updatedAt: /* @__PURE__ */ new Date() });
|
|
606
|
+
if (patch.status === "waiting_for_approval" || patch.status === "completed" || patch.status === "failed" || patch.status === "blocked_by_policy" || patch.status === "validation_failed") {
|
|
607
|
+
delete record.leaseOwner;
|
|
608
|
+
delete record.leaseExpiresAt;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
async recordHitlDecision(actionInvocationId, tenantId, spaceId, decision) {
|
|
612
|
+
const record = await this.getActionInvocation(actionInvocationId, tenantId, spaceId);
|
|
613
|
+
if (!record) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
|
|
614
|
+
record.hitlRoute = decision.route;
|
|
615
|
+
record.hitlRiskTier = decision.riskTier;
|
|
616
|
+
record.hitlReason = decision.reason;
|
|
617
|
+
if (decision.policyVersion) record.hitlPolicyVersion = decision.policyVersion;
|
|
618
|
+
record.updatedAt = decision.evaluatedAt;
|
|
619
|
+
return record;
|
|
620
|
+
}
|
|
621
|
+
async beginApprovalDecision(input) {
|
|
622
|
+
const record = await this.getActionInvocation(
|
|
623
|
+
input.actionInvocationId,
|
|
624
|
+
input.tenantId,
|
|
625
|
+
input.spaceId
|
|
626
|
+
);
|
|
627
|
+
if (!record || record.status !== "waiting_for_approval") {
|
|
628
|
+
return { applied: false, ...record ? { invocation: record } : {} };
|
|
629
|
+
}
|
|
630
|
+
record.approvalDecision = input.decision;
|
|
631
|
+
record.updatedAt = input.now;
|
|
632
|
+
if (!input.decision.approved) {
|
|
633
|
+
record.status = "failed";
|
|
634
|
+
record.error = `Approval rejected: ${input.decision.reason ?? "no reason provided"}`;
|
|
635
|
+
delete record.leaseOwner;
|
|
636
|
+
delete record.leaseExpiresAt;
|
|
637
|
+
return { applied: true, invocation: record };
|
|
638
|
+
}
|
|
639
|
+
record.status = "running";
|
|
640
|
+
record.error = void 0;
|
|
641
|
+
record.leaseOwner = input.leaseOwner;
|
|
642
|
+
record.leaseExpiresAt = new Date(input.now.getTime() + input.leaseDurationMs);
|
|
643
|
+
record.attemptCount = Math.max(record.attemptCount, 1);
|
|
644
|
+
return { applied: true, invocation: record };
|
|
447
645
|
}
|
|
448
646
|
async appendPolicyEvaluation(record) {
|
|
449
647
|
if (this.policyEvaluations.some((candidate) => candidate.id === record.id)) return;
|
|
@@ -521,14 +719,20 @@ var PostgresPlatformHostStore = class {
|
|
|
521
719
|
parameters jsonb NOT NULL, result jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
522
720
|
correlation_id text NOT NULL, causation_id text, idempotency_key text, error text,
|
|
523
721
|
attempt_count integer NOT NULL DEFAULT 0, lease_owner text,
|
|
524
|
-
lease_expires_at timestamptz,
|
|
722
|
+
lease_expires_at timestamptz, hitl_route text, hitl_risk_tier text,
|
|
723
|
+
hitl_reason text, hitl_policy_version text, approval_decision jsonb,
|
|
525
724
|
created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL
|
|
526
725
|
);
|
|
527
726
|
ALTER TABLE fabric_platform.action_invocations
|
|
528
727
|
ADD COLUMN IF NOT EXISTS idempotency_key text,
|
|
529
728
|
ADD COLUMN IF NOT EXISTS attempt_count integer NOT NULL DEFAULT 0,
|
|
530
729
|
ADD COLUMN IF NOT EXISTS lease_owner text,
|
|
531
|
-
ADD COLUMN IF NOT EXISTS lease_expires_at timestamptz
|
|
730
|
+
ADD COLUMN IF NOT EXISTS lease_expires_at timestamptz,
|
|
731
|
+
ADD COLUMN IF NOT EXISTS hitl_route text,
|
|
732
|
+
ADD COLUMN IF NOT EXISTS hitl_risk_tier text,
|
|
733
|
+
ADD COLUMN IF NOT EXISTS hitl_reason text,
|
|
734
|
+
ADD COLUMN IF NOT EXISTS hitl_policy_version text,
|
|
735
|
+
ADD COLUMN IF NOT EXISTS approval_decision jsonb;
|
|
532
736
|
CREATE UNIQUE INDEX IF NOT EXISTS action_invocations_idempotency_idx
|
|
533
737
|
ON fabric_platform.action_invocations
|
|
534
738
|
(tenant_id, space_id, action_id, idempotency_key)
|
|
@@ -612,9 +816,9 @@ var PostgresPlatformHostStore = class {
|
|
|
612
816
|
`UPDATE fabric_platform.action_invocations SET
|
|
613
817
|
status=COALESCE($4,status), result=COALESCE($5::jsonb,result),
|
|
614
818
|
error=CASE WHEN $6::boolean THEN $7 ELSE error END,
|
|
615
|
-
lease_owner=CASE WHEN $4 IN ('completed','failed','blocked_by_policy','validation_failed')
|
|
819
|
+
lease_owner=CASE WHEN $4 IN ('waiting_for_approval','completed','failed','blocked_by_policy','validation_failed')
|
|
616
820
|
THEN NULL ELSE lease_owner END,
|
|
617
|
-
lease_expires_at=CASE WHEN $4 IN ('completed','failed','blocked_by_policy','validation_failed')
|
|
821
|
+
lease_expires_at=CASE WHEN $4 IN ('waiting_for_approval','completed','failed','blocked_by_policy','validation_failed')
|
|
618
822
|
THEN NULL ELSE lease_expires_at END,
|
|
619
823
|
updated_at=now()
|
|
620
824
|
WHERE id=$1 AND tenant_id=$2 AND space_id=$3`,
|
|
@@ -629,6 +833,54 @@ var PostgresPlatformHostStore = class {
|
|
|
629
833
|
]
|
|
630
834
|
);
|
|
631
835
|
}
|
|
836
|
+
async recordHitlDecision(actionInvocationId, tenantId, spaceId, decision) {
|
|
837
|
+
const result = await this.sql.query(
|
|
838
|
+
`UPDATE fabric_platform.action_invocations SET
|
|
839
|
+
hitl_route=$4, hitl_risk_tier=$5, hitl_reason=$6,
|
|
840
|
+
hitl_policy_version=$7, updated_at=$8
|
|
841
|
+
WHERE id=$1 AND tenant_id=$2 AND space_id=$3
|
|
842
|
+
RETURNING *`,
|
|
843
|
+
[
|
|
844
|
+
actionInvocationId,
|
|
845
|
+
tenantId,
|
|
846
|
+
spaceId,
|
|
847
|
+
decision.route,
|
|
848
|
+
decision.riskTier,
|
|
849
|
+
decision.reason,
|
|
850
|
+
decision.policyVersion ?? null,
|
|
851
|
+
decision.evaluatedAt
|
|
852
|
+
]
|
|
853
|
+
);
|
|
854
|
+
const row = result.rows[0];
|
|
855
|
+
if (!row) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
|
|
856
|
+
return toActionRecord(row);
|
|
857
|
+
}
|
|
858
|
+
async beginApprovalDecision(input) {
|
|
859
|
+
const result = await this.sql.query(
|
|
860
|
+
`UPDATE fabric_platform.action_invocations SET
|
|
861
|
+
approval_decision=$4::jsonb,
|
|
862
|
+
status=CASE WHEN $5::boolean THEN 'running' ELSE 'failed' END,
|
|
863
|
+
error=CASE WHEN $5::boolean THEN NULL ELSE $6 END,
|
|
864
|
+
lease_owner=CASE WHEN $5::boolean THEN $7 ELSE NULL END,
|
|
865
|
+
lease_expires_at=CASE WHEN $5::boolean THEN $8 ELSE NULL END,
|
|
866
|
+
attempt_count=CASE WHEN $5::boolean THEN GREATEST(attempt_count,1) ELSE attempt_count END,
|
|
867
|
+
updated_at=$9
|
|
868
|
+
WHERE id=$1 AND tenant_id=$2 AND space_id=$3 AND status='waiting_for_approval'
|
|
869
|
+
RETURNING *`,
|
|
870
|
+
[
|
|
871
|
+
input.actionInvocationId,
|
|
872
|
+
input.tenantId,
|
|
873
|
+
input.spaceId,
|
|
874
|
+
JSON.stringify({ ...input.decision, decidedAt: input.decision.decidedAt.toISOString() }),
|
|
875
|
+
input.decision.approved,
|
|
876
|
+
`Approval rejected: ${input.decision.reason ?? "no reason provided"}`,
|
|
877
|
+
input.leaseOwner,
|
|
878
|
+
new Date(input.now.getTime() + input.leaseDurationMs),
|
|
879
|
+
input.now
|
|
880
|
+
]
|
|
881
|
+
);
|
|
882
|
+
return result.rows[0] ? { applied: true, invocation: toActionRecord(result.rows[0]) } : { applied: false };
|
|
883
|
+
}
|
|
632
884
|
async appendPolicyEvaluation(record) {
|
|
633
885
|
await this.sql.query(
|
|
634
886
|
`INSERT INTO fabric_platform.policy_evaluations
|
|
@@ -836,11 +1088,27 @@ function toActionRecord(row) {
|
|
|
836
1088
|
attemptCount: Number(row.attempt_count ?? 0),
|
|
837
1089
|
...row.lease_owner ? { leaseOwner: String(row.lease_owner) } : {},
|
|
838
1090
|
...row.lease_expires_at ? { leaseExpiresAt: new Date(row.lease_expires_at) } : {},
|
|
1091
|
+
...row.hitl_route ? { hitlRoute: String(row.hitl_route) } : {},
|
|
1092
|
+
...row.hitl_risk_tier ? { hitlRiskTier: String(row.hitl_risk_tier) } : {},
|
|
1093
|
+
...row.hitl_reason ? { hitlReason: String(row.hitl_reason) } : {},
|
|
1094
|
+
...row.hitl_policy_version ? { hitlPolicyVersion: String(row.hitl_policy_version) } : {},
|
|
1095
|
+
...row.approval_decision ? { approvalDecision: toApprovalDecision(row.approval_decision) } : {},
|
|
839
1096
|
...row.error ? { error: String(row.error) } : {},
|
|
840
1097
|
createdAt: new Date(row.created_at),
|
|
841
1098
|
updatedAt: new Date(row.updated_at)
|
|
842
1099
|
};
|
|
843
1100
|
}
|
|
1101
|
+
function toApprovalDecision(value) {
|
|
1102
|
+
const decision = value;
|
|
1103
|
+
return {
|
|
1104
|
+
approved: Boolean(decision.approved),
|
|
1105
|
+
approverId: String(decision.approverId),
|
|
1106
|
+
approverType: String(decision.approverType),
|
|
1107
|
+
...decision.reason ? { reason: String(decision.reason) } : {},
|
|
1108
|
+
...decision.editsReference ? { editsReference: String(decision.editsReference) } : {},
|
|
1109
|
+
decidedAt: new Date(decision.decidedAt)
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
844
1112
|
function toEventRecord(row) {
|
|
845
1113
|
return {
|
|
846
1114
|
id: String(row.id),
|
|
@@ -881,21 +1149,29 @@ async function runPlatformActionWorkerCycle(options) {
|
|
|
881
1149
|
});
|
|
882
1150
|
let completed = 0;
|
|
883
1151
|
let failed = 0;
|
|
1152
|
+
let waitingForApproval = 0;
|
|
884
1153
|
for (const invocation of claimed) {
|
|
885
1154
|
try {
|
|
886
1155
|
const result = await options.host.executeInvocation(
|
|
887
1156
|
invocation.id,
|
|
888
1157
|
invocation.tenantId,
|
|
889
|
-
invocation.spaceId
|
|
1158
|
+
invocation.spaceId,
|
|
1159
|
+
{ leaseOwner: options.workerId }
|
|
890
1160
|
);
|
|
891
1161
|
if (result.status === "completed") completed += 1;
|
|
1162
|
+
else if (result.status === "waiting_for_approval") waitingForApproval += 1;
|
|
892
1163
|
else failed += 1;
|
|
893
1164
|
} catch (error) {
|
|
894
1165
|
failed += 1;
|
|
895
1166
|
options.onError?.(error, invocation);
|
|
896
1167
|
}
|
|
897
1168
|
}
|
|
898
|
-
return {
|
|
1169
|
+
return {
|
|
1170
|
+
claimed: claimed.length,
|
|
1171
|
+
completed,
|
|
1172
|
+
failed,
|
|
1173
|
+
...waitingForApproval > 0 ? { waitingForApproval } : {}
|
|
1174
|
+
};
|
|
899
1175
|
}
|
|
900
1176
|
async function runPlatformActionWorker(options) {
|
|
901
1177
|
while (!options.signal?.aborted) {
|