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