@fabricorg/platform-host 0.3.0 → 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/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,34 +84,98 @@ function createGovernedActionHost(options) {
80
84
  );
81
85
  return { ...executed, workflowId: durableWorkflowId };
82
86
  }
83
- async function executeInvocation(actionInvocationId, tenantId, spaceId) {
84
- const invocation = await options.store.getActionInvocation(
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 (!invocation) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
90
- if (isTerminal(invocation.status)) {
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
- actionInvocationId,
93
- status: invocation.status,
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);
99
107
  if (!action) {
100
108
  return fail(invocation, "failed", `Unknown action: ${invocation.actionId}`);
101
109
  }
102
- await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
103
- status: "running"
104
- });
110
+ if (invocation.attemptCount > 1 && !action.idempotent) {
111
+ return fail(
112
+ invocation,
113
+ "failed",
114
+ `Interrupted action ${invocation.actionId} is not declared idempotent; manual reconciliation is required.`
115
+ );
116
+ }
117
+ if (invocation.status !== "running") {
118
+ await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
119
+ status: "running"
120
+ });
121
+ }
105
122
  try {
106
123
  const parsed = action.schema.safeParse(invocation.parameters);
107
124
  if (!parsed.success) {
108
125
  const message = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
109
126
  return fail(invocation, "validation_failed", message);
110
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
+ }
111
179
  const authorizationInput = toAuthorizationInput(action, invocation);
112
180
  const definitions = options.resolvePolicies ? await options.resolvePolicies({
113
181
  ...authorizationInput,
@@ -127,7 +195,7 @@ function createGovernedActionHost(options) {
127
195
  });
128
196
  for (const outcome of outcomes) {
129
197
  await options.store.appendPolicyEvaluation({
130
- id: createFabricId("pol"),
198
+ id: lifecycleId("pol", actionInvocationId, outcome.policyId),
131
199
  actionInvocationId,
132
200
  tenantId,
133
201
  spaceId,
@@ -143,7 +211,7 @@ function createGovernedActionHost(options) {
143
211
  subjectType: "ActionInvocation",
144
212
  subjectId: actionInvocationId,
145
213
  payload: { actionId: action.actionId, policyId: aggregate.policyId, reason }
146
- });
214
+ }, `compliance:${aggregate.policyId}`);
147
215
  return fail(invocation, "blocked_by_policy", reason);
148
216
  }
149
217
  const binding = action.stateMachine;
@@ -162,7 +230,8 @@ function createGovernedActionHost(options) {
162
230
  targetState,
163
231
  action.actionId
164
232
  );
165
- if (!transition.valid) {
233
+ const replayingAppliedTransition = action.idempotent && currentState === targetState;
234
+ if (!transition.valid && !replayingAppliedTransition) {
166
235
  return fail(invocation, "failed", transition.error ?? "Invalid state transition");
167
236
  }
168
237
  }
@@ -191,16 +260,22 @@ function createGovernedActionHost(options) {
191
260
  data = handlerResult.data ?? {};
192
261
  domainEvents = extractEvents(data);
193
262
  if (action.eventPhase !== "after_adapters") {
194
- for (const event of domainEvents) await appendEvent(invocation, event);
263
+ for (const [index, event] of domainEvents.entries()) {
264
+ await appendEvent(invocation, event, `domain:${index}`);
265
+ }
195
266
  }
196
267
  } catch (error) {
197
268
  return fail(invocation, "failed", errorMessage(error));
198
269
  }
199
- for (const step of action.adapterSteps ?? []) {
270
+ for (const [stepIndex, step] of (action.adapterSteps ?? []).entries()) {
200
271
  const input = step.getInput(parsed.data, data);
201
272
  if (!input) continue;
202
273
  const adapter = adapters.require(step.adapterType, step.operation);
203
- const adapterInvocationId = createFabricId("adp");
274
+ const adapterInvocationId = lifecycleId("adp", actionInvocationId, String(stepIndex));
275
+ const previousAdapterInvocation = await options.store.getAdapterInvocation(
276
+ adapterInvocationId
277
+ );
278
+ if (previousAdapterInvocation?.status === "succeeded") continue;
204
279
  const adapterEventSubject = options.adapterEventSubject?.(
205
280
  action.actionId,
206
281
  parsed.data,
@@ -233,7 +308,7 @@ function createGovernedActionHost(options) {
233
308
  subjectType: adapterEventSubject.subjectType,
234
309
  subjectId: adapterEventSubject.subjectId,
235
310
  payload: { adapterType: step.adapterType, operation: step.operation }
236
- });
311
+ }, `adapter:${stepIndex}:started`);
237
312
  try {
238
313
  const result2 = await executeWithAdapterRetry({
239
314
  policy: step.retryPolicy ?? adapter.retryPolicy,
@@ -268,7 +343,7 @@ function createGovernedActionHost(options) {
268
343
  subjectType: adapterEventSubject.subjectType,
269
344
  subjectId: adapterEventSubject.subjectId,
270
345
  payload: { adapterType: step.adapterType, operation: step.operation }
271
- });
346
+ }, `adapter:${stepIndex}:failed`);
272
347
  return fail(invocation, "failed", result2.error ?? "Adapter failed");
273
348
  }
274
349
  const resultRecord = result2;
@@ -292,7 +367,7 @@ function createGovernedActionHost(options) {
292
367
  input: recordedInput,
293
368
  output
294
369
  }
295
- });
370
+ }, `adapter:${stepIndex}:succeeded`);
296
371
  } catch (error) {
297
372
  const message = errorMessage(error);
298
373
  await options.store.updateAdapterInvocation(adapterInvocationId, {
@@ -304,22 +379,93 @@ function createGovernedActionHost(options) {
304
379
  }
305
380
  }
306
381
  if (action.eventPhase === "after_adapters") {
307
- for (const event of domainEvents) await appendEvent(invocation, event);
382
+ for (const [index, event] of domainEvents.entries()) {
383
+ await appendEvent(invocation, event, `domain:${index}`);
384
+ }
308
385
  }
309
386
  const result = withoutPrivateHostFields(data);
310
387
  await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
311
388
  status: "completed",
312
389
  result
313
390
  });
314
- return { actionInvocationId, status: "completed", result };
391
+ return {
392
+ actionInvocationId,
393
+ status: "completed",
394
+ result,
395
+ ...invocation.hitlRoute ? { hitlRoute: invocation.hitlRoute } : {},
396
+ ...invocation.hitlRiskTier ? { hitlRiskTier: invocation.hitlRiskTier } : {}
397
+ };
315
398
  } catch (error) {
316
399
  return fail(invocation, "failed", errorMessage(error));
317
400
  }
318
401
  }
319
- async function appendEvent(invocation, event) {
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
+ }
465
+ async function appendEvent(invocation, event, deduplicationKey) {
320
466
  const timestamp = now();
321
467
  const envelope = {
322
- id: createFabricId("evt"),
468
+ id: lifecycleId("evt", invocation.id, deduplicationKey),
323
469
  tenantId: invocation.tenantId,
324
470
  spaceId: invocation.spaceId,
325
471
  eventType: event.eventType,
@@ -348,9 +494,38 @@ function createGovernedActionHost(options) {
348
494
  invocation.spaceId,
349
495
  { status, error }
350
496
  );
351
- return { actionInvocationId: invocation.id, status, error };
497
+ return {
498
+ actionInvocationId: invocation.id,
499
+ status,
500
+ error,
501
+ ...invocation.hitlRoute ? { hitlRoute: invocation.hitlRoute } : {},
502
+ ...invocation.hitlRiskTier ? { hitlRiskTier: invocation.hitlRiskTier } : {}
503
+ };
352
504
  }
353
- 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";
354
529
  }
355
530
  function toAuthorizationInput(action, input) {
356
531
  return {
@@ -389,6 +564,10 @@ function errorMessage(error) {
389
564
  function isRecord(value) {
390
565
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
391
566
  }
567
+ function lifecycleId(prefix, invocationId, key) {
568
+ const safeKey = key.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 96);
569
+ return `${prefix}_${invocationId}_${safeKey}`;
570
+ }
392
571
 
393
572
  // src/memory-store.ts
394
573
  var MemoryPlatformHostStore = class {
@@ -424,19 +603,64 @@ var MemoryPlatformHostStore = class {
424
603
  const record = await this.getActionInvocation(id, tenantId, spaceId);
425
604
  if (!record) throw new Error(`ActionInvocation not found: ${id}`);
426
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 };
427
645
  }
428
646
  async appendPolicyEvaluation(record) {
647
+ if (this.policyEvaluations.some((candidate) => candidate.id === record.id)) return;
429
648
  this.policyEvaluations.push(record);
430
649
  }
431
650
  async createAdapterInvocation(record) {
651
+ if (this.adapterInvocations.some((candidate) => candidate.id === record.id)) return;
432
652
  this.adapterInvocations.push(record);
433
653
  }
654
+ async getAdapterInvocation(id) {
655
+ return this.adapterInvocations.find((candidate) => candidate.id === id);
656
+ }
434
657
  async updateAdapterInvocation(id, patch) {
435
658
  const record = this.adapterInvocations.find((candidate) => candidate.id === id);
436
659
  if (!record) throw new Error(`AdapterInvocation not found: ${id}`);
437
660
  Object.assign(record, patch);
438
661
  }
439
662
  async appendEvent(event) {
663
+ if (this.events.some((candidate) => candidate.id === event.id)) return;
440
664
  this.events.push(event);
441
665
  }
442
666
  async nextEventSequence(tenantId, spaceId) {
@@ -495,14 +719,20 @@ var PostgresPlatformHostStore = class {
495
719
  parameters jsonb NOT NULL, result jsonb NOT NULL DEFAULT '{}'::jsonb,
496
720
  correlation_id text NOT NULL, causation_id text, idempotency_key text, error text,
497
721
  attempt_count integer NOT NULL DEFAULT 0, lease_owner text,
498
- 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,
499
724
  created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL
500
725
  );
501
726
  ALTER TABLE fabric_platform.action_invocations
502
727
  ADD COLUMN IF NOT EXISTS idempotency_key text,
503
728
  ADD COLUMN IF NOT EXISTS attempt_count integer NOT NULL DEFAULT 0,
504
729
  ADD COLUMN IF NOT EXISTS lease_owner text,
505
- 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;
506
736
  CREATE UNIQUE INDEX IF NOT EXISTS action_invocations_idempotency_idx
507
737
  ON fabric_platform.action_invocations
508
738
  (tenant_id, space_id, action_id, idempotency_key)
@@ -586,9 +816,9 @@ var PostgresPlatformHostStore = class {
586
816
  `UPDATE fabric_platform.action_invocations SET
587
817
  status=COALESCE($4,status), result=COALESCE($5::jsonb,result),
588
818
  error=CASE WHEN $6::boolean THEN $7 ELSE error END,
589
- 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')
590
820
  THEN NULL ELSE lease_owner END,
591
- 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')
592
822
  THEN NULL ELSE lease_expires_at END,
593
823
  updated_at=now()
594
824
  WHERE id=$1 AND tenant_id=$2 AND space_id=$3`,
@@ -603,11 +833,59 @@ var PostgresPlatformHostStore = class {
603
833
  ]
604
834
  );
605
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
+ }
606
884
  async appendPolicyEvaluation(record) {
607
885
  await this.sql.query(
608
886
  `INSERT INTO fabric_platform.policy_evaluations
609
887
  (id,action_invocation_id,tenant_id,space_id,outcome,created_at)
610
- VALUES ($1,$2,$3,$4,$5::jsonb,$6)`,
888
+ VALUES ($1,$2,$3,$4,$5::jsonb,$6) ON CONFLICT (id) DO NOTHING`,
611
889
  [
612
890
  record.id,
613
891
  record.actionInvocationId,
@@ -623,7 +901,8 @@ var PostgresPlatformHostStore = class {
623
901
  `INSERT INTO fabric_platform.adapter_invocations
624
902
  (id,action_invocation_id,tenant_id,space_id,adapter_type,operation,vendor,status,
625
903
  input,output,error,attempt,created_at,updated_at)
626
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10::jsonb,$11,$12,$13,$14)`,
904
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10::jsonb,$11,$12,$13,$14)
905
+ ON CONFLICT (id) DO NOTHING`,
627
906
  [
628
907
  record.id,
629
908
  record.actionInvocationId,
@@ -642,6 +921,13 @@ var PostgresPlatformHostStore = class {
642
921
  ]
643
922
  );
644
923
  }
924
+ async getAdapterInvocation(id) {
925
+ const result = await this.sql.query(
926
+ `SELECT * FROM fabric_platform.adapter_invocations WHERE id=$1`,
927
+ [id]
928
+ );
929
+ return result.rows[0] ? toAdapterRecord(result.rows[0]) : void 0;
930
+ }
645
931
  async updateAdapterInvocation(id, patch) {
646
932
  await this.sql.query(
647
933
  `UPDATE fabric_platform.adapter_invocations SET
@@ -665,7 +951,8 @@ var PostgresPlatformHostStore = class {
665
951
  (id,tenant_id,space_id,event_type,event_schema_version,subject_type,subject_id,
666
952
  actor_id,actor_type,action_invocation_id,payload,sequence,occurred_at,recorded_at,
667
953
  correlation_id,causation_id)
668
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,$13,$14,$15,$16)`,
954
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,$13,$14,$15,$16)
955
+ ON CONFLICT (id) DO NOTHING`,
669
956
  [
670
957
  event.id,
671
958
  event.tenantId,
@@ -765,6 +1052,24 @@ var PostgresPlatformHostStore = class {
765
1052
  return result.rows.map(toEventRecord);
766
1053
  }
767
1054
  };
1055
+ function toAdapterRecord(row) {
1056
+ return {
1057
+ id: String(row.id),
1058
+ actionInvocationId: String(row.action_invocation_id),
1059
+ tenantId: String(row.tenant_id),
1060
+ spaceId: String(row.space_id),
1061
+ adapterType: String(row.adapter_type),
1062
+ operation: String(row.operation),
1063
+ vendor: String(row.vendor),
1064
+ status: String(row.status),
1065
+ input: row.input,
1066
+ ...row.output ? { output: row.output } : {},
1067
+ ...row.error ? { error: String(row.error) } : {},
1068
+ attempt: Number(row.attempt),
1069
+ createdAt: new Date(row.created_at),
1070
+ updatedAt: new Date(row.updated_at)
1071
+ };
1072
+ }
768
1073
  function toActionRecord(row) {
769
1074
  return {
770
1075
  id: String(row.id),
@@ -783,11 +1088,27 @@ function toActionRecord(row) {
783
1088
  attemptCount: Number(row.attempt_count ?? 0),
784
1089
  ...row.lease_owner ? { leaseOwner: String(row.lease_owner) } : {},
785
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) } : {},
786
1096
  ...row.error ? { error: String(row.error) } : {},
787
1097
  createdAt: new Date(row.created_at),
788
1098
  updatedAt: new Date(row.updated_at)
789
1099
  };
790
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
+ }
791
1112
  function toEventRecord(row) {
792
1113
  return {
793
1114
  id: String(row.id),
@@ -828,21 +1149,29 @@ async function runPlatformActionWorkerCycle(options) {
828
1149
  });
829
1150
  let completed = 0;
830
1151
  let failed = 0;
1152
+ let waitingForApproval = 0;
831
1153
  for (const invocation of claimed) {
832
1154
  try {
833
1155
  const result = await options.host.executeInvocation(
834
1156
  invocation.id,
835
1157
  invocation.tenantId,
836
- invocation.spaceId
1158
+ invocation.spaceId,
1159
+ { leaseOwner: options.workerId }
837
1160
  );
838
1161
  if (result.status === "completed") completed += 1;
1162
+ else if (result.status === "waiting_for_approval") waitingForApproval += 1;
839
1163
  else failed += 1;
840
1164
  } catch (error) {
841
1165
  failed += 1;
842
1166
  options.onError?.(error, invocation);
843
1167
  }
844
1168
  }
845
- return { claimed: claimed.length, completed, failed };
1169
+ return {
1170
+ claimed: claimed.length,
1171
+ completed,
1172
+ failed,
1173
+ ...waitingForApproval > 0 ? { waitingForApproval } : {}
1174
+ };
846
1175
  }
847
1176
  async function runPlatformActionWorker(options) {
848
1177
  while (!options.signal?.aborted) {