@fabricorg/platform-host 7.1.1 → 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 +6 -0
- package/dist/index.cjs +237 -210
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +237 -210
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
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
|
+
|
|
3
9
|
## 7.1.1
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
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,175 +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
|
-
const incomingDigest = completionDigest(completion, recorded.provider ?? completion.provider);
|
|
1356
|
-
if (recorded.digest === incomingDigest) {
|
|
1357
|
-
return withConsistency(actionResult(invocation), invocation.actionId);
|
|
1358
|
-
}
|
|
1359
|
-
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}.`;
|
|
1360
|
-
await recordCompletionReconciliation(invocation, { ...completion, kind: `contradicted:${incomingDigest.slice(0, 16)}` }, message);
|
|
1361
|
-
await appendEvent(invocation, {
|
|
1362
|
-
eventType: "ExternalOperationContradicted",
|
|
1363
|
-
subjectType: "AdapterInvocation",
|
|
1364
|
-
subjectId: invocation.pendingCompletion?.adapterInvocationId ?? actionInvocationId,
|
|
1365
|
-
payload: { externalReference: completion.externalReference, recorded: recorded.outcome, reported: completion.outcome }
|
|
1366
|
-
}, `external:${completion.externalReference}:contradicted:${completion.outcome}`, 1, writer.transaction);
|
|
1367
|
-
await writer.update({ status: "reconciliation_required", error: message });
|
|
1368
|
-
emitInvocationStatusTelemetry(invocation, "reconciliation_required", options.telemetry, now(), invocation.status);
|
|
1369
|
-
return withConsistency(actionResult({ ...invocation, status: "reconciliation_required", error: message }), invocation.actionId);
|
|
1370
|
-
}
|
|
1371
|
-
const pending = invocation.pendingCompletion;
|
|
1372
|
-
if (!pending) {
|
|
1373
|
-
const action = actionResolver(invocation.actionId);
|
|
1374
|
-
if ((action?.execution?.completion ?? "immediate") === "immediate") {
|
|
1375
|
-
throw new ExternalCompletionError("immediate_contract", `Action ${invocation.actionId} declares immediate completion; nothing external completes it.`);
|
|
1376
|
-
}
|
|
1377
|
-
throw new ExternalCompletionError("not_awaiting", `ActionInvocation ${actionInvocationId} is not awaiting an external completion.`);
|
|
1378
|
-
}
|
|
1379
|
-
if (pending.externalReference !== completion.externalReference) {
|
|
1380
|
-
throw new ExternalCompletionError("reference_mismatch", `ActionInvocation ${actionInvocationId} awaits "${pending.externalReference}", not "${completion.externalReference}".`);
|
|
1381
|
-
}
|
|
1382
|
-
if (completion.provider !== void 0 && completion.provider !== pending.provider) {
|
|
1383
|
-
throw new ExternalCompletionError("provider_mismatch", `ActionInvocation ${actionInvocationId} awaits completion from "${pending.provider}", not "${completion.provider}".`);
|
|
1384
|
-
}
|
|
1385
|
-
const recordedAt = now();
|
|
1386
|
-
const externalCompletion = {
|
|
1387
|
-
...completion,
|
|
1388
|
-
provider: completion.provider ?? pending.provider,
|
|
1389
|
-
observedAt: completion.observedAt instanceof Date ? completion.observedAt.toISOString() : completion.observedAt,
|
|
1390
|
-
recordedAt,
|
|
1391
|
-
digest: completionDigest(completion, completion.provider ?? pending.provider)
|
|
1392
|
-
};
|
|
1393
|
-
const subject = { subjectType: "AdapterInvocation", subjectId: pending.adapterInvocationId };
|
|
1394
|
-
const settles = invocation.status === "running" && pending.parkedAt !== void 0 || invocation.status === "reconciliation_required" && invocation.error?.includes("did not complete by") === true;
|
|
1395
|
-
if (completion.outcome === "completed") {
|
|
1396
|
-
const action = actionResolver(invocation.actionId);
|
|
1397
|
-
const merged = withoutPrivateHostFields({ ...invocation.result, ...completion.result ?? {} }, eventResultFields);
|
|
1398
|
-
if (action?.resultSchema) {
|
|
1399
|
-
const parsedResult = action.resultSchema.safeParse(merged);
|
|
1400
|
-
if (!parsedResult.success) {
|
|
1401
|
-
throw new ExternalCompletionError("result_invalid", `External completion result validation failed at ${parsedResult.error.issues.map((issue) => issue.path.map(String).join(".") || "result").join(", ")}`);
|
|
1402
|
-
}
|
|
1403
|
-
}
|
|
1404
|
-
await options.store.updateAdapterInvocation(pending.adapterInvocationId, {
|
|
1405
|
-
...completion.result ? { output: withoutPrivateHostFields(completion.result, eventResultFields) } : {},
|
|
1406
|
-
updatedAt: recordedAt
|
|
1407
|
-
});
|
|
1408
|
-
await appendEvent(invocation, {
|
|
1409
|
-
eventType: "ExternalOperationCompleted",
|
|
1410
|
-
...subject,
|
|
1411
|
-
payload: { externalReference: pending.externalReference, provider: pending.provider, ...completion.result ? { result: withoutPrivateHostFields(completion.result, eventResultFields) } : {} }
|
|
1412
|
-
}, `external:${pending.adapterInvocationId}:completed`, 1, writer.transaction);
|
|
1413
|
-
const patch2 = settles ? { status: "completed", result: merged, externalCompletion, pendingCompletion: void 0, error: void 0 } : { externalCompletion, pendingCompletion: void 0 };
|
|
1414
|
-
await writer.update(patch2);
|
|
1415
|
-
if (settles) emitInvocationStatusTelemetry(invocation, "completed", options.telemetry, now(), invocation.status);
|
|
1416
|
-
return withConsistency(actionResult({ ...invocation, ...patch2, result: settles ? merged : invocation.result, ...settles ? { error: void 0 } : {} }), invocation.actionId);
|
|
1417
|
-
}
|
|
1418
|
-
const error = completion.error ?? `External operation "${pending.externalReference}" failed.`;
|
|
1419
|
-
await options.store.updateAdapterInvocation(pending.adapterInvocationId, { status: "failed", error, updatedAt: recordedAt });
|
|
1420
|
-
await appendEvent(invocation, {
|
|
1421
|
-
eventType: "ExternalOperationFailed",
|
|
1422
|
-
...subject,
|
|
1423
|
-
payload: { externalReference: pending.externalReference, provider: pending.provider, error }
|
|
1424
|
-
}, `external:${pending.adapterInvocationId}:failed`, 1, writer.transaction);
|
|
1425
|
-
const patch = settles ? { status: "failed", error, externalCompletion, pendingCompletion: void 0 } : { externalCompletion, pendingCompletion: void 0 };
|
|
1426
|
-
await writer.update(patch);
|
|
1427
|
-
if (settles) emitInvocationStatusTelemetry(invocation, "failed", options.telemetry, now(), invocation.status);
|
|
1428
|
-
return withConsistency(actionResult({ ...invocation, ...patch }), invocation.actionId);
|
|
1429
|
-
}
|
|
1430
|
-
function completionDigest(completion, provider) {
|
|
1431
|
-
return crypto.createHash("sha256").update(canonicalJson({
|
|
1432
|
-
provider: provider ?? null,
|
|
1433
|
-
externalReference: completion.externalReference,
|
|
1434
|
-
outcome: completion.outcome,
|
|
1435
|
-
result: completion.result ?? null,
|
|
1436
|
-
error: completion.error ?? null,
|
|
1437
|
-
evidenceReferences: completion.evidenceReferences ?? null
|
|
1438
|
-
})).digest("hex");
|
|
1439
|
-
}
|
|
1440
|
-
async function recordCompletionReconciliation(invocation, completion, reason) {
|
|
1441
|
-
const governanceStore = asGovernanceStore(options.store);
|
|
1442
|
-
if (!governanceStore) throw new Error("External completion reconciliation requires a governance-capable store.");
|
|
1443
|
-
const provider = completion.provider ?? invocation.pendingCompletion?.provider ?? invocation.externalCompletion?.provider ?? "external";
|
|
1444
|
-
const seed = crypto.createHash("sha256").update(`${provider}\0${completion.externalReference}\0${completion.kind ?? "refused"}`).digest("hex").slice(0, 32);
|
|
1445
|
-
await governanceStore.appendExternalReconciliation({
|
|
1446
|
-
id: lifecycleId("rec", invocation.id, `ext:${seed}`),
|
|
1447
|
-
actionInvocationId: invocation.id,
|
|
1448
|
-
tenantId: invocation.tenantId,
|
|
1449
|
-
spaceId: invocation.spaceId,
|
|
1450
|
-
status: "pending",
|
|
1451
|
-
provider,
|
|
1452
|
-
externalOperationId: completion.externalReference,
|
|
1453
|
-
attempt: 1,
|
|
1454
|
-
reason,
|
|
1455
|
-
...completion.evidenceReferences ? { evidenceReferences: completion.evidenceReferences } : {},
|
|
1456
|
-
observedAt: now()
|
|
1457
|
-
});
|
|
1458
|
-
}
|
|
1459
|
-
async function reconcileOverdueCompletions(input = {}) {
|
|
1460
|
-
const recoverable = options.store;
|
|
1461
|
-
if (!recoverable.listActionInvocations) {
|
|
1462
|
-
throw new Error("Overdue completion reconciliation requires a store that can list invocations.");
|
|
1463
|
-
}
|
|
1464
|
-
if (!asGovernanceStore(options.store)) {
|
|
1465
|
-
throw new Error("Overdue completion reconciliation requires a governance-capable store to record its findings.");
|
|
1466
|
-
}
|
|
1467
|
-
const current = input.now ?? now();
|
|
1468
|
-
const candidates = await recoverable.listActionInvocations({
|
|
1469
|
-
statuses: ["running"],
|
|
1470
|
-
completionDueBefore: current,
|
|
1471
|
-
unleased: true,
|
|
1472
|
-
...input.tenantId ? { tenantId: input.tenantId } : {},
|
|
1473
|
-
...input.spaceId ? { spaceId: input.spaceId } : {},
|
|
1474
|
-
limit: Math.max(1, Math.min(input.limit ?? 100, 1e3))
|
|
1475
|
-
});
|
|
1476
|
-
const overdue = [];
|
|
1477
|
-
for (const invocation of candidates) {
|
|
1478
|
-
const pending = invocation.pendingCompletion;
|
|
1479
|
-
if (!pending?.dueAt || pending.dueAt.getTime() > current.getTime()) continue;
|
|
1480
|
-
if (invocation.leaseOwner) continue;
|
|
1481
|
-
const message = `External operation "${pending.externalReference}" from ${pending.provider} did not complete by ${pending.dueAt.toISOString()}.`;
|
|
1482
|
-
await recordCompletionReconciliation(invocation, { externalReference: pending.externalReference, provider: pending.provider, kind: "overdue" }, message);
|
|
1483
|
-
await persistInvocation(invocation, { status: "reconciliation_required", error: message });
|
|
1484
|
-
overdue.push(invocation.id);
|
|
1485
|
-
}
|
|
1486
|
-
return { overdue };
|
|
1487
|
-
}
|
|
1488
1534
|
function declaredConsistency(actionId) {
|
|
1489
1535
|
return actionResolver(actionId)?.execution?.consistency;
|
|
1490
1536
|
}
|
|
@@ -1581,7 +1627,26 @@ function createGovernedActionHost(options) {
|
|
|
1581
1627
|
);
|
|
1582
1628
|
emitInvocationStatusTelemetry(invocation, patch.status, options.telemetry, now(), previousStatus);
|
|
1583
1629
|
}
|
|
1584
|
-
|
|
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
|
+
};
|
|
1585
1650
|
}
|
|
1586
1651
|
function actionResult(invocation) {
|
|
1587
1652
|
return {
|
|
@@ -1600,18 +1665,10 @@ function asApprovalStore(store) {
|
|
|
1600
1665
|
const candidate = store;
|
|
1601
1666
|
return typeof candidate.recordHitlDecision === "function" && typeof candidate.beginApprovalDecision === "function" ? store : void 0;
|
|
1602
1667
|
}
|
|
1603
|
-
function asAtomicMutationStore(store) {
|
|
1604
|
-
const candidate = store;
|
|
1605
|
-
return typeof candidate.transactionWithEvents === "function" ? store : void 0;
|
|
1606
|
-
}
|
|
1607
1668
|
function asOutboxStore(store) {
|
|
1608
1669
|
const candidate = store;
|
|
1609
1670
|
return typeof candidate.appendEventWithOutbox === "function" && typeof candidate.claimOutbox === "function" ? store : void 0;
|
|
1610
1671
|
}
|
|
1611
|
-
function asGovernanceStore(store) {
|
|
1612
|
-
const candidate = store;
|
|
1613
|
-
return typeof candidate.recordMutationGovernance === "function" && typeof candidate.appendPolicyObligations === "function" && typeof candidate.appendExecutionAttestation === "function" ? store : void 0;
|
|
1614
|
-
}
|
|
1615
1672
|
function numberValue(value) {
|
|
1616
1673
|
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
1617
1674
|
}
|
|
@@ -1647,39 +1704,9 @@ function initialState(machine) {
|
|
|
1647
1704
|
function isTerminal(status) {
|
|
1648
1705
|
return ["completed", "failed", "blocked_by_policy", "reconciliation_required", "validation_failed"].includes(status);
|
|
1649
1706
|
}
|
|
1650
|
-
function emitInvocationStatusTelemetry(invocation, status, telemetry, occurredAt, previousStatus) {
|
|
1651
|
-
if (!status) return;
|
|
1652
|
-
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;
|
|
1653
|
-
if (!event) return;
|
|
1654
|
-
emitPlatformHostTelemetry(telemetry, {
|
|
1655
|
-
kind: "event",
|
|
1656
|
-
name: event.name,
|
|
1657
|
-
metricName: event.metricName,
|
|
1658
|
-
occurredAt,
|
|
1659
|
-
tenantId: invocation.tenantId,
|
|
1660
|
-
spaceId: invocation.spaceId,
|
|
1661
|
-
attributes: {
|
|
1662
|
-
...invocation.actionId ? { actionId: invocation.actionId } : {},
|
|
1663
|
-
...invocation.actionVersion !== void 0 ? { actionVersion: invocation.actionVersion } : {},
|
|
1664
|
-
...previousStatus ? { fromStatus: previousStatus } : {}
|
|
1665
|
-
}
|
|
1666
|
-
});
|
|
1667
|
-
}
|
|
1668
|
-
function withoutPrivateHostFields(data, eventResultFields) {
|
|
1669
|
-
return Object.fromEntries(
|
|
1670
|
-
Object.entries(data).filter(([key]) => !eventResultFields.includes(key))
|
|
1671
|
-
);
|
|
1672
|
-
}
|
|
1673
|
-
function errorMessage(error) {
|
|
1674
|
-
return error instanceof Error ? error.message : String(error);
|
|
1675
|
-
}
|
|
1676
1707
|
function isRecord(value) {
|
|
1677
1708
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
1678
1709
|
}
|
|
1679
|
-
function lifecycleId(prefix, invocationId, key) {
|
|
1680
|
-
const safeKey = key.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 96);
|
|
1681
|
-
return `${prefix}_${invocationId}_${safeKey}`;
|
|
1682
|
-
}
|
|
1683
1710
|
function validateAuthorizationBinding(input, parameterDigest, authorityMoment, currentTime = /* @__PURE__ */ new Date()) {
|
|
1684
1711
|
const binding = input.authorizationBinding;
|
|
1685
1712
|
if (binding.id !== input.authorizationBindingId && input.authorizationBindingId) throw new Error("Authorization binding ID does not match authorizationBindingId.");
|