@fabricorg/platform-host 7.1.0 → 7.1.2

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @fabricorg/platform-host
2
2
 
3
+ ## 7.1.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 07e3c65: The external-completion lifecycle moves out of the host into its own internal module, with the helpers it shares with the host in another. No behaviour changes; the public surface is unchanged and every test passes as before.
8
+
9
+ ## 7.1.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 91f7d7e: A repeated completion was judged identical by outcome alone, so a second `completed` callback carrying a different result was absorbed as a replay and its evidence discarded. A recorded completion now carries a canonical digest of provider, reference, outcome, result, error, and evidence, with observation time left out because a redelivery carries a new one. A repeat with the same digest is absorbed; the same outcome with a different digest is a contradiction and is handled as one: reconciliation, the first outcome kept, the second logged, `ExternalOperationContradicted` emitted.
14
+
3
15
  ## 7.1.0
4
16
 
5
17
  ### Minor Changes
package/README.md CHANGED
@@ -265,7 +265,9 @@ await host.completeExternalInvocation(actionInvocationId, tenantId, spaceId, {
265
265
  ```
266
266
 
267
267
  Matching is by reference: a completion naming a reference the invocation is not waiting on is
268
- refused. A repeated identical completion is absorbed. A contradictory one moves the invocation to
268
+ refused. A repeated identical completion is absorbed, identity being a digest of provider,
269
+ reference, outcome, result, error, and evidence. A contradictory one, a flipped outcome or the same
270
+ outcome with different evidence, moves the invocation to
269
271
  `reconciliation_required`, keeps the first outcome on record, logs the second, and emits
270
272
  `ExternalOperationContradicted`. Under an atomic store the read and write happen under one lock. The
271
273
  callback's result is validated against the action's result schema and stripped of private fields.
package/dist/index.cjs CHANGED
@@ -284,6 +284,221 @@ function isPromiseLike(value) {
284
284
  );
285
285
  }
286
286
 
287
+ // src/host-internals.ts
288
+ function asAtomicMutationStore(store) {
289
+ const candidate = store;
290
+ return typeof candidate.transactionWithEvents === "function" ? store : void 0;
291
+ }
292
+ function asGovernanceStore(store) {
293
+ const candidate = store;
294
+ return typeof candidate.recordMutationGovernance === "function" && typeof candidate.appendPolicyObligations === "function" && typeof candidate.appendExecutionAttestation === "function" ? store : void 0;
295
+ }
296
+ function withoutPrivateHostFields(data, eventResultFields) {
297
+ return Object.fromEntries(
298
+ Object.entries(data).filter(([key]) => !eventResultFields.includes(key))
299
+ );
300
+ }
301
+ function errorMessage(error) {
302
+ return error instanceof Error ? error.message : String(error);
303
+ }
304
+ function lifecycleId(prefix, invocationId, key) {
305
+ const safeKey = key.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 96);
306
+ return `${prefix}_${invocationId}_${safeKey}`;
307
+ }
308
+ function emitInvocationStatusTelemetry(invocation, status, telemetry, occurredAt, previousStatus) {
309
+ if (!status) return;
310
+ const event = status === "completed" ? { name: "invocation.completed", metricName: PLATFORM_HOST_METRIC_NAMES.invocationCompleted } : status === "blocked_by_policy" ? { name: "invocation.policy_blocked", metricName: PLATFORM_HOST_METRIC_NAMES.invocationPolicyBlocked } : status === "validation_failed" ? { name: "invocation.validation_failed", metricName: PLATFORM_HOST_METRIC_NAMES.invocationValidationFailed } : status === "waiting_for_approval" ? { name: "invocation.approval_waited", metricName: PLATFORM_HOST_METRIC_NAMES.invocationApprovalWaited } : status === "reconciliation_required" ? { name: "invocation.reconciliation_required", metricName: PLATFORM_HOST_METRIC_NAMES.invocationReconciliationRequired } : status === "failed" ? { name: "invocation.failed", metricName: PLATFORM_HOST_METRIC_NAMES.invocationFailed } : void 0;
311
+ if (!event) return;
312
+ emitPlatformHostTelemetry(telemetry, {
313
+ kind: "event",
314
+ name: event.name,
315
+ metricName: event.metricName,
316
+ occurredAt,
317
+ tenantId: invocation.tenantId,
318
+ spaceId: invocation.spaceId,
319
+ attributes: {
320
+ ...invocation.actionId ? { actionId: invocation.actionId } : {},
321
+ ...invocation.actionVersion !== void 0 ? { actionVersion: invocation.actionVersion } : {},
322
+ ...previousStatus ? { fromStatus: previousStatus } : {}
323
+ }
324
+ });
325
+ }
326
+
327
+ // src/external-completion.ts
328
+ function createExternalCompletionLifecycle(deps) {
329
+ const { now, actionResolver, appendEvent, persistInvocation, actionResult: actionResult2, withConsistency, eventResultFields } = deps;
330
+ async function completeExternalInvocation(actionInvocationId, tenantId, spaceId, completion) {
331
+ if (typeof completion.externalReference !== "string" || completion.externalReference.trim() === "") {
332
+ throw new ExternalCompletionError("invalid_completion", "External completion must name the external reference it completes.");
333
+ }
334
+ if (completion.outcome !== "completed" && completion.outcome !== "failed") {
335
+ throw new ExternalCompletionError("invalid_completion", `External completion outcome must be "completed" or "failed", received "${String(completion.outcome)}".`);
336
+ }
337
+ const atomicStore = asAtomicMutationStore(deps.store);
338
+ if (atomicStore) {
339
+ return atomicStore.transactionWithEvents(async (transaction) => {
340
+ const read2 = transaction.getActionInvocationForUpdate ? await transaction.getActionInvocationForUpdate(actionInvocationId, tenantId, spaceId) : await deps.store.getActionInvocation(actionInvocationId, tenantId, spaceId);
341
+ return settleExternalCompletion(read2, actionInvocationId, completion, {
342
+ update: (patch) => transaction.updateActionInvocation(actionInvocationId, tenantId, spaceId, patch),
343
+ transaction
344
+ });
345
+ });
346
+ }
347
+ const read = await deps.store.getActionInvocation(actionInvocationId, tenantId, spaceId);
348
+ return settleExternalCompletion(read, actionInvocationId, completion, {
349
+ update: (patch) => deps.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, patch)
350
+ });
351
+ }
352
+ async function settleExternalCompletion(invocation, actionInvocationId, completion, writer) {
353
+ if (!invocation) throw new ExternalCompletionError("not_found", `ActionInvocation not found: ${actionInvocationId}`);
354
+ const recorded = invocation.externalCompletion;
355
+ const stillExecuting = !recorded && (invocation.status === "pending" || invocation.status === "running" && (invocation.leaseOwner !== void 0 || invocation.pendingCompletion?.parkedAt === void 0));
356
+ if (stillExecuting) {
357
+ throw new ExternalCompletionError("still_executing", `ActionInvocation ${actionInvocationId} is still executing; retry the completion after it parks.`);
358
+ }
359
+ if (recorded) {
360
+ if (recorded.externalReference !== completion.externalReference) {
361
+ throw new ExternalCompletionError("reference_mismatch", `ActionInvocation ${actionInvocationId} was completed by "${recorded.externalReference}", not "${completion.externalReference}".`);
362
+ }
363
+ if (completion.provider !== void 0 && recorded.provider !== void 0 && completion.provider !== recorded.provider) {
364
+ throw new ExternalCompletionError("provider_mismatch", `ActionInvocation ${actionInvocationId} was completed by "${recorded.provider}", not "${completion.provider}".`);
365
+ }
366
+ const incomingDigest = completionDigest(completion, recorded.provider ?? completion.provider);
367
+ if (recorded.digest === incomingDigest) {
368
+ return withConsistency(actionResult2(invocation), invocation.actionId);
369
+ }
370
+ const message = recorded.outcome === completion.outcome ? `External operation "${completion.externalReference}" reported ${completion.outcome} again with different evidence.` : `External operation "${completion.externalReference}" reported ${completion.outcome} after reporting ${recorded.outcome}.`;
371
+ await recordCompletionReconciliation(invocation, { ...completion, kind: `contradicted:${incomingDigest.slice(0, 16)}` }, message);
372
+ await appendEvent(invocation, {
373
+ eventType: "ExternalOperationContradicted",
374
+ subjectType: "AdapterInvocation",
375
+ subjectId: invocation.pendingCompletion?.adapterInvocationId ?? actionInvocationId,
376
+ payload: { externalReference: completion.externalReference, recorded: recorded.outcome, reported: completion.outcome }
377
+ }, `external:${completion.externalReference}:contradicted:${completion.outcome}`, 1, writer.transaction);
378
+ await writer.update({ status: "reconciliation_required", error: message });
379
+ emitInvocationStatusTelemetry(invocation, "reconciliation_required", deps.telemetry, now(), invocation.status);
380
+ return withConsistency(actionResult2({ ...invocation, status: "reconciliation_required", error: message }), invocation.actionId);
381
+ }
382
+ const pending = invocation.pendingCompletion;
383
+ if (!pending) {
384
+ const action = actionResolver(invocation.actionId);
385
+ if ((action?.execution?.completion ?? "immediate") === "immediate") {
386
+ throw new ExternalCompletionError("immediate_contract", `Action ${invocation.actionId} declares immediate completion; nothing external completes it.`);
387
+ }
388
+ throw new ExternalCompletionError("not_awaiting", `ActionInvocation ${actionInvocationId} is not awaiting an external completion.`);
389
+ }
390
+ if (pending.externalReference !== completion.externalReference) {
391
+ throw new ExternalCompletionError("reference_mismatch", `ActionInvocation ${actionInvocationId} awaits "${pending.externalReference}", not "${completion.externalReference}".`);
392
+ }
393
+ if (completion.provider !== void 0 && completion.provider !== pending.provider) {
394
+ throw new ExternalCompletionError("provider_mismatch", `ActionInvocation ${actionInvocationId} awaits completion from "${pending.provider}", not "${completion.provider}".`);
395
+ }
396
+ const recordedAt = now();
397
+ const externalCompletion = {
398
+ ...completion,
399
+ provider: completion.provider ?? pending.provider,
400
+ observedAt: completion.observedAt instanceof Date ? completion.observedAt.toISOString() : completion.observedAt,
401
+ recordedAt,
402
+ digest: completionDigest(completion, completion.provider ?? pending.provider)
403
+ };
404
+ const subject = { subjectType: "AdapterInvocation", subjectId: pending.adapterInvocationId };
405
+ const settles = invocation.status === "running" && pending.parkedAt !== void 0 || invocation.status === "reconciliation_required" && invocation.error?.includes("did not complete by") === true;
406
+ if (completion.outcome === "completed") {
407
+ const action = actionResolver(invocation.actionId);
408
+ const merged = withoutPrivateHostFields({ ...invocation.result, ...completion.result ?? {} }, eventResultFields);
409
+ if (action?.resultSchema) {
410
+ const parsedResult = action.resultSchema.safeParse(merged);
411
+ if (!parsedResult.success) {
412
+ throw new ExternalCompletionError("result_invalid", `External completion result validation failed at ${parsedResult.error.issues.map((issue) => issue.path.map(String).join(".") || "result").join(", ")}`);
413
+ }
414
+ }
415
+ await deps.store.updateAdapterInvocation(pending.adapterInvocationId, {
416
+ ...completion.result ? { output: withoutPrivateHostFields(completion.result, eventResultFields) } : {},
417
+ updatedAt: recordedAt
418
+ });
419
+ await appendEvent(invocation, {
420
+ eventType: "ExternalOperationCompleted",
421
+ ...subject,
422
+ payload: { externalReference: pending.externalReference, provider: pending.provider, ...completion.result ? { result: withoutPrivateHostFields(completion.result, eventResultFields) } : {} }
423
+ }, `external:${pending.adapterInvocationId}:completed`, 1, writer.transaction);
424
+ const patch2 = settles ? { status: "completed", result: merged, externalCompletion, pendingCompletion: void 0, error: void 0 } : { externalCompletion, pendingCompletion: void 0 };
425
+ await writer.update(patch2);
426
+ if (settles) emitInvocationStatusTelemetry(invocation, "completed", deps.telemetry, now(), invocation.status);
427
+ return withConsistency(actionResult2({ ...invocation, ...patch2, result: settles ? merged : invocation.result, ...settles ? { error: void 0 } : {} }), invocation.actionId);
428
+ }
429
+ const error = completion.error ?? `External operation "${pending.externalReference}" failed.`;
430
+ await deps.store.updateAdapterInvocation(pending.adapterInvocationId, { status: "failed", error, updatedAt: recordedAt });
431
+ await appendEvent(invocation, {
432
+ eventType: "ExternalOperationFailed",
433
+ ...subject,
434
+ payload: { externalReference: pending.externalReference, provider: pending.provider, error }
435
+ }, `external:${pending.adapterInvocationId}:failed`, 1, writer.transaction);
436
+ const patch = settles ? { status: "failed", error, externalCompletion, pendingCompletion: void 0 } : { externalCompletion, pendingCompletion: void 0 };
437
+ await writer.update(patch);
438
+ if (settles) emitInvocationStatusTelemetry(invocation, "failed", deps.telemetry, now(), invocation.status);
439
+ return withConsistency(actionResult2({ ...invocation, ...patch }), invocation.actionId);
440
+ }
441
+ function completionDigest(completion, provider) {
442
+ return crypto.createHash("sha256").update(canonicalJson({
443
+ provider: provider ?? null,
444
+ externalReference: completion.externalReference,
445
+ outcome: completion.outcome,
446
+ result: completion.result ?? null,
447
+ error: completion.error ?? null,
448
+ evidenceReferences: completion.evidenceReferences ?? null
449
+ })).digest("hex");
450
+ }
451
+ async function recordCompletionReconciliation(invocation, completion, reason) {
452
+ const governanceStore = asGovernanceStore(deps.store);
453
+ if (!governanceStore) throw new Error("External completion reconciliation requires a governance-capable store.");
454
+ const provider = completion.provider ?? invocation.pendingCompletion?.provider ?? invocation.externalCompletion?.provider ?? "external";
455
+ const seed = crypto.createHash("sha256").update(`${provider}\0${completion.externalReference}\0${completion.kind ?? "refused"}`).digest("hex").slice(0, 32);
456
+ await governanceStore.appendExternalReconciliation({
457
+ id: lifecycleId("rec", invocation.id, `ext:${seed}`),
458
+ actionInvocationId: invocation.id,
459
+ tenantId: invocation.tenantId,
460
+ spaceId: invocation.spaceId,
461
+ status: "pending",
462
+ provider,
463
+ externalOperationId: completion.externalReference,
464
+ attempt: 1,
465
+ reason,
466
+ ...completion.evidenceReferences ? { evidenceReferences: completion.evidenceReferences } : {},
467
+ observedAt: now()
468
+ });
469
+ }
470
+ async function reconcileOverdueCompletions(input = {}) {
471
+ const recoverable = deps.store;
472
+ if (!recoverable.listActionInvocations) {
473
+ throw new Error("Overdue completion reconciliation requires a store that can list invocations.");
474
+ }
475
+ if (!asGovernanceStore(deps.store)) {
476
+ throw new Error("Overdue completion reconciliation requires a governance-capable store to record its findings.");
477
+ }
478
+ const current = input.now ?? now();
479
+ const candidates = await recoverable.listActionInvocations({
480
+ statuses: ["running"],
481
+ completionDueBefore: current,
482
+ unleased: true,
483
+ ...input.tenantId ? { tenantId: input.tenantId } : {},
484
+ ...input.spaceId ? { spaceId: input.spaceId } : {},
485
+ limit: Math.max(1, Math.min(input.limit ?? 100, 1e3))
486
+ });
487
+ const overdue = [];
488
+ for (const invocation of candidates) {
489
+ const pending = invocation.pendingCompletion;
490
+ if (!pending?.dueAt || pending.dueAt.getTime() > current.getTime()) continue;
491
+ if (invocation.leaseOwner) continue;
492
+ const message = `External operation "${pending.externalReference}" from ${pending.provider} did not complete by ${pending.dueAt.toISOString()}.`;
493
+ await recordCompletionReconciliation(invocation, { externalReference: pending.externalReference, provider: pending.provider, kind: "overdue" }, message);
494
+ await persistInvocation(invocation, { status: "reconciliation_required", error: message });
495
+ overdue.push(invocation.id);
496
+ }
497
+ return { overdue };
498
+ }
499
+ return { completeExternalInvocation, reconcileOverdueCompletions, recordCompletionReconciliation };
500
+ }
501
+
287
502
  // src/host.ts
288
503
  var DEFAULT_EXTRACT_EVENTS = (data) => {
289
504
  const value = data._events;
@@ -1042,7 +1257,7 @@ function createGovernedActionHost(options) {
1042
1257
  });
1043
1258
  let error = refuse;
1044
1259
  try {
1045
- await recordCompletionReconciliation(invocation, { externalReference: reference, provider: adapter.vendor }, refuse);
1260
+ await externalCompletion.recordCompletionReconciliation(invocation, { externalReference: reference, provider: adapter.vendor }, refuse);
1046
1261
  } catch (logError) {
1047
1262
  error = `${refuse} Reconciliation log unavailable: ${errorMessage(logError)}`;
1048
1263
  await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, { error });
@@ -1123,7 +1338,7 @@ function createGovernedActionHost(options) {
1123
1338
  await persistInvocation(invocation, { status: "reconciliation_required", error: message });
1124
1339
  let error = message;
1125
1340
  try {
1126
- await recordCompletionReconciliation(invocation, { externalReference: pendingHandoff.externalReference, provider: pendingHandoff.provider, kind: "obligations" }, message);
1341
+ await externalCompletion.recordCompletionReconciliation(invocation, { externalReference: pendingHandoff.externalReference, provider: pendingHandoff.provider, kind: "obligations" }, message);
1127
1342
  } catch (logError) {
1128
1343
  error = `${message} Reconciliation log unavailable: ${errorMessage(logError)}`;
1129
1344
  await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, { error });
@@ -1316,163 +1531,6 @@ function createGovernedActionHost(options) {
1316
1531
  spaceId
1317
1532
  });
1318
1533
  }
1319
- async function completeExternalInvocation(actionInvocationId, tenantId, spaceId, completion) {
1320
- if (typeof completion.externalReference !== "string" || completion.externalReference.trim() === "") {
1321
- throw new ExternalCompletionError("invalid_completion", "External completion must name the external reference it completes.");
1322
- }
1323
- if (completion.outcome !== "completed" && completion.outcome !== "failed") {
1324
- throw new ExternalCompletionError("invalid_completion", `External completion outcome must be "completed" or "failed", received "${String(completion.outcome)}".`);
1325
- }
1326
- const atomicStore = asAtomicMutationStore(options.store);
1327
- if (atomicStore) {
1328
- return atomicStore.transactionWithEvents(async (transaction) => {
1329
- const read2 = transaction.getActionInvocationForUpdate ? await transaction.getActionInvocationForUpdate(actionInvocationId, tenantId, spaceId) : await options.store.getActionInvocation(actionInvocationId, tenantId, spaceId);
1330
- return settleExternalCompletion(read2, actionInvocationId, completion, {
1331
- update: (patch) => transaction.updateActionInvocation(actionInvocationId, tenantId, spaceId, patch),
1332
- transaction
1333
- });
1334
- });
1335
- }
1336
- const read = await options.store.getActionInvocation(actionInvocationId, tenantId, spaceId);
1337
- return settleExternalCompletion(read, actionInvocationId, completion, {
1338
- update: (patch) => options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, patch)
1339
- });
1340
- }
1341
- async function settleExternalCompletion(invocation, actionInvocationId, completion, writer) {
1342
- if (!invocation) throw new ExternalCompletionError("not_found", `ActionInvocation not found: ${actionInvocationId}`);
1343
- const recorded = invocation.externalCompletion;
1344
- const stillExecuting = !recorded && (invocation.status === "pending" || invocation.status === "running" && (invocation.leaseOwner !== void 0 || invocation.pendingCompletion?.parkedAt === void 0));
1345
- if (stillExecuting) {
1346
- throw new ExternalCompletionError("still_executing", `ActionInvocation ${actionInvocationId} is still executing; retry the completion after it parks.`);
1347
- }
1348
- if (recorded) {
1349
- if (recorded.externalReference !== completion.externalReference) {
1350
- throw new ExternalCompletionError("reference_mismatch", `ActionInvocation ${actionInvocationId} was completed by "${recorded.externalReference}", not "${completion.externalReference}".`);
1351
- }
1352
- if (completion.provider !== void 0 && recorded.provider !== void 0 && completion.provider !== recorded.provider) {
1353
- throw new ExternalCompletionError("provider_mismatch", `ActionInvocation ${actionInvocationId} was completed by "${recorded.provider}", not "${completion.provider}".`);
1354
- }
1355
- if (recorded.outcome === completion.outcome) {
1356
- return withConsistency(actionResult(invocation), invocation.actionId);
1357
- }
1358
- const message = `External operation "${completion.externalReference}" reported ${completion.outcome} after reporting ${recorded.outcome}.`;
1359
- await recordCompletionReconciliation(invocation, { ...completion, kind: `contradicted:${completion.outcome}` }, message);
1360
- await appendEvent(invocation, {
1361
- eventType: "ExternalOperationContradicted",
1362
- subjectType: "AdapterInvocation",
1363
- subjectId: invocation.pendingCompletion?.adapterInvocationId ?? actionInvocationId,
1364
- payload: { externalReference: completion.externalReference, recorded: recorded.outcome, reported: completion.outcome }
1365
- }, `external:${completion.externalReference}:contradicted:${completion.outcome}`, 1, writer.transaction);
1366
- await writer.update({ status: "reconciliation_required", error: message });
1367
- emitInvocationStatusTelemetry(invocation, "reconciliation_required", options.telemetry, now(), invocation.status);
1368
- return withConsistency(actionResult({ ...invocation, status: "reconciliation_required", error: message }), invocation.actionId);
1369
- }
1370
- const pending = invocation.pendingCompletion;
1371
- if (!pending) {
1372
- const action = actionResolver(invocation.actionId);
1373
- if ((action?.execution?.completion ?? "immediate") === "immediate") {
1374
- throw new ExternalCompletionError("immediate_contract", `Action ${invocation.actionId} declares immediate completion; nothing external completes it.`);
1375
- }
1376
- throw new ExternalCompletionError("not_awaiting", `ActionInvocation ${actionInvocationId} is not awaiting an external completion.`);
1377
- }
1378
- if (pending.externalReference !== completion.externalReference) {
1379
- throw new ExternalCompletionError("reference_mismatch", `ActionInvocation ${actionInvocationId} awaits "${pending.externalReference}", not "${completion.externalReference}".`);
1380
- }
1381
- if (completion.provider !== void 0 && completion.provider !== pending.provider) {
1382
- throw new ExternalCompletionError("provider_mismatch", `ActionInvocation ${actionInvocationId} awaits completion from "${pending.provider}", not "${completion.provider}".`);
1383
- }
1384
- const recordedAt = now();
1385
- const externalCompletion = {
1386
- ...completion,
1387
- provider: completion.provider ?? pending.provider,
1388
- observedAt: completion.observedAt instanceof Date ? completion.observedAt.toISOString() : completion.observedAt,
1389
- recordedAt
1390
- };
1391
- const subject = { subjectType: "AdapterInvocation", subjectId: pending.adapterInvocationId };
1392
- const settles = invocation.status === "running" && pending.parkedAt !== void 0 || invocation.status === "reconciliation_required" && invocation.error?.includes("did not complete by") === true;
1393
- if (completion.outcome === "completed") {
1394
- const action = actionResolver(invocation.actionId);
1395
- const merged = withoutPrivateHostFields({ ...invocation.result, ...completion.result ?? {} }, eventResultFields);
1396
- if (action?.resultSchema) {
1397
- const parsedResult = action.resultSchema.safeParse(merged);
1398
- if (!parsedResult.success) {
1399
- throw new ExternalCompletionError("result_invalid", `External completion result validation failed at ${parsedResult.error.issues.map((issue) => issue.path.map(String).join(".") || "result").join(", ")}`);
1400
- }
1401
- }
1402
- await options.store.updateAdapterInvocation(pending.adapterInvocationId, {
1403
- ...completion.result ? { output: withoutPrivateHostFields(completion.result, eventResultFields) } : {},
1404
- updatedAt: recordedAt
1405
- });
1406
- await appendEvent(invocation, {
1407
- eventType: "ExternalOperationCompleted",
1408
- ...subject,
1409
- payload: { externalReference: pending.externalReference, provider: pending.provider, ...completion.result ? { result: withoutPrivateHostFields(completion.result, eventResultFields) } : {} }
1410
- }, `external:${pending.adapterInvocationId}:completed`, 1, writer.transaction);
1411
- const patch2 = settles ? { status: "completed", result: merged, externalCompletion, pendingCompletion: void 0, error: void 0 } : { externalCompletion, pendingCompletion: void 0 };
1412
- await writer.update(patch2);
1413
- if (settles) emitInvocationStatusTelemetry(invocation, "completed", options.telemetry, now(), invocation.status);
1414
- return withConsistency(actionResult({ ...invocation, ...patch2, result: settles ? merged : invocation.result, ...settles ? { error: void 0 } : {} }), invocation.actionId);
1415
- }
1416
- const error = completion.error ?? `External operation "${pending.externalReference}" failed.`;
1417
- await options.store.updateAdapterInvocation(pending.adapterInvocationId, { status: "failed", error, updatedAt: recordedAt });
1418
- await appendEvent(invocation, {
1419
- eventType: "ExternalOperationFailed",
1420
- ...subject,
1421
- payload: { externalReference: pending.externalReference, provider: pending.provider, error }
1422
- }, `external:${pending.adapterInvocationId}:failed`, 1, writer.transaction);
1423
- const patch = settles ? { status: "failed", error, externalCompletion, pendingCompletion: void 0 } : { externalCompletion, pendingCompletion: void 0 };
1424
- await writer.update(patch);
1425
- if (settles) emitInvocationStatusTelemetry(invocation, "failed", options.telemetry, now(), invocation.status);
1426
- return withConsistency(actionResult({ ...invocation, ...patch }), invocation.actionId);
1427
- }
1428
- async function recordCompletionReconciliation(invocation, completion, reason) {
1429
- const governanceStore = asGovernanceStore(options.store);
1430
- if (!governanceStore) throw new Error("External completion reconciliation requires a governance-capable store.");
1431
- const provider = completion.provider ?? invocation.pendingCompletion?.provider ?? invocation.externalCompletion?.provider ?? "external";
1432
- const seed = crypto.createHash("sha256").update(`${provider}\0${completion.externalReference}\0${completion.kind ?? "refused"}`).digest("hex").slice(0, 32);
1433
- await governanceStore.appendExternalReconciliation({
1434
- id: lifecycleId("rec", invocation.id, `ext:${seed}`),
1435
- actionInvocationId: invocation.id,
1436
- tenantId: invocation.tenantId,
1437
- spaceId: invocation.spaceId,
1438
- status: "pending",
1439
- provider,
1440
- externalOperationId: completion.externalReference,
1441
- attempt: 1,
1442
- reason,
1443
- ...completion.evidenceReferences ? { evidenceReferences: completion.evidenceReferences } : {},
1444
- observedAt: now()
1445
- });
1446
- }
1447
- async function reconcileOverdueCompletions(input = {}) {
1448
- const recoverable = options.store;
1449
- if (!recoverable.listActionInvocations) {
1450
- throw new Error("Overdue completion reconciliation requires a store that can list invocations.");
1451
- }
1452
- if (!asGovernanceStore(options.store)) {
1453
- throw new Error("Overdue completion reconciliation requires a governance-capable store to record its findings.");
1454
- }
1455
- const current = input.now ?? now();
1456
- const candidates = await recoverable.listActionInvocations({
1457
- statuses: ["running"],
1458
- completionDueBefore: current,
1459
- unleased: true,
1460
- ...input.tenantId ? { tenantId: input.tenantId } : {},
1461
- ...input.spaceId ? { spaceId: input.spaceId } : {},
1462
- limit: Math.max(1, Math.min(input.limit ?? 100, 1e3))
1463
- });
1464
- const overdue = [];
1465
- for (const invocation of candidates) {
1466
- const pending = invocation.pendingCompletion;
1467
- if (!pending?.dueAt || pending.dueAt.getTime() > current.getTime()) continue;
1468
- if (invocation.leaseOwner) continue;
1469
- const message = `External operation "${pending.externalReference}" from ${pending.provider} did not complete by ${pending.dueAt.toISOString()}.`;
1470
- await recordCompletionReconciliation(invocation, { externalReference: pending.externalReference, provider: pending.provider, kind: "overdue" }, message);
1471
- await persistInvocation(invocation, { status: "reconciliation_required", error: message });
1472
- overdue.push(invocation.id);
1473
- }
1474
- return { overdue };
1475
- }
1476
1534
  function declaredConsistency(actionId) {
1477
1535
  return actionResolver(actionId)?.execution?.consistency;
1478
1536
  }
@@ -1569,7 +1627,26 @@ function createGovernedActionHost(options) {
1569
1627
  );
1570
1628
  emitInvocationStatusTelemetry(invocation, patch.status, options.telemetry, now(), previousStatus);
1571
1629
  }
1572
- return { submitAction, executeInvocation, resumeApprovedInvocation, recordExecutionAttestation, recordExternalReconciliation, completeExternalInvocation, reconcileOverdueCompletions };
1630
+ const externalCompletion = createExternalCompletionLifecycle({
1631
+ store: options.store,
1632
+ telemetry: options.telemetry,
1633
+ eventResultFields,
1634
+ now,
1635
+ actionResolver,
1636
+ appendEvent,
1637
+ persistInvocation,
1638
+ actionResult,
1639
+ withConsistency
1640
+ });
1641
+ return {
1642
+ submitAction,
1643
+ executeInvocation,
1644
+ resumeApprovedInvocation,
1645
+ recordExecutionAttestation,
1646
+ recordExternalReconciliation,
1647
+ completeExternalInvocation: externalCompletion.completeExternalInvocation,
1648
+ reconcileOverdueCompletions: externalCompletion.reconcileOverdueCompletions
1649
+ };
1573
1650
  }
1574
1651
  function actionResult(invocation) {
1575
1652
  return {
@@ -1588,18 +1665,10 @@ function asApprovalStore(store) {
1588
1665
  const candidate = store;
1589
1666
  return typeof candidate.recordHitlDecision === "function" && typeof candidate.beginApprovalDecision === "function" ? store : void 0;
1590
1667
  }
1591
- function asAtomicMutationStore(store) {
1592
- const candidate = store;
1593
- return typeof candidate.transactionWithEvents === "function" ? store : void 0;
1594
- }
1595
1668
  function asOutboxStore(store) {
1596
1669
  const candidate = store;
1597
1670
  return typeof candidate.appendEventWithOutbox === "function" && typeof candidate.claimOutbox === "function" ? store : void 0;
1598
1671
  }
1599
- function asGovernanceStore(store) {
1600
- const candidate = store;
1601
- return typeof candidate.recordMutationGovernance === "function" && typeof candidate.appendPolicyObligations === "function" && typeof candidate.appendExecutionAttestation === "function" ? store : void 0;
1602
- }
1603
1672
  function numberValue(value) {
1604
1673
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
1605
1674
  }
@@ -1635,39 +1704,9 @@ function initialState(machine) {
1635
1704
  function isTerminal(status) {
1636
1705
  return ["completed", "failed", "blocked_by_policy", "reconciliation_required", "validation_failed"].includes(status);
1637
1706
  }
1638
- function emitInvocationStatusTelemetry(invocation, status, telemetry, occurredAt, previousStatus) {
1639
- if (!status) return;
1640
- const event = status === "completed" ? { name: "invocation.completed", metricName: PLATFORM_HOST_METRIC_NAMES.invocationCompleted } : status === "blocked_by_policy" ? { name: "invocation.policy_blocked", metricName: PLATFORM_HOST_METRIC_NAMES.invocationPolicyBlocked } : status === "validation_failed" ? { name: "invocation.validation_failed", metricName: PLATFORM_HOST_METRIC_NAMES.invocationValidationFailed } : status === "waiting_for_approval" ? { name: "invocation.approval_waited", metricName: PLATFORM_HOST_METRIC_NAMES.invocationApprovalWaited } : status === "reconciliation_required" ? { name: "invocation.reconciliation_required", metricName: PLATFORM_HOST_METRIC_NAMES.invocationReconciliationRequired } : status === "failed" ? { name: "invocation.failed", metricName: PLATFORM_HOST_METRIC_NAMES.invocationFailed } : void 0;
1641
- if (!event) return;
1642
- emitPlatformHostTelemetry(telemetry, {
1643
- kind: "event",
1644
- name: event.name,
1645
- metricName: event.metricName,
1646
- occurredAt,
1647
- tenantId: invocation.tenantId,
1648
- spaceId: invocation.spaceId,
1649
- attributes: {
1650
- ...invocation.actionId ? { actionId: invocation.actionId } : {},
1651
- ...invocation.actionVersion !== void 0 ? { actionVersion: invocation.actionVersion } : {},
1652
- ...previousStatus ? { fromStatus: previousStatus } : {}
1653
- }
1654
- });
1655
- }
1656
- function withoutPrivateHostFields(data, eventResultFields) {
1657
- return Object.fromEntries(
1658
- Object.entries(data).filter(([key]) => !eventResultFields.includes(key))
1659
- );
1660
- }
1661
- function errorMessage(error) {
1662
- return error instanceof Error ? error.message : String(error);
1663
- }
1664
1707
  function isRecord(value) {
1665
1708
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
1666
1709
  }
1667
- function lifecycleId(prefix, invocationId, key) {
1668
- const safeKey = key.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 96);
1669
- return `${prefix}_${invocationId}_${safeKey}`;
1670
- }
1671
1710
  function validateAuthorizationBinding(input, parameterDigest, authorityMoment, currentTime = /* @__PURE__ */ new Date()) {
1672
1711
  const binding = input.authorizationBinding;
1673
1712
  if (binding.id !== input.authorizationBindingId && input.authorizationBindingId) throw new Error("Authorization binding ID does not match authorizationBindingId.");