@fabricorg/platform-host 7.3.0 → 7.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +80 -0
- package/README.md +236 -15
- package/dist/index.cjs +416 -80
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +135 -17
- package/dist/index.d.ts +135 -17
- package/dist/index.js +413 -79
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.cjs
CHANGED
|
@@ -45,18 +45,8 @@ var ExternalCompletionError = class extends Error {
|
|
|
45
45
|
}
|
|
46
46
|
};
|
|
47
47
|
var PARAMETER_DIGEST_ALGORITHM = "fabric-canonical-json-sha256-v1";
|
|
48
|
-
function canonicalJson(value) {
|
|
49
|
-
return JSON.stringify(sort(value));
|
|
50
|
-
}
|
|
51
|
-
function sort(value) {
|
|
52
|
-
if (Array.isArray(value)) return value.map(sort);
|
|
53
|
-
if (value && typeof value === "object") {
|
|
54
|
-
return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([key, child]) => [key, sort(child)]));
|
|
55
|
-
}
|
|
56
|
-
return value;
|
|
57
|
-
}
|
|
58
48
|
function digestParameters(parameters) {
|
|
59
|
-
return crypto.createHash("sha256").update(canonicalJson(parameters)).digest("hex");
|
|
49
|
+
return crypto.createHash("sha256").update(platform.canonicalJson(parameters)).digest("hex");
|
|
60
50
|
}
|
|
61
51
|
var ATTRIBUTE_NAME = /^[a-z][a-z0-9-]*(?:\.[A-Za-z][A-Za-z0-9_-]*)+$/;
|
|
62
52
|
var INVOCATION_SOURCE = /^(?:ui|sdui|api|agent|worker|system|[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+)$/;
|
|
@@ -319,6 +309,24 @@ function withoutPrivateHostFields(data, eventResultFields) {
|
|
|
319
309
|
function errorMessage(error) {
|
|
320
310
|
return error instanceof Error ? error.message : String(error);
|
|
321
311
|
}
|
|
312
|
+
var HostError = class extends Error {
|
|
313
|
+
};
|
|
314
|
+
function isRecord(value) {
|
|
315
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
316
|
+
}
|
|
317
|
+
function tryRedactErrorText(hook, context, message) {
|
|
318
|
+
if (!hook || message === "") return { success: true, text: message };
|
|
319
|
+
try {
|
|
320
|
+
const text = hook(context, message);
|
|
321
|
+
if (typeof text === "string" && text.length > 0) return { success: true, text };
|
|
322
|
+
} catch {
|
|
323
|
+
}
|
|
324
|
+
return { success: false };
|
|
325
|
+
}
|
|
326
|
+
function redactErrorText(hook, context, message) {
|
|
327
|
+
const result = tryRedactErrorText(hook, context, message);
|
|
328
|
+
return result.success ? result.text : "Error text withheld: the error-text redactor failed.";
|
|
329
|
+
}
|
|
322
330
|
function lifecycleId(prefix, invocationId, key) {
|
|
323
331
|
const safeKey = key.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 96);
|
|
324
332
|
return `${prefix}_${invocationId}_${safeKey}`;
|
|
@@ -381,7 +389,11 @@ function createExternalCompletionLifecycle(deps) {
|
|
|
381
389
|
if (completion.provider !== void 0 && recorded.provider !== void 0 && completion.provider !== recorded.provider) {
|
|
382
390
|
throw new ExternalCompletionError("provider_mismatch", `ActionInvocation ${actionInvocationId} was completed by "${recorded.provider}", not "${completion.provider}".`);
|
|
383
391
|
}
|
|
384
|
-
const
|
|
392
|
+
const provider = recorded.provider ?? completion.provider;
|
|
393
|
+
const rawDigest = completionDigest(completion, provider);
|
|
394
|
+
if (recorded.digest === rawDigest) return withConsistency(actionResult2(invocation), invocation.actionId);
|
|
395
|
+
completion = redactCompletion(invocation, completion, provider ?? "external");
|
|
396
|
+
const incomingDigest = completionDigest(completion, provider);
|
|
385
397
|
if (recorded.digest === incomingDigest) {
|
|
386
398
|
return withConsistency(actionResult2(invocation), invocation.actionId);
|
|
387
399
|
}
|
|
@@ -411,8 +423,10 @@ function createExternalCompletionLifecycle(deps) {
|
|
|
411
423
|
if (completion.provider !== void 0 && completion.provider !== pending.provider) {
|
|
412
424
|
throw new ExternalCompletionError("provider_mismatch", `ActionInvocation ${actionInvocationId} awaits completion from "${pending.provider}", not "${completion.provider}".`);
|
|
413
425
|
}
|
|
426
|
+
completion = redactCompletion(invocation, completion, completion.provider ?? pending.provider);
|
|
414
427
|
const recordedAt = now();
|
|
415
428
|
const externalCompletion = {
|
|
429
|
+
// completion has passed the hooks; retain the callback shape for compatibility.
|
|
416
430
|
...completion,
|
|
417
431
|
provider: completion.provider ?? pending.provider,
|
|
418
432
|
observedAt: completion.observedAt instanceof Date ? completion.observedAt.toISOString() : completion.observedAt,
|
|
@@ -433,7 +447,7 @@ function createExternalCompletionLifecycle(deps) {
|
|
|
433
447
|
await deps.store.updateAdapterInvocation(pending.adapterInvocationId, {
|
|
434
448
|
...completion.result ? { output: withoutPrivateHostFields(completion.result, eventResultFields) } : {},
|
|
435
449
|
updatedAt: recordedAt
|
|
436
|
-
});
|
|
450
|
+
}, { tenantId: invocation.tenantId, spaceId: invocation.spaceId, actionInvocationId: invocation.id });
|
|
437
451
|
await appendEvent(invocation, {
|
|
438
452
|
eventType: "ExternalOperationCompleted",
|
|
439
453
|
...subject,
|
|
@@ -445,7 +459,7 @@ function createExternalCompletionLifecycle(deps) {
|
|
|
445
459
|
return withConsistency(actionResult2({ ...invocation, ...patch2, result: settles ? merged : invocation.result, ...settles ? { error: void 0 } : {} }), invocation.actionId);
|
|
446
460
|
}
|
|
447
461
|
const error = completion.error ?? `External operation "${pending.externalReference}" failed.`;
|
|
448
|
-
await deps.store.updateAdapterInvocation(pending.adapterInvocationId, { status: "failed", error, updatedAt: recordedAt });
|
|
462
|
+
await deps.store.updateAdapterInvocation(pending.adapterInvocationId, { status: "failed", error, updatedAt: recordedAt }, { tenantId: invocation.tenantId, spaceId: invocation.spaceId, actionInvocationId: invocation.id });
|
|
449
463
|
await appendEvent(invocation, {
|
|
450
464
|
eventType: "ExternalOperationFailed",
|
|
451
465
|
...subject,
|
|
@@ -456,8 +470,32 @@ function createExternalCompletionLifecycle(deps) {
|
|
|
456
470
|
if (settles) emitInvocationStatusTelemetry(invocation, "failed", deps.telemetry, now(), invocation.status);
|
|
457
471
|
return withConsistency(actionResult2({ ...invocation, ...patch }), invocation.actionId);
|
|
458
472
|
}
|
|
473
|
+
function redactCompletion(invocation, completion, provider) {
|
|
474
|
+
let result = completion.result;
|
|
475
|
+
if (result && deps.redactExternalCompletionResult) {
|
|
476
|
+
try {
|
|
477
|
+
result = deps.redactExternalCompletionResult(invocation.actionId, provider, result);
|
|
478
|
+
if (!isRecord(result)) throw new HostError("Completion result redactor must return a non-null, non-array object.");
|
|
479
|
+
} catch {
|
|
480
|
+
throw new ExternalCompletionError("redaction_failed", "External completion result withheld: the result redactor failed; nothing was recorded.");
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
const error = completion.error == null ? void 0 : tryRedactErrorText(
|
|
484
|
+
deps.redactErrorText,
|
|
485
|
+
{ actionId: invocation.actionId, source: "external_completion", provider },
|
|
486
|
+
completion.error
|
|
487
|
+
);
|
|
488
|
+
if (error && !error.success) {
|
|
489
|
+
throw new ExternalCompletionError("redaction_failed", "External completion error withheld: the error-text redactor failed; nothing was recorded.");
|
|
490
|
+
}
|
|
491
|
+
return {
|
|
492
|
+
...completion,
|
|
493
|
+
...completion.result !== void 0 ? { result } : {},
|
|
494
|
+
...error?.success ? { error: error.text } : {}
|
|
495
|
+
};
|
|
496
|
+
}
|
|
459
497
|
function completionDigest(completion, provider) {
|
|
460
|
-
return crypto.createHash("sha256").update(canonicalJson({
|
|
498
|
+
return crypto.createHash("sha256").update(platform.canonicalJson({
|
|
461
499
|
provider: provider ?? null,
|
|
462
500
|
externalReference: completion.externalReference,
|
|
463
501
|
outcome: completion.outcome,
|
|
@@ -468,7 +506,7 @@ function createExternalCompletionLifecycle(deps) {
|
|
|
468
506
|
}
|
|
469
507
|
async function recordCompletionReconciliation(invocation, completion, reason) {
|
|
470
508
|
const governanceStore = asGovernanceStore(deps.store);
|
|
471
|
-
if (!governanceStore) throw new
|
|
509
|
+
if (!governanceStore) throw new HostError("External completion reconciliation requires a governance-capable store.");
|
|
472
510
|
const provider = completion.provider ?? invocation.pendingCompletion?.provider ?? invocation.externalCompletion?.provider ?? "external";
|
|
473
511
|
const seed = crypto.createHash("sha256").update(`${provider}\0${completion.externalReference}\0${completion.kind ?? "refused"}`).digest("hex").slice(0, 32);
|
|
474
512
|
await governanceStore.appendExternalReconciliation({
|
|
@@ -488,10 +526,10 @@ function createExternalCompletionLifecycle(deps) {
|
|
|
488
526
|
async function reconcileOverdueCompletions(input = {}) {
|
|
489
527
|
const recoverable = deps.store;
|
|
490
528
|
if (!recoverable.listActionInvocations) {
|
|
491
|
-
throw new
|
|
529
|
+
throw new HostError("Overdue completion reconciliation requires a store that can list invocations.");
|
|
492
530
|
}
|
|
493
531
|
if (!asGovernanceStore(deps.store)) {
|
|
494
|
-
throw new
|
|
532
|
+
throw new HostError("Overdue completion reconciliation requires a governance-capable store to record its findings.");
|
|
495
533
|
}
|
|
496
534
|
const current = input.now ?? now();
|
|
497
535
|
const candidates = await recoverable.listActionInvocations({
|
|
@@ -866,6 +904,10 @@ var ADMISSION_FAILED = Object.freeze({ kind: "failed" });
|
|
|
866
904
|
var ADMISSION_TIMED_OUT = Object.freeze({ kind: "timed_out" });
|
|
867
905
|
var ADMISSION_REFUSAL_CODE = /^[a-z][A-Za-z0-9_.:-]{0,127}$/;
|
|
868
906
|
function createGovernedActionHost(options) {
|
|
907
|
+
const retryableExecutionMaxAttempts = options.retryableExecutionMaxAttempts ?? 5;
|
|
908
|
+
if (!Number.isInteger(retryableExecutionMaxAttempts) || retryableExecutionMaxAttempts <= 0) {
|
|
909
|
+
throw new Error("retryableExecutionMaxAttempts must be a positive integer.");
|
|
910
|
+
}
|
|
869
911
|
if (options.composition) {
|
|
870
912
|
if (!options.registry?.orderedModules) {
|
|
871
913
|
throw new Error("Composition-bound Platform Host requires an explicit module registry.");
|
|
@@ -886,8 +928,12 @@ function createGovernedActionHost(options) {
|
|
|
886
928
|
}
|
|
887
929
|
}
|
|
888
930
|
const adapters = new platform.AdapterRegistry();
|
|
889
|
-
for (const adapter of options.adapters ?? [])
|
|
931
|
+
for (const adapter of options.adapters ?? []) {
|
|
932
|
+
if (adapter.circuitBreaker) platform.assertAdapterCircuitBreakerConfig(adapter.circuitBreaker);
|
|
933
|
+
adapters.register(adapter);
|
|
934
|
+
}
|
|
890
935
|
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
936
|
+
const circuitBreakers = options.adapterCircuitBreakers ?? new platform.AdapterCircuitBreakerRegistry({ now: () => now().getTime() });
|
|
891
937
|
const extractEvents = options.extractEvents ?? DEFAULT_EXTRACT_EVENTS;
|
|
892
938
|
const configuredActionResolver = options.resolveAction ?? options.registry?.resolveAction;
|
|
893
939
|
if (!configuredActionResolver) throw new Error("Platform Host requires an explicit module registry or resolveAction function.");
|
|
@@ -898,6 +944,9 @@ function createGovernedActionHost(options) {
|
|
|
898
944
|
if (typeof admissionTimeoutMs !== "number" || !Number.isInteger(admissionTimeoutMs) || admissionTimeoutMs < 1 || admissionTimeoutMs > MAX_TIMER_MS) {
|
|
899
945
|
throw new Error(`admissionTimeoutMs must be a whole number of milliseconds from 1 to ${MAX_TIMER_MS}.`);
|
|
900
946
|
}
|
|
947
|
+
function caughtError(context, error) {
|
|
948
|
+
return error instanceof HostError ? errorMessage(error) : redactErrorText(options.redactErrorText, context, errorMessage(error));
|
|
949
|
+
}
|
|
901
950
|
async function submitAction(input) {
|
|
902
951
|
const read = snapshotSubmission(input);
|
|
903
952
|
if (read.executionReason && !["initial", "offline_replay"].includes(read.executionReason)) {
|
|
@@ -1195,6 +1244,7 @@ function createGovernedActionHost(options) {
|
|
|
1195
1244
|
spaceId,
|
|
1196
1245
|
attributes: { actionVersion: invocation.actionVersion }
|
|
1197
1246
|
});
|
|
1247
|
+
let retryableGovernance = true;
|
|
1198
1248
|
try {
|
|
1199
1249
|
const parsed = action.schema.safeParse(invocation.parameters);
|
|
1200
1250
|
if (!parsed.success) {
|
|
@@ -1347,6 +1397,7 @@ function createGovernedActionHost(options) {
|
|
|
1347
1397
|
mode: "execute",
|
|
1348
1398
|
now: now()
|
|
1349
1399
|
});
|
|
1400
|
+
retryableGovernance = false;
|
|
1350
1401
|
for (const outcome of outcomes) {
|
|
1351
1402
|
await options.store.appendPolicyEvaluation({
|
|
1352
1403
|
id: lifecycleId("pol", actionInvocationId, outcome.policyId),
|
|
@@ -1390,12 +1441,14 @@ function createGovernedActionHost(options) {
|
|
|
1390
1441
|
}
|
|
1391
1442
|
let data;
|
|
1392
1443
|
let domainEvents = [];
|
|
1444
|
+
let pendingTransitionEvent;
|
|
1393
1445
|
try {
|
|
1394
1446
|
const runHandler = async (db, transaction) => {
|
|
1395
1447
|
if (options.outbox && !transaction?.appendEventWithOutbox) {
|
|
1396
|
-
throw new
|
|
1448
|
+
throw new HostError("Outbox egress requires transactionWithEvents to provide appendEventWithOutbox before handler execution.");
|
|
1397
1449
|
}
|
|
1398
1450
|
const binding = action.stateMachine;
|
|
1451
|
+
let validatedTransition;
|
|
1399
1452
|
if (binding) {
|
|
1400
1453
|
const machine = stateMachineResolver(binding.entityType);
|
|
1401
1454
|
const entityId = binding.getEntityId(parsed.data);
|
|
@@ -1425,9 +1478,30 @@ function createGovernedActionHost(options) {
|
|
|
1425
1478
|
db
|
|
1426
1479
|
}
|
|
1427
1480
|
);
|
|
1428
|
-
|
|
1481
|
+
validatedTransition = { entityId, currentState, targetState, valid: transition.valid };
|
|
1482
|
+
let replayingAppliedTransition = false;
|
|
1483
|
+
if (action.idempotent && currentState === targetState && !transition.valid && entityId) {
|
|
1484
|
+
let source;
|
|
1485
|
+
if (transaction?.getEntityStateSource) {
|
|
1486
|
+
source = await transaction.getEntityStateSource(tenantId, spaceId, binding.entityType, entityId);
|
|
1487
|
+
} else if (options.store.getEntityStateSource) {
|
|
1488
|
+
source = await options.store.getEntityStateSource(tenantId, spaceId, binding.entityType, entityId);
|
|
1489
|
+
} else {
|
|
1490
|
+
const eventStore = options.store;
|
|
1491
|
+
const listEvents = transaction?.listEvents?.bind(transaction) ?? eventStore.listEvents?.bind(eventStore);
|
|
1492
|
+
const events2 = listEvents ? await listEvents(tenantId, spaceId) : [];
|
|
1493
|
+
let latest;
|
|
1494
|
+
for (const event of events2) {
|
|
1495
|
+
if (event.tenantId === tenantId && event.spaceId === spaceId && event.subjectType === binding.entityType && event.subjectId === entityId && typeof event.payload?.toState === "string" && (!latest || event.sequence > latest.sequence)) latest = event;
|
|
1496
|
+
}
|
|
1497
|
+
if (latest && typeof latest.actionInvocationId === "string") {
|
|
1498
|
+
source = { state: latest.payload.toState, actionInvocationId: latest.actionInvocationId };
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
replayingAppliedTransition = source?.actionInvocationId === actionInvocationId && source.state === currentState;
|
|
1502
|
+
}
|
|
1429
1503
|
if (!transition.valid && !replayingAppliedTransition) {
|
|
1430
|
-
throw new Error(transition.error
|
|
1504
|
+
throw transition.error != null ? new Error(transition.error) : new HostError("Invalid state transition");
|
|
1431
1505
|
}
|
|
1432
1506
|
}
|
|
1433
1507
|
}
|
|
@@ -1448,14 +1522,14 @@ function createGovernedActionHost(options) {
|
|
|
1448
1522
|
parsed.data
|
|
1449
1523
|
) : { success: true, data: {} };
|
|
1450
1524
|
if (!handlerResult.success) {
|
|
1451
|
-
throw new Error(handlerResult.error
|
|
1525
|
+
throw handlerResult.error != null ? new Error(handlerResult.error) : new HostError("Action handler failed");
|
|
1452
1526
|
}
|
|
1453
1527
|
const handlerData = handlerResult.data ?? {};
|
|
1454
1528
|
if (action.resultSchema) {
|
|
1455
1529
|
const publicResult = withoutPrivateHostFields(handlerData, eventResultFields);
|
|
1456
1530
|
const parsedResult = action.resultSchema.safeParse(publicResult);
|
|
1457
1531
|
if (!parsedResult.success) {
|
|
1458
|
-
throw new
|
|
1532
|
+
throw new HostError(`Action result validation failed at ${parsedResult.error.issues.map((issue) => issue.path.map(String).join(".") || "result").join(", ")}`);
|
|
1459
1533
|
}
|
|
1460
1534
|
}
|
|
1461
1535
|
const events = extractEvents(handlerData);
|
|
@@ -1463,7 +1537,7 @@ function createGovernedActionHost(options) {
|
|
|
1463
1537
|
(event) => !action.emitsEvents.includes(event.eventType)
|
|
1464
1538
|
);
|
|
1465
1539
|
if (undeclaredEvent) {
|
|
1466
|
-
throw new
|
|
1540
|
+
throw new HostError(
|
|
1467
1541
|
`${action.actionId} emitted undeclared event type ${undeclaredEvent.eventType}`
|
|
1468
1542
|
);
|
|
1469
1543
|
}
|
|
@@ -1478,7 +1552,29 @@ function createGovernedActionHost(options) {
|
|
|
1478
1552
|
);
|
|
1479
1553
|
}
|
|
1480
1554
|
}
|
|
1481
|
-
|
|
1555
|
+
let transitionEvent;
|
|
1556
|
+
if (binding && validatedTransition?.valid && validatedTransition.entityId) {
|
|
1557
|
+
const { entityId, currentState, targetState } = validatedTransition;
|
|
1558
|
+
const handlerEmittedTransition = events.some((event) => event.eventType === "StateTransitioned" && event.subjectType === binding.entityType && event.subjectId === entityId);
|
|
1559
|
+
if (!handlerEmittedTransition) {
|
|
1560
|
+
transitionEvent = {
|
|
1561
|
+
eventType: "StateTransitioned",
|
|
1562
|
+
subjectType: binding.entityType,
|
|
1563
|
+
subjectId: entityId,
|
|
1564
|
+
payload: {
|
|
1565
|
+
entityType: binding.entityType,
|
|
1566
|
+
entityId,
|
|
1567
|
+
fromState: currentState,
|
|
1568
|
+
toState: targetState,
|
|
1569
|
+
causedByAction: action.actionId
|
|
1570
|
+
}
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
if (transitionEvent && action.eventPhase !== "after_adapters") {
|
|
1575
|
+
await appendEvent(invocation, transitionEvent, "state:transition", 1, transaction);
|
|
1576
|
+
}
|
|
1577
|
+
return { data: handlerData, domainEvents: events, pendingTransitionEvent: transitionEvent };
|
|
1482
1578
|
};
|
|
1483
1579
|
const atomicStore = asAtomicMutationStore(options.store);
|
|
1484
1580
|
const executed = atomicStore ? await atomicStore.transactionWithEvents(
|
|
@@ -1486,9 +1582,11 @@ function createGovernedActionHost(options) {
|
|
|
1486
1582
|
) : await options.store.transaction((db) => runHandler(db));
|
|
1487
1583
|
data = executed.data;
|
|
1488
1584
|
domainEvents = executed.domainEvents;
|
|
1585
|
+
pendingTransitionEvent = executed.pendingTransitionEvent;
|
|
1489
1586
|
} catch (error) {
|
|
1490
|
-
return fail2(invocation, "failed",
|
|
1587
|
+
return fail2(invocation, "failed", caughtError({ actionId: invocation.actionId, source: "handler" }, error));
|
|
1491
1588
|
}
|
|
1589
|
+
const adapterInvocationScope = { tenantId: invocation.tenantId, spaceId: invocation.spaceId, actionInvocationId: invocation.id };
|
|
1492
1590
|
let pendingHandoff = invocation.pendingCompletion;
|
|
1493
1591
|
for (const [stepIndex, step] of (action.adapterSteps ?? []).entries()) {
|
|
1494
1592
|
const input = step.getInput(parsed.data, data);
|
|
@@ -1496,12 +1594,13 @@ function createGovernedActionHost(options) {
|
|
|
1496
1594
|
const adapter = adapters.require(step.adapterType, step.operation);
|
|
1497
1595
|
const adapterInvocationId = lifecycleId("adp", actionInvocationId, String(stepIndex));
|
|
1498
1596
|
const previousAdapterInvocation = await options.store.getAdapterInvocation(
|
|
1499
|
-
adapterInvocationId
|
|
1597
|
+
adapterInvocationId,
|
|
1598
|
+
adapterInvocationScope
|
|
1500
1599
|
);
|
|
1501
1600
|
if (previousAdapterInvocation?.status === "succeeded") continue;
|
|
1502
1601
|
if (pendingHandoff?.adapterInvocationId === adapterInvocationId) {
|
|
1503
1602
|
const subject = options.adapterEventSubject?.(action.actionId, parsed.data, data) ?? { subjectType: "AdapterInvocation", subjectId: adapterInvocationId };
|
|
1504
|
-
await options.store.updateAdapterInvocation(adapterInvocationId, { status: "succeeded", updatedAt: now() });
|
|
1603
|
+
await options.store.updateAdapterInvocation(adapterInvocationId, { status: "succeeded", updatedAt: now() }, adapterInvocationScope);
|
|
1505
1604
|
await appendEvent(invocation, {
|
|
1506
1605
|
eventType: "ExternalOperationAccepted",
|
|
1507
1606
|
subjectType: subject.subjectType,
|
|
@@ -1535,9 +1634,7 @@ function createGovernedActionHost(options) {
|
|
|
1535
1634
|
const startedAt = now();
|
|
1536
1635
|
const record = {
|
|
1537
1636
|
id: adapterInvocationId,
|
|
1538
|
-
|
|
1539
|
-
tenantId,
|
|
1540
|
-
spaceId,
|
|
1637
|
+
...adapterInvocationScope,
|
|
1541
1638
|
adapterType: step.adapterType,
|
|
1542
1639
|
operation: step.operation,
|
|
1543
1640
|
vendor: adapter.vendor,
|
|
@@ -1554,6 +1651,50 @@ function createGovernedActionHost(options) {
|
|
|
1554
1651
|
subjectId: adapterEventSubject.subjectId,
|
|
1555
1652
|
payload: { adapterType: step.adapterType, operation: step.operation }
|
|
1556
1653
|
}, `adapter:${stepIndex}:started`);
|
|
1654
|
+
async function appendDegradedAdapterEvidence(outputWithheld, message, accepted, output, externalReference) {
|
|
1655
|
+
if (accepted) {
|
|
1656
|
+
await appendEvent(invocation, {
|
|
1657
|
+
eventType: "ExternalOperationAccepted",
|
|
1658
|
+
subjectType: adapterEventSubject.subjectType,
|
|
1659
|
+
subjectId: adapterEventSubject.subjectId,
|
|
1660
|
+
payload: {
|
|
1661
|
+
adapterType: step.adapterType,
|
|
1662
|
+
operation: step.operation,
|
|
1663
|
+
externalReference: accepted.externalReference,
|
|
1664
|
+
...accepted.dueAt ? { dueAt: accepted.dueAt.toISOString() } : {}
|
|
1665
|
+
}
|
|
1666
|
+
}, `adapter:${stepIndex}:accepted`);
|
|
1667
|
+
}
|
|
1668
|
+
await appendEvent(invocation, {
|
|
1669
|
+
eventType: "AdapterInvocationSucceeded",
|
|
1670
|
+
subjectType: adapterEventSubject.subjectType,
|
|
1671
|
+
subjectId: adapterEventSubject.subjectId,
|
|
1672
|
+
payload: {
|
|
1673
|
+
adapterType: step.adapterType,
|
|
1674
|
+
operation: step.operation,
|
|
1675
|
+
input: recordedInput,
|
|
1676
|
+
...outputWithheld ? { outputWithheld: true } : { output }
|
|
1677
|
+
}
|
|
1678
|
+
}, `adapter:${stepIndex}:succeeded`);
|
|
1679
|
+
const governanceStore2 = asGovernanceStore(options.store);
|
|
1680
|
+
if (outputWithheld && governanceStore2) {
|
|
1681
|
+
await governanceStore2.appendExternalReconciliation({
|
|
1682
|
+
id: lifecycleId("rec", actionInvocationId, `${adapter.vendor}:${adapterInvocationId}`),
|
|
1683
|
+
actionInvocationId,
|
|
1684
|
+
tenantId,
|
|
1685
|
+
spaceId,
|
|
1686
|
+
status: "pending",
|
|
1687
|
+
provider: adapter.vendor,
|
|
1688
|
+
...externalReference ?? accepted?.externalReference ? { externalOperationId: externalReference ?? accepted?.externalReference } : {},
|
|
1689
|
+
attempt: 1,
|
|
1690
|
+
reason: message,
|
|
1691
|
+
observedAt: now()
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
const adapterErrorContext = { actionId: action.actionId, source: "adapter", adapterType: step.adapterType, operation: step.operation, provider: adapter.vendor };
|
|
1696
|
+
const executionErrorContext = { actionId: action.actionId, source: "execution" };
|
|
1697
|
+
let errorContext = executionErrorContext;
|
|
1557
1698
|
let adapterDeadlineTimer;
|
|
1558
1699
|
try {
|
|
1559
1700
|
const adapterController = options.adapterDeadlineMs ? new AbortController() : void 0;
|
|
@@ -1561,23 +1702,31 @@ function createGovernedActionHost(options) {
|
|
|
1561
1702
|
if (adapterController && deadlineMs) {
|
|
1562
1703
|
const remaining = deadlineMs - now().getTime();
|
|
1563
1704
|
if (remaining <= 0) {
|
|
1564
|
-
throw new
|
|
1705
|
+
throw new HostError(`Adapter deadline already expired before execution: ${step.adapterType}:${step.operation}`);
|
|
1565
1706
|
}
|
|
1566
1707
|
adapterDeadlineTimer = setTimeout(
|
|
1567
|
-
() => adapterController.abort(new
|
|
1708
|
+
() => adapterController.abort(new HostError("Adapter deadline exceeded")),
|
|
1568
1709
|
remaining
|
|
1569
1710
|
);
|
|
1570
1711
|
adapterDeadlineTimer.unref?.();
|
|
1571
1712
|
}
|
|
1713
|
+
const circuitBreaker = circuitBreakers.forAdapter(adapter, { tenantId, spaceId });
|
|
1572
1714
|
const result2 = await platform.executeWithAdapterRetry({
|
|
1715
|
+
...circuitBreaker ? { circuitBreaker } : {},
|
|
1573
1716
|
policy: step.retryPolicy ?? adapter.retryPolicy,
|
|
1574
1717
|
defaultIdempotent: adapter.idempotent,
|
|
1575
|
-
|
|
1718
|
+
// Every await before the call happens here, before breaker
|
|
1719
|
+
// admission, so nothing awaits between admission and the adapter.
|
|
1720
|
+
prepareAttempt: async (attempt) => {
|
|
1721
|
+
errorContext = executionErrorContext;
|
|
1576
1722
|
await options.store.updateAdapterInvocation(adapterInvocationId, {
|
|
1577
1723
|
attempt,
|
|
1578
1724
|
updatedAt: now()
|
|
1579
|
-
});
|
|
1580
|
-
|
|
1725
|
+
}, adapterInvocationScope);
|
|
1726
|
+
},
|
|
1727
|
+
execute: async (attempt, maxAttempts) => {
|
|
1728
|
+
errorContext = adapterErrorContext;
|
|
1729
|
+
const adapterResult = await adapter.execute(input, {
|
|
1581
1730
|
tenantId,
|
|
1582
1731
|
spaceId,
|
|
1583
1732
|
actionInvocationId,
|
|
@@ -1589,30 +1738,37 @@ function createGovernedActionHost(options) {
|
|
|
1589
1738
|
...deadlineMs ? { deadlineMs } : {},
|
|
1590
1739
|
...adapterController ? { signal: adapterController.signal } : {}
|
|
1591
1740
|
});
|
|
1741
|
+
errorContext = executionErrorContext;
|
|
1742
|
+
return adapterResult;
|
|
1592
1743
|
},
|
|
1593
1744
|
isSuccessful: (result3) => result3.success,
|
|
1594
1745
|
getError: (result3) => result3.error,
|
|
1595
1746
|
classifyOutcome: (result3) => result3.outcome ?? "transient_failure",
|
|
1596
1747
|
classifyThrownError: (error) => {
|
|
1748
|
+
if (platform.isAdapterCircuitOpenError(error)) return "transient_failure";
|
|
1597
1749
|
if (adapterController?.signal.aborted) return "timeout";
|
|
1598
1750
|
const message = error instanceof Error ? error.message : String(error);
|
|
1599
1751
|
if (/timeout|deadline|timed?\s*out/i.test(message)) return "timeout";
|
|
1600
1752
|
return "transient_failure";
|
|
1601
1753
|
}
|
|
1602
1754
|
});
|
|
1755
|
+
errorContext = adapterErrorContext;
|
|
1603
1756
|
platform.assertAdapterOutcomeConsistency(result2);
|
|
1757
|
+
errorContext = executionErrorContext;
|
|
1604
1758
|
if (!result2.success) {
|
|
1759
|
+
const resultError = result2.error == null ? result2.error : redactErrorText(options.redactErrorText, adapterErrorContext, result2.error);
|
|
1605
1760
|
const outcome = result2.outcome ?? "transient_failure";
|
|
1606
1761
|
if (outcome === "ambiguous") {
|
|
1607
1762
|
const evidence = result2.outcomeEvidence;
|
|
1608
|
-
const
|
|
1763
|
+
const reason = evidence?.reason == null ? resultError : redactErrorText(options.redactErrorText, adapterErrorContext, evidence.reason);
|
|
1764
|
+
const message = resultError ?? "Adapter outcome is ambiguous; external effect may or may not have been applied";
|
|
1609
1765
|
const adapterReconciliation = {
|
|
1610
1766
|
kind: "adapter_outcome_ambiguous",
|
|
1611
1767
|
adapterType: step.adapterType,
|
|
1612
1768
|
operation: step.operation,
|
|
1613
1769
|
vendor: adapter.vendor,
|
|
1614
1770
|
adapterInvocationId,
|
|
1615
|
-
...evidence?.reason ?? result2.error ? { reason
|
|
1771
|
+
...evidence?.reason ?? result2.error ? { reason } : {},
|
|
1616
1772
|
...evidence?.externalReference ? { externalReference: evidence.externalReference } : {},
|
|
1617
1773
|
message
|
|
1618
1774
|
};
|
|
@@ -1620,7 +1776,7 @@ function createGovernedActionHost(options) {
|
|
|
1620
1776
|
status: "ambiguous",
|
|
1621
1777
|
error: message,
|
|
1622
1778
|
updatedAt: now()
|
|
1623
|
-
});
|
|
1779
|
+
}, adapterInvocationScope);
|
|
1624
1780
|
await appendEvent(invocation, {
|
|
1625
1781
|
eventType: "AdapterInvocationAmbiguous",
|
|
1626
1782
|
subjectType: adapterEventSubject.subjectType,
|
|
@@ -1658,24 +1814,52 @@ function createGovernedActionHost(options) {
|
|
|
1658
1814
|
}
|
|
1659
1815
|
await options.store.updateAdapterInvocation(adapterInvocationId, {
|
|
1660
1816
|
status: "failed",
|
|
1661
|
-
error:
|
|
1817
|
+
error: resultError ?? "Adapter failed",
|
|
1662
1818
|
updatedAt: now()
|
|
1663
|
-
});
|
|
1819
|
+
}, adapterInvocationScope);
|
|
1664
1820
|
await appendEvent(invocation, {
|
|
1665
1821
|
eventType: "AdapterInvocationFailed",
|
|
1666
1822
|
subjectType: adapterEventSubject.subjectType,
|
|
1667
1823
|
subjectId: adapterEventSubject.subjectId,
|
|
1668
1824
|
payload: { adapterType: step.adapterType, operation: step.operation }
|
|
1669
1825
|
}, `adapter:${stepIndex}:failed`);
|
|
1670
|
-
return fail2(invocation, "failed",
|
|
1826
|
+
return fail2(invocation, "failed", resultError ?? "Adapter failed");
|
|
1671
1827
|
}
|
|
1672
1828
|
const resultRecord = result2;
|
|
1673
|
-
|
|
1829
|
+
let output = isRecord(resultRecord.output) ? resultRecord.output : Object.fromEntries(
|
|
1674
1830
|
Object.entries(resultRecord).filter(
|
|
1675
1831
|
([key]) => key !== "success" && key !== "error"
|
|
1676
1832
|
)
|
|
1677
1833
|
);
|
|
1678
1834
|
const handoff = result2.acceptedExternalOperation;
|
|
1835
|
+
if (options.redactAdapterOutput) {
|
|
1836
|
+
try {
|
|
1837
|
+
output = options.redactAdapterOutput(action.actionId, step.adapterType, step.operation, output);
|
|
1838
|
+
if (!isRecord(output)) throw new HostError("Adapter output redactor must return a non-null, non-array object.");
|
|
1839
|
+
} catch {
|
|
1840
|
+
const message = "Adapter output withheld: the output redactor failed after the external effect; reconciliation required.";
|
|
1841
|
+
const reference = typeof handoff?.externalReference === "string" ? handoff.externalReference.trim() : "";
|
|
1842
|
+
const acceptedAt = now();
|
|
1843
|
+
const deadlineMs2 = action.execution?.completionDeadlineMs;
|
|
1844
|
+
const pendingCompletion = pendingHandoff ?? (reference ? {
|
|
1845
|
+
provider: adapter.vendor,
|
|
1846
|
+
adapterType: step.adapterType,
|
|
1847
|
+
operation: step.operation,
|
|
1848
|
+
adapterInvocationId,
|
|
1849
|
+
externalReference: reference,
|
|
1850
|
+
acceptedAt,
|
|
1851
|
+
...deadlineMs2 ? { dueAt: new Date(acceptedAt.getTime() + deadlineMs2) } : {}
|
|
1852
|
+
} : void 0);
|
|
1853
|
+
try {
|
|
1854
|
+
await persistReconciliationVerdict(invocation, { status: "reconciliation_required", error: message, ...pendingCompletion ? { pendingCompletion } : {} });
|
|
1855
|
+
await appendDegradedAdapterEvidence(true, message, pendingCompletion?.adapterInvocationId === adapterInvocationId ? pendingCompletion : void 0, void 0, reference || void 0);
|
|
1856
|
+
await options.store.updateAdapterInvocation(adapterInvocationId, { status: "succeeded", updatedAt: now() }, adapterInvocationScope);
|
|
1857
|
+
} catch {
|
|
1858
|
+
throw new RecoverableFinalizationError(message);
|
|
1859
|
+
}
|
|
1860
|
+
return withConsistency({ actionInvocationId, status: "reconciliation_required", error: message, ...pendingCompletion ? { pendingCompletion } : {} }, action.actionId);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1679
1863
|
if (handoff) {
|
|
1680
1864
|
const reference = typeof handoff.externalReference === "string" ? handoff.externalReference.trim() : "";
|
|
1681
1865
|
if (reference === "") {
|
|
@@ -1697,19 +1881,28 @@ function createGovernedActionHost(options) {
|
|
|
1697
1881
|
const completion = action.execution?.completion ?? "immediate";
|
|
1698
1882
|
const refuse = completion === "immediate" ? `Adapter ${step.adapterType}:${step.operation} handed off external operation "${reference}" but ${action.actionId} declares immediate completion; the external effect may be in flight.` : pendingHandoff ? `Adapter ${step.adapterType}:${step.operation} handed off a second external operation "${reference}" while "${pendingHandoff.externalReference}" is already pending; one invocation waits on one completion.` : void 0;
|
|
1699
1883
|
if (refuse) {
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1884
|
+
try {
|
|
1885
|
+
await persistReconciliationVerdict(invocation, {
|
|
1886
|
+
status: "reconciliation_required",
|
|
1887
|
+
error: refuse,
|
|
1888
|
+
...pendingHandoff ? {} : { pendingCompletion: accepted }
|
|
1889
|
+
});
|
|
1890
|
+
await appendDegradedAdapterEvidence(false, refuse, pendingHandoff ? void 0 : accepted, output);
|
|
1891
|
+
} catch {
|
|
1892
|
+
throw new RecoverableFinalizationError(refuse);
|
|
1893
|
+
}
|
|
1706
1894
|
let error = refuse;
|
|
1707
1895
|
try {
|
|
1708
1896
|
await externalCompletion.recordCompletionReconciliation(invocation, { externalReference: reference, provider: adapter.vendor }, refuse);
|
|
1709
1897
|
} catch (logError) {
|
|
1710
|
-
error = `${refuse} Reconciliation log unavailable: ${
|
|
1898
|
+
error = `${refuse} Reconciliation log unavailable: ${caughtError({ actionId: invocation.actionId, source: "execution" }, logError)}`;
|
|
1711
1899
|
await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, { error });
|
|
1712
1900
|
}
|
|
1901
|
+
try {
|
|
1902
|
+
await options.store.updateAdapterInvocation(adapterInvocationId, { status: "succeeded", output, updatedAt: now() }, adapterInvocationScope);
|
|
1903
|
+
} catch {
|
|
1904
|
+
throw new RecoverableFinalizationError(refuse);
|
|
1905
|
+
}
|
|
1713
1906
|
return withConsistency({
|
|
1714
1907
|
actionInvocationId,
|
|
1715
1908
|
status: "reconciliation_required",
|
|
@@ -1736,7 +1929,7 @@ function createGovernedActionHost(options) {
|
|
|
1736
1929
|
status: "succeeded",
|
|
1737
1930
|
output,
|
|
1738
1931
|
updatedAt: now()
|
|
1739
|
-
});
|
|
1932
|
+
}, adapterInvocationScope);
|
|
1740
1933
|
if (options.extractExecutionAttestation) {
|
|
1741
1934
|
const attestation = await options.extractExecutionAttestation({
|
|
1742
1935
|
actionInvocationId,
|
|
@@ -1765,12 +1958,26 @@ function createGovernedActionHost(options) {
|
|
|
1765
1958
|
}
|
|
1766
1959
|
}, `adapter:${stepIndex}:succeeded`);
|
|
1767
1960
|
} catch (error) {
|
|
1768
|
-
|
|
1961
|
+
if (error instanceof RecoverableFinalizationError) throw error;
|
|
1962
|
+
const message = caughtError(platform.isAdapterCircuitOpenError(error) ? adapterErrorContext : errorContext, error);
|
|
1769
1963
|
await options.store.updateAdapterInvocation(adapterInvocationId, {
|
|
1770
1964
|
status: "failed",
|
|
1771
1965
|
error: message,
|
|
1772
1966
|
updatedAt: now()
|
|
1773
|
-
});
|
|
1967
|
+
}, adapterInvocationScope);
|
|
1968
|
+
if (platform.isAdapterCircuitOpenError(error)) {
|
|
1969
|
+
await appendEvent(invocation, {
|
|
1970
|
+
eventType: "AdapterInvocationFailed",
|
|
1971
|
+
subjectType: adapterEventSubject.subjectType,
|
|
1972
|
+
subjectId: adapterEventSubject.subjectId,
|
|
1973
|
+
payload: {
|
|
1974
|
+
adapterType: step.adapterType,
|
|
1975
|
+
operation: step.operation,
|
|
1976
|
+
reason: "circuit_open",
|
|
1977
|
+
...error.retryAfterMs !== void 0 ? { retryAfterMs: error.retryAfterMs } : {}
|
|
1978
|
+
}
|
|
1979
|
+
}, `adapter:${stepIndex}:failed`);
|
|
1980
|
+
}
|
|
1774
1981
|
return fail2(invocation, "failed", message);
|
|
1775
1982
|
} finally {
|
|
1776
1983
|
if (adapterDeadlineTimer) clearTimeout(adapterDeadlineTimer);
|
|
@@ -1788,7 +1995,7 @@ function createGovernedActionHost(options) {
|
|
|
1788
1995
|
try {
|
|
1789
1996
|
await externalCompletion.recordCompletionReconciliation(invocation, { externalReference: pendingHandoff.externalReference, provider: pendingHandoff.provider, kind: "obligations" }, message);
|
|
1790
1997
|
} catch (logError) {
|
|
1791
|
-
error = `${message} Reconciliation log unavailable: ${
|
|
1998
|
+
error = `${message} Reconciliation log unavailable: ${caughtError({ actionId: invocation.actionId, source: "execution" }, logError)}`;
|
|
1792
1999
|
await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, { error });
|
|
1793
2000
|
}
|
|
1794
2001
|
return withConsistency({ actionInvocationId, status: "reconciliation_required", error, pendingCompletion: pendingHandoff }, action.actionId);
|
|
@@ -1812,9 +2019,12 @@ function createGovernedActionHost(options) {
|
|
|
1812
2019
|
transaction
|
|
1813
2020
|
);
|
|
1814
2021
|
}
|
|
2022
|
+
if (pendingTransitionEvent) {
|
|
2023
|
+
await appendEvent(invocation, pendingTransitionEvent, "state:transition", 1, transaction);
|
|
2024
|
+
}
|
|
1815
2025
|
if (invocation.leaseOwner && invocation.leaseToken !== void 0) {
|
|
1816
2026
|
if (!transaction.updateLeasedActionInvocation) {
|
|
1817
|
-
throw new
|
|
2027
|
+
throw new HostError("Leased atomic finalization requires transaction-scoped fencing support.");
|
|
1818
2028
|
}
|
|
1819
2029
|
const updated = await transaction.updateLeasedActionInvocation({
|
|
1820
2030
|
id: actionInvocationId,
|
|
@@ -1847,6 +2057,9 @@ function createGovernedActionHost(options) {
|
|
|
1847
2057
|
for (const [index, event] of domainEvents.entries()) {
|
|
1848
2058
|
await appendEvent(invocation, event, `domain:${index}`, action.version);
|
|
1849
2059
|
}
|
|
2060
|
+
if (pendingTransitionEvent) {
|
|
2061
|
+
await appendEvent(invocation, pendingTransitionEvent, "state:transition", 1);
|
|
2062
|
+
}
|
|
1850
2063
|
}
|
|
1851
2064
|
await persistInvocation(invocation, finalPatch);
|
|
1852
2065
|
}
|
|
@@ -1866,7 +2079,15 @@ function createGovernedActionHost(options) {
|
|
|
1866
2079
|
};
|
|
1867
2080
|
} catch (error) {
|
|
1868
2081
|
if (error instanceof RecoverableFinalizationError) throw error;
|
|
1869
|
-
|
|
2082
|
+
if (retryableGovernance && platform.isRetryableExecutionError(error)) {
|
|
2083
|
+
const reason = executionReasonOverride === "approval_resume" ? "approval resume" : !invocation.leaseOwner || invocation.leaseToken === void 0 ? "not leased" : !action.idempotent ? "action not idempotent" : invocation.attemptCount >= retryableExecutionMaxAttempts ? `attempts exhausted (${invocation.attemptCount}/${retryableExecutionMaxAttempts})` : void 0;
|
|
2084
|
+
if (!reason) throw error;
|
|
2085
|
+
return fail2(invocation, "failed", caughtError(
|
|
2086
|
+
{ actionId: invocation.actionId, source: "execution" },
|
|
2087
|
+
new Error(`Retryable execution not retried: ${reason}; ${errorMessage(error)}`)
|
|
2088
|
+
));
|
|
2089
|
+
}
|
|
2090
|
+
return fail2(invocation, "failed", caughtError({ actionId: invocation.actionId, source: "execution" }, error));
|
|
1870
2091
|
}
|
|
1871
2092
|
}
|
|
1872
2093
|
async function resumeApprovedInvocation(actionInvocationId, tenantId, spaceId, decision) {
|
|
@@ -1949,9 +2170,9 @@ function createGovernedActionHost(options) {
|
|
|
1949
2170
|
}
|
|
1950
2171
|
async function recordExecutionAttestation(actionInvocationId, tenantId, spaceId, attestation, adapterInvocationId) {
|
|
1951
2172
|
const governanceStore = asGovernanceStore(options.store);
|
|
1952
|
-
if (!governanceStore) throw new
|
|
2173
|
+
if (!governanceStore) throw new HostError("Execution attestations require a governance-capable store.");
|
|
1953
2174
|
const invocation = await options.store.getActionInvocation(actionInvocationId, tenantId, spaceId);
|
|
1954
|
-
if (!invocation) throw new
|
|
2175
|
+
if (!invocation) throw new HostError(`ActionInvocation not found: ${actionInvocationId}`);
|
|
1955
2176
|
const id = lifecycleId("att", actionInvocationId, adapterInvocationId ?? `${attestation.provider}:${attestation.operation}:${attestation.externalOperationId ?? "external"}`);
|
|
1956
2177
|
await governanceStore.appendExecutionAttestation({
|
|
1957
2178
|
...attestation,
|
|
@@ -2029,11 +2250,11 @@ function createGovernedActionHost(options) {
|
|
|
2029
2250
|
...traceContext ? { traceContext } : {}
|
|
2030
2251
|
};
|
|
2031
2252
|
if (transaction) {
|
|
2032
|
-
if (!transaction.appendEventWithOutbox) throw new
|
|
2253
|
+
if (!transaction.appendEventWithOutbox) throw new HostError("Outbox egress requires an atomic transaction with appendEventWithOutbox.");
|
|
2033
2254
|
await transaction.appendEventWithOutbox(envelope, metadata);
|
|
2034
2255
|
} else {
|
|
2035
2256
|
const outboxStore = asOutboxStore(options.store);
|
|
2036
|
-
if (!outboxStore) throw new
|
|
2257
|
+
if (!outboxStore) throw new HostError("Outbox egress requires an outbox-capable Platform Host store.");
|
|
2037
2258
|
await outboxStore.appendEventWithOutbox(envelope, metadata);
|
|
2038
2259
|
}
|
|
2039
2260
|
} else {
|
|
@@ -2050,12 +2271,20 @@ function createGovernedActionHost(options) {
|
|
|
2050
2271
|
...invocation.hitlRiskTier ? { hitlRiskTier: invocation.hitlRiskTier } : {}
|
|
2051
2272
|
};
|
|
2052
2273
|
}
|
|
2274
|
+
async function persistReconciliationVerdict(invocation, patch) {
|
|
2275
|
+
try {
|
|
2276
|
+
await persistInvocation(invocation, patch);
|
|
2277
|
+
} catch (error) {
|
|
2278
|
+
if (error instanceof RecoverableFinalizationError) throw error;
|
|
2279
|
+
await persistInvocation(invocation, patch);
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2053
2282
|
async function persistInvocation(invocation, patch) {
|
|
2054
2283
|
const previousStatus = invocation.status;
|
|
2055
2284
|
if (invocation.leaseOwner && invocation.leaseToken !== void 0) {
|
|
2056
2285
|
const recoverable = options.store;
|
|
2057
2286
|
if (!recoverable.updateLeasedActionInvocation) {
|
|
2058
|
-
throw new
|
|
2287
|
+
throw new HostError("Leased execution requires a fencing-capable PlatformHostStore.");
|
|
2059
2288
|
}
|
|
2060
2289
|
const updated = await recoverable.updateLeasedActionInvocation({
|
|
2061
2290
|
id: invocation.id,
|
|
@@ -2080,6 +2309,8 @@ function createGovernedActionHost(options) {
|
|
|
2080
2309
|
const externalCompletion = createExternalCompletionLifecycle({
|
|
2081
2310
|
store: options.store,
|
|
2082
2311
|
telemetry: options.telemetry,
|
|
2312
|
+
redactErrorText: options.redactErrorText,
|
|
2313
|
+
redactExternalCompletionResult: options.redactExternalCompletionResult,
|
|
2083
2314
|
eventResultFields,
|
|
2084
2315
|
now,
|
|
2085
2316
|
actionResolver,
|
|
@@ -2160,9 +2391,6 @@ function initialState(machine) {
|
|
|
2160
2391
|
function isTerminal(status) {
|
|
2161
2392
|
return ["completed", "failed", "blocked_by_policy", "reconciliation_required", "validation_failed"].includes(status);
|
|
2162
2393
|
}
|
|
2163
|
-
function isRecord(value) {
|
|
2164
|
-
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
2165
|
-
}
|
|
2166
2394
|
function isAdmissionTime(value) {
|
|
2167
2395
|
return value instanceof Date && Number.isFinite(value.getTime());
|
|
2168
2396
|
}
|
|
@@ -2465,6 +2693,15 @@ var MemoryPlatformHostStore = class {
|
|
|
2465
2693
|
appendEventWithOutbox: (event, metadata) => appendPending(event, metadata),
|
|
2466
2694
|
nextEventSequence: async (tenantId, spaceId) => (await this.listEvents(tenantId, spaceId)).length + pendingEvents.filter((candidate) => candidate.event.tenantId === tenantId && candidate.event.spaceId === spaceId).length + 1,
|
|
2467
2695
|
listEvents: async (tenantId, spaceId) => [...await this.listEvents(tenantId, spaceId), ...pendingEvents.map((candidate) => candidate.event).filter((event) => event.tenantId === tenantId && event.spaceId === spaceId)],
|
|
2696
|
+
getEntityStateSource: async (tenantId, spaceId, entityType, entityId) => {
|
|
2697
|
+
let source = await this.getEntityStateSource(tenantId, spaceId, entityType, entityId);
|
|
2698
|
+
for (const { event } of pendingEvents) {
|
|
2699
|
+
if (event.tenantId !== tenantId || event.spaceId !== spaceId || event.subjectType !== entityType || event.subjectId !== entityId) continue;
|
|
2700
|
+
const state = event.payload?.toState;
|
|
2701
|
+
if (typeof state === "string") source = typeof event.actionInvocationId === "string" ? { state, actionInvocationId: event.actionInvocationId } : void 0;
|
|
2702
|
+
}
|
|
2703
|
+
return source;
|
|
2704
|
+
},
|
|
2468
2705
|
getEntityState: async (tenantId, spaceId, entityType, entityId) => {
|
|
2469
2706
|
let state = await this.getEntityState(tenantId, spaceId, entityType, entityId);
|
|
2470
2707
|
for (const pending of pendingEvents) {
|
|
@@ -2612,11 +2849,11 @@ var MemoryPlatformHostStore = class {
|
|
|
2612
2849
|
if (this.adapterInvocations.some((candidate) => candidate.id === record.id)) return;
|
|
2613
2850
|
this.adapterInvocations.push(record);
|
|
2614
2851
|
}
|
|
2615
|
-
async getAdapterInvocation(id) {
|
|
2616
|
-
return this.adapterInvocations.find((candidate) => candidate.id === id);
|
|
2852
|
+
async getAdapterInvocation(id, scope) {
|
|
2853
|
+
return this.adapterInvocations.find((candidate) => candidate.id === id && (scope === void 0 || candidate.tenantId === scope.tenantId && candidate.spaceId === scope.spaceId && candidate.actionInvocationId === scope.actionInvocationId));
|
|
2617
2854
|
}
|
|
2618
|
-
async updateAdapterInvocation(id, patch) {
|
|
2619
|
-
const record = this.adapterInvocations.find((candidate) => candidate.id === id);
|
|
2855
|
+
async updateAdapterInvocation(id, patch, scope) {
|
|
2856
|
+
const record = this.adapterInvocations.find((candidate) => candidate.id === id && (scope === void 0 || candidate.tenantId === scope.tenantId && candidate.spaceId === scope.spaceId && candidate.actionInvocationId === scope.actionInvocationId));
|
|
2620
2857
|
if (!record) throw new Error(`AdapterInvocation not found: ${id}`);
|
|
2621
2858
|
Object.assign(record, patch);
|
|
2622
2859
|
}
|
|
@@ -2721,6 +2958,15 @@ var MemoryPlatformHostStore = class {
|
|
|
2721
2958
|
async nextEventSequence(tenantId, spaceId) {
|
|
2722
2959
|
return this.events.filter((event) => event.tenantId === tenantId && event.spaceId === spaceId).length + 1;
|
|
2723
2960
|
}
|
|
2961
|
+
async getEntityStateSource(tenantId, spaceId, entityType, entityId) {
|
|
2962
|
+
let source;
|
|
2963
|
+
for (const event of this.events) {
|
|
2964
|
+
if (event.tenantId !== tenantId || event.spaceId !== spaceId || event.subjectType !== entityType || event.subjectId !== entityId) continue;
|
|
2965
|
+
const state = event.payload?.toState;
|
|
2966
|
+
if (typeof state === "string") source = typeof event.actionInvocationId === "string" ? { state, actionInvocationId: event.actionInvocationId } : void 0;
|
|
2967
|
+
}
|
|
2968
|
+
return source;
|
|
2969
|
+
}
|
|
2724
2970
|
async getEntityState(tenantId, spaceId, entityType, entityId) {
|
|
2725
2971
|
let state;
|
|
2726
2972
|
for (const event of this.events) {
|
|
@@ -2893,6 +3139,7 @@ var PostgresPlatformHostStore = class _PostgresPlatformHostStore {
|
|
|
2893
3139
|
appendEventWithOutbox: (event, metadata) => scoped.appendEventWithOutbox(event, metadata),
|
|
2894
3140
|
nextEventSequence: (tenantId, spaceId) => scoped.nextEventSequence(tenantId, spaceId),
|
|
2895
3141
|
listEvents: (tenantId, spaceId) => scoped.listEvents(tenantId, spaceId),
|
|
3142
|
+
getEntityStateSource: (tenantId, spaceId, entityType, entityId) => scoped.getEntityStateSource(tenantId, spaceId, entityType, entityId),
|
|
2896
3143
|
getEntityState: (tenantId, spaceId, entityType, entityId) => scoped.getEntityState(tenantId, spaceId, entityType, entityId),
|
|
2897
3144
|
updateActionInvocation: (id, tenantId, spaceId, patch) => scoped.updateActionInvocation(id, tenantId, spaceId, patch),
|
|
2898
3145
|
updateLeasedActionInvocation: (input) => scoped.updateLeasedActionInvocation(input)
|
|
@@ -3346,19 +3593,19 @@ var PostgresPlatformHostStore = class _PostgresPlatformHostStore {
|
|
|
3346
3593
|
]
|
|
3347
3594
|
);
|
|
3348
3595
|
}
|
|
3349
|
-
async getAdapterInvocation(id) {
|
|
3596
|
+
async getAdapterInvocation(id, scope) {
|
|
3350
3597
|
const result = await this.sql.query(
|
|
3351
|
-
`SELECT * FROM fabric_platform.adapter_invocations WHERE id=$1`,
|
|
3352
|
-
[id]
|
|
3598
|
+
`SELECT * FROM fabric_platform.adapter_invocations WHERE id=$1${scope ? " AND tenant_id=$2 AND space_id=$3 AND action_invocation_id=$4" : ""}`,
|
|
3599
|
+
[id, ...scope ? [scope.tenantId, scope.spaceId, scope.actionInvocationId] : []]
|
|
3353
3600
|
);
|
|
3354
3601
|
return result.rows[0] ? toAdapterRecord(result.rows[0]) : void 0;
|
|
3355
3602
|
}
|
|
3356
|
-
async updateAdapterInvocation(id, patch) {
|
|
3603
|
+
async updateAdapterInvocation(id, patch, scope) {
|
|
3357
3604
|
await this.sql.query(
|
|
3358
3605
|
`UPDATE fabric_platform.adapter_invocations SET
|
|
3359
3606
|
status=COALESCE($2,status), output=COALESCE($3::jsonb,output),
|
|
3360
3607
|
error=CASE WHEN $4::boolean THEN $5 ELSE error END,
|
|
3361
|
-
attempt=COALESCE($6,attempt), updated_at=COALESCE($7,now()) WHERE id=$1`,
|
|
3608
|
+
attempt=COALESCE($6,attempt), updated_at=COALESCE($7,now()) WHERE id=$1${scope ? " AND tenant_id=$8 AND space_id=$9 AND action_invocation_id=$10" : ""}`,
|
|
3362
3609
|
[
|
|
3363
3610
|
id,
|
|
3364
3611
|
patch.status ?? null,
|
|
@@ -3366,7 +3613,8 @@ var PostgresPlatformHostStore = class _PostgresPlatformHostStore {
|
|
|
3366
3613
|
Object.hasOwn(patch, "error"),
|
|
3367
3614
|
patch.error ?? null,
|
|
3368
3615
|
patch.attempt ?? null,
|
|
3369
|
-
patch.updatedAt ?? null
|
|
3616
|
+
patch.updatedAt ?? null,
|
|
3617
|
+
...scope ? [scope.tenantId, scope.spaceId, scope.actionInvocationId] : []
|
|
3370
3618
|
]
|
|
3371
3619
|
);
|
|
3372
3620
|
}
|
|
@@ -3597,6 +3845,16 @@ var PostgresPlatformHostStore = class _PostgresPlatformHostStore {
|
|
|
3597
3845
|
);
|
|
3598
3846
|
return Number(result.rows[0].next_sequence);
|
|
3599
3847
|
}
|
|
3848
|
+
async getEntityStateSource(tenantId, spaceId, entityType, entityId) {
|
|
3849
|
+
const result = await this.sql.query(
|
|
3850
|
+
`SELECT payload->>'toState' AS state, action_invocation_id FROM fabric_platform.asset_events
|
|
3851
|
+
WHERE tenant_id=$1 AND space_id=$2 AND subject_type=$3 AND subject_id=$4
|
|
3852
|
+
AND payload ? 'toState' ORDER BY sequence DESC LIMIT 1`,
|
|
3853
|
+
[tenantId, spaceId, entityType, entityId]
|
|
3854
|
+
);
|
|
3855
|
+
const row = result.rows[0];
|
|
3856
|
+
return typeof row?.state === "string" && typeof row.action_invocation_id === "string" ? { state: row.state, actionInvocationId: row.action_invocation_id } : void 0;
|
|
3857
|
+
}
|
|
3600
3858
|
async getEntityState(tenantId, spaceId, entityType, entityId) {
|
|
3601
3859
|
const result = await this.sql.query(
|
|
3602
3860
|
`SELECT payload->>'toState' AS state FROM fabric_platform.asset_events
|
|
@@ -3989,8 +4247,6 @@ function toOutboxRequeueRecord(row, prefix) {
|
|
|
3989
4247
|
priorLeaseToken: Number(field("prior_lease_token"))
|
|
3990
4248
|
};
|
|
3991
4249
|
}
|
|
3992
|
-
|
|
3993
|
-
// src/worker.ts
|
|
3994
4250
|
var DEFAULT_BATCH_SIZE = 10;
|
|
3995
4251
|
var DEFAULT_LEASE_DURATION_MS = 5 * 6e4;
|
|
3996
4252
|
var DEFAULT_POLL_INTERVAL_MS = 1e3;
|
|
@@ -4039,6 +4295,7 @@ async function runPlatformActionWorkerCycle(options) {
|
|
|
4039
4295
|
let completed = 0;
|
|
4040
4296
|
let failed = 0;
|
|
4041
4297
|
let waitingForApproval = 0;
|
|
4298
|
+
let deferred = 0;
|
|
4042
4299
|
for (const invocation of claimed) {
|
|
4043
4300
|
const stopHeartbeat = startLeaseHeartbeat(options, invocation, leaseDurationMs);
|
|
4044
4301
|
try {
|
|
@@ -4052,7 +4309,8 @@ async function runPlatformActionWorkerCycle(options) {
|
|
|
4052
4309
|
else if (result2.status === "waiting_for_approval") waitingForApproval += 1;
|
|
4053
4310
|
else failed += 1;
|
|
4054
4311
|
} catch (error) {
|
|
4055
|
-
|
|
4312
|
+
if (platform.isRetryableExecutionError(error)) deferred += 1;
|
|
4313
|
+
else failed += 1;
|
|
4056
4314
|
options.onError?.(error, invocation);
|
|
4057
4315
|
} finally {
|
|
4058
4316
|
await stopHeartbeat();
|
|
@@ -4062,7 +4320,8 @@ async function runPlatformActionWorkerCycle(options) {
|
|
|
4062
4320
|
claimed: claimed.length,
|
|
4063
4321
|
completed,
|
|
4064
4322
|
failed,
|
|
4065
|
-
...waitingForApproval > 0 ? { waitingForApproval } : {}
|
|
4323
|
+
...waitingForApproval > 0 ? { waitingForApproval } : {},
|
|
4324
|
+
...deferred > 0 ? { deferred } : {}
|
|
4066
4325
|
};
|
|
4067
4326
|
emitWorkerTelemetry(options.telemetry, "worker.cycle.completed", options.workerId, result);
|
|
4068
4327
|
return result;
|
|
@@ -4289,6 +4548,32 @@ function submitInput(subject, scope, idempotencyKey) {
|
|
|
4289
4548
|
idempotencyKey
|
|
4290
4549
|
};
|
|
4291
4550
|
}
|
|
4551
|
+
async function adapterProbe(subject) {
|
|
4552
|
+
await subject.reset?.();
|
|
4553
|
+
if (subject.ours.tenantId === subject.theirs.tenantId) fail("the two scopes must be in different tenants");
|
|
4554
|
+
const suffix = globalThis.crypto.randomUUID();
|
|
4555
|
+
const scope = {
|
|
4556
|
+
tenantId: subject.ours.tenantId,
|
|
4557
|
+
spaceId: `iso-probe-${suffix}`,
|
|
4558
|
+
actionInvocationId: `iso_act_${suffix}`
|
|
4559
|
+
};
|
|
4560
|
+
const id = `iso_adp_${suffix}`;
|
|
4561
|
+
const now = /* @__PURE__ */ new Date();
|
|
4562
|
+
await subject.store.createAdapterInvocation({
|
|
4563
|
+
id,
|
|
4564
|
+
...scope,
|
|
4565
|
+
adapterType: "iso",
|
|
4566
|
+
operation: "probe",
|
|
4567
|
+
vendor: "iso",
|
|
4568
|
+
status: "running",
|
|
4569
|
+
input: {},
|
|
4570
|
+
output: { probe: true },
|
|
4571
|
+
attempt: 1,
|
|
4572
|
+
createdAt: now,
|
|
4573
|
+
updatedAt: now
|
|
4574
|
+
});
|
|
4575
|
+
return { id, scope };
|
|
4576
|
+
}
|
|
4292
4577
|
function tenantIsolationChecks() {
|
|
4293
4578
|
return [
|
|
4294
4579
|
{
|
|
@@ -4433,6 +4718,54 @@ function tenantIsolationChecks() {
|
|
|
4433
4718
|
if (leaked.length > 0) fail(`a viewer in the other tenant saw ${leaked.length} of our rows`);
|
|
4434
4719
|
return [theirs.allowed ? "other tenant's viewer sees none of our rows" : "other tenant's viewer is denied"];
|
|
4435
4720
|
}
|
|
4721
|
+
},
|
|
4722
|
+
{
|
|
4723
|
+
id: "fabric.tenancy.adapter-invocation-read-scoped.v1",
|
|
4724
|
+
async run(subject) {
|
|
4725
|
+
const { id, scope } = await adapterProbe(subject);
|
|
4726
|
+
try {
|
|
4727
|
+
const theirs = { ...scope, tenantId: subject.theirs.tenantId, spaceId: subject.theirs.spaceId };
|
|
4728
|
+
if (await subject.store.getAdapterInvocation(id, { ...scope, tenantId: subject.theirs.tenantId })) fail("an adapter invocation was readable under another tenant's scope");
|
|
4729
|
+
if (await subject.store.getAdapterInvocation(id, theirs)) fail("an adapter invocation was readable under another tenant's scope");
|
|
4730
|
+
if (await subject.store.getAdapterInvocation(id, { ...scope, spaceId: `${scope.spaceId}-other` })) fail("an adapter invocation was readable under another space's scope");
|
|
4731
|
+
if (await subject.store.getAdapterInvocation(id, { ...scope, actionInvocationId: `${scope.actionInvocationId}-other` })) fail("an adapter invocation was readable under another action invocation's scope");
|
|
4732
|
+
const ours = await subject.store.getAdapterInvocation(id, scope);
|
|
4733
|
+
if (!ours || ours.id !== id || ours.tenantId !== scope.tenantId || ours.spaceId !== scope.spaceId || ours.actionInvocationId !== scope.actionInvocationId) fail("our own adapter invocation was not readable under our scope, so the check proves nothing");
|
|
4734
|
+
} finally {
|
|
4735
|
+
await subject.store.updateAdapterInvocation(id, { status: "failed", error: "tenant isolation probe; retired", updatedAt: /* @__PURE__ */ new Date() }, scope);
|
|
4736
|
+
}
|
|
4737
|
+
return ["adapter invocation readable under our scope and invisible outside it"];
|
|
4738
|
+
}
|
|
4739
|
+
},
|
|
4740
|
+
{
|
|
4741
|
+
id: "fabric.tenancy.adapter-invocation-write-scoped.v1",
|
|
4742
|
+
async run(subject) {
|
|
4743
|
+
const { id, scope } = await adapterProbe(subject);
|
|
4744
|
+
const shape = (record) => JSON.stringify({ status: record.status, error: record.error, output: record.output, attempt: record.attempt });
|
|
4745
|
+
try {
|
|
4746
|
+
const before = await subject.store.getAdapterInvocation(id, scope);
|
|
4747
|
+
if (!before) fail("our own adapter invocation was not readable under our scope, so the check proves nothing");
|
|
4748
|
+
const untouched = shape(before);
|
|
4749
|
+
for (const other of [
|
|
4750
|
+
{ ...scope, tenantId: subject.theirs.tenantId, spaceId: subject.theirs.spaceId },
|
|
4751
|
+
{ ...scope, tenantId: subject.theirs.tenantId },
|
|
4752
|
+
{ ...scope, spaceId: `${scope.spaceId}-other` },
|
|
4753
|
+
{ ...scope, actionInvocationId: `${scope.actionInvocationId}-other` }
|
|
4754
|
+
]) {
|
|
4755
|
+
try {
|
|
4756
|
+
await subject.store.updateAdapterInvocation(id, { status: "failed", error: "iso probe", updatedAt: /* @__PURE__ */ new Date() }, other);
|
|
4757
|
+
} catch {
|
|
4758
|
+
}
|
|
4759
|
+
const after = await subject.store.getAdapterInvocation(id, scope);
|
|
4760
|
+
if (!after || shape(after) !== untouched) fail("an adapter invocation changed under another scope");
|
|
4761
|
+
}
|
|
4762
|
+
await subject.store.updateAdapterInvocation(id, { status: "succeeded", updatedAt: /* @__PURE__ */ new Date() }, scope);
|
|
4763
|
+
if ((await subject.store.getAdapterInvocation(id, scope))?.status !== "succeeded") fail("our own adapter invocation was not updated under our scope, so the check proves nothing");
|
|
4764
|
+
} finally {
|
|
4765
|
+
await subject.store.updateAdapterInvocation(id, { status: "failed", error: "tenant isolation probe; retired", updatedAt: /* @__PURE__ */ new Date() }, scope);
|
|
4766
|
+
}
|
|
4767
|
+
return ["adapter invocation untouched outside our scope and writable under ours"];
|
|
4768
|
+
}
|
|
4436
4769
|
}
|
|
4437
4770
|
];
|
|
4438
4771
|
}
|
|
@@ -4449,6 +4782,10 @@ async function runTenantIsolationChecks(subject) {
|
|
|
4449
4782
|
return { passed: checks.every((check) => check.status !== "failed"), checks };
|
|
4450
4783
|
}
|
|
4451
4784
|
|
|
4785
|
+
Object.defineProperty(exports, "canonicalJson", {
|
|
4786
|
+
enumerable: true,
|
|
4787
|
+
get: function () { return platform.canonicalJson; }
|
|
4788
|
+
});
|
|
4452
4789
|
exports.ActionAdmissionError = ActionAdmissionError;
|
|
4453
4790
|
exports.ExternalCompletionError = ExternalCompletionError;
|
|
4454
4791
|
exports.IdempotencyConflictError = IdempotencyConflictError;
|
|
@@ -4459,7 +4796,6 @@ exports.PLATFORM_HOST_HEALTH_CONTRACT_VERSION = PLATFORM_HOST_HEALTH_CONTRACT_VE
|
|
|
4459
4796
|
exports.PLATFORM_HOST_METRIC_NAMES = PLATFORM_HOST_METRIC_NAMES;
|
|
4460
4797
|
exports.PostgresPlatformHostStore = PostgresPlatformHostStore;
|
|
4461
4798
|
exports.applyPostgresMigrations = applyPostgresMigrations;
|
|
4462
|
-
exports.canonicalJson = canonicalJson;
|
|
4463
4799
|
exports.cloneOutboxRecord = cloneOutboxRecord;
|
|
4464
4800
|
exports.createDurableSagaParentLifecycle = createDurableSagaParentLifecycle;
|
|
4465
4801
|
exports.createGovernedActionHost = createGovernedActionHost;
|