@fabricorg/platform-host 8.1.1 → 8.2.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 +53 -0
- package/README.md +154 -14
- package/dist/index.cjs +450 -154
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +39 -7
- package/dist/index.d.ts +39 -7
- package/dist/index.js +451 -155
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AdapterCircuitBreakerRegistry, AdapterRegistry, FABRIC_GOVERNANCE_CONTRACT_VERSION, aggregatePolicyOutcomes, assertAdapterCircuitBreakerConfig, assertAdapterOutcomeConsistency, assertGovernanceRuntimeEvidence, assertMutationGovernanceContext, canonicalJson, createFabricId, evaluatePolicyDefinitions, evaluateStateMachineTransition, executeWithAdapterRetry, isAdapterCircuitOpenError, isReconciliationRequiredError, isRetryableExecutionError } from "@fabricorg/platform";
|
|
1
|
+
import { AdapterCircuitBreakerRegistry, AdapterRegistry, FABRIC_GOVERNANCE_CONTRACT_VERSION, aggregatePolicyOutcomes, assertActionFinalization, assertAdapterCircuitBreakerConfig, assertAdapterOutcomeConsistency, assertAdapterStepDeadlines, assertGovernanceRuntimeEvidence, assertMutationGovernanceContext, canonicalJson, createFabricId, evaluatePolicyDefinitions, evaluateStateMachineTransition, executeWithAdapterRetry, isAdapterCircuitOpenError, isReconciliationRequiredError, isRetryableExecutionError } from "@fabricorg/platform";
|
|
2
2
|
import { performance } from "perf_hooks";
|
|
3
3
|
import { assertAssemblyRuntimeCompatible } from "@fabricorg/assembly";
|
|
4
4
|
import { createHash } from "crypto";
|
|
@@ -95,6 +95,78 @@ function normalizeProvenance(input, options) {
|
|
|
95
95
|
};
|
|
96
96
|
}
|
|
97
97
|
//#endregion
|
|
98
|
+
//#region src/action-finalization.ts
|
|
99
|
+
/** Strict JSON evidence: reject lossy serialization rather than silently dropping values. */
|
|
100
|
+
function finalizationJson(value) {
|
|
101
|
+
const ancestors = /* @__PURE__ */ new Set();
|
|
102
|
+
function check(item) {
|
|
103
|
+
if (item === null || typeof item === "string" || typeof item === "boolean") return;
|
|
104
|
+
if (typeof item === "number" && Number.isFinite(item)) return;
|
|
105
|
+
if (typeof item !== "object" || !item || ancestors.has(item)) throw new Error("Finalization requires JSON-compatible data.");
|
|
106
|
+
if (!Array.isArray(item) && Object.getPrototypeOf(item) !== Object.prototype && Object.getPrototypeOf(item) !== null) throw new Error("Finalization requires plain JSON objects.");
|
|
107
|
+
if (Object.getOwnPropertySymbols(item).length > 0) throw new Error("Finalization requires JSON string keys.");
|
|
108
|
+
if (Array.isArray(item)) {
|
|
109
|
+
const keys = Object.keys(item);
|
|
110
|
+
if (keys.length !== item.length || keys.some((key, index) => key !== String(index))) throw new Error("Finalization requires dense JSON arrays.");
|
|
111
|
+
}
|
|
112
|
+
ancestors.add(item);
|
|
113
|
+
for (const child of Object.values(item)) check(child);
|
|
114
|
+
ancestors.delete(item);
|
|
115
|
+
}
|
|
116
|
+
check(value);
|
|
117
|
+
return JSON.parse(JSON.stringify(value));
|
|
118
|
+
}
|
|
119
|
+
/** Match the persisted JSON representation before validating finalization execution input. */
|
|
120
|
+
function normalizeFinalizationJson(value) {
|
|
121
|
+
return finalizationJson(JSON.parse(JSON.stringify(value)));
|
|
122
|
+
}
|
|
123
|
+
/** Omit only public optional fields; payloads and patch values retain strict JSON validation. */
|
|
124
|
+
function omitUndefinedOptionalFields(value, keys) {
|
|
125
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return value;
|
|
126
|
+
const copy = { ...value };
|
|
127
|
+
for (const key of keys) if (Object.hasOwn(copy, key) && copy[key] === void 0) delete copy[key];
|
|
128
|
+
return copy;
|
|
129
|
+
}
|
|
130
|
+
function validateFinalization(value, action, privateFields, contracts) {
|
|
131
|
+
const normalized = omitUndefinedOptionalFields(value, ["events", "resultPatch"]);
|
|
132
|
+
if (normalized !== value && normalized && typeof normalized === "object" && !Array.isArray(normalized)) {
|
|
133
|
+
const record = normalized;
|
|
134
|
+
if (Array.isArray(record.events)) {
|
|
135
|
+
const events = Object.assign(record.events.slice(), record.events);
|
|
136
|
+
for (let index = 0; index < events.length; index++) if (Object.hasOwn(events, index)) events[index] = omitUndefinedOptionalFields(events[index], ["eventSchemaVersion"]);
|
|
137
|
+
record.events = events;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const result = finalizationJson(normalized);
|
|
141
|
+
if (!result || typeof result !== "object" || Array.isArray(result) || Object.keys(result).some((key) => key !== "events" && key !== "resultPatch")) throw new Error("Finalization must return only events and resultPatch.");
|
|
142
|
+
if (result.resultPatch !== void 0) {
|
|
143
|
+
if (!result.resultPatch || typeof result.resultPatch !== "object" || Array.isArray(result.resultPatch)) throw new Error("Finalization resultPatch must be an object.");
|
|
144
|
+
if (Object.keys(result.resultPatch).some((key) => privateFields.includes(key))) throw new Error("Finalization resultPatch contains a private result key.");
|
|
145
|
+
}
|
|
146
|
+
if (result.events !== void 0) {
|
|
147
|
+
if (!Array.isArray(result.events)) throw new Error("Finalization events must be an array.");
|
|
148
|
+
for (const event of result.events) {
|
|
149
|
+
if (!event || typeof event !== "object" || Array.isArray(event) || Object.keys(event).some((key) => ![
|
|
150
|
+
"eventType",
|
|
151
|
+
"subjectType",
|
|
152
|
+
"subjectId",
|
|
153
|
+
"payload",
|
|
154
|
+
"eventSchemaVersion"
|
|
155
|
+
].includes(key))) throw new Error("Invalid finalization event fields.");
|
|
156
|
+
for (const key of [
|
|
157
|
+
"eventType",
|
|
158
|
+
"subjectType",
|
|
159
|
+
"subjectId"
|
|
160
|
+
]) if (typeof event[key] !== "string" || !event[key].trim()) throw new Error("Finalization event identifiers must be non-empty.");
|
|
161
|
+
if (!Object.hasOwn(event, "payload")) throw new Error("Finalization event requires a JSON payload.");
|
|
162
|
+
if (event.eventSchemaVersion !== void 0 && (!Number.isInteger(event.eventSchemaVersion) || event.eventSchemaVersion <= 0)) throw new Error("Finalization eventSchemaVersion must be a positive integer.");
|
|
163
|
+
if (!action.emitsEvents.includes(event.eventType)) throw new Error("Finalization emitted undeclared event type.");
|
|
164
|
+
if (contracts && (!contracts.hasEventType(event.eventType) || !contracts.hasSubjectType(event.subjectType))) throw new Error("Finalization emitted an unregistered event or subject type.");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
//#endregion
|
|
98
170
|
//#region src/observability.ts
|
|
99
171
|
/** Version of the vendor-neutral health/readiness contract. */
|
|
100
172
|
const PLATFORM_HOST_HEALTH_CONTRACT_VERSION = 1;
|
|
@@ -1462,6 +1534,7 @@ function createGovernedActionHost(options) {
|
|
|
1462
1534
|
await attemptEvidenceWrites(evidenceWrites);
|
|
1463
1535
|
}
|
|
1464
1536
|
async function failExecution(record, status, error) {
|
|
1537
|
+
if (record.finalizationCheckpoint && hasDurableProviderEffect) return settleAppliedEffect(error);
|
|
1465
1538
|
await persistExecutionInvocation(record, {
|
|
1466
1539
|
status,
|
|
1467
1540
|
error
|
|
@@ -1754,14 +1827,34 @@ function createGovernedActionHost(options) {
|
|
|
1754
1827
|
};
|
|
1755
1828
|
}
|
|
1756
1829
|
const action = actionResolver(invocation.actionId);
|
|
1757
|
-
|
|
1758
|
-
|
|
1830
|
+
let hasNonSucceededAdapterRow = false;
|
|
1831
|
+
if ((action || invocation.finalizationCheckpoint) && (resumingRunningInvocation || invocation.attemptCount > 1)) try {
|
|
1832
|
+
let runningCheckpoint;
|
|
1833
|
+
for (const stepIndex of Array.from({ length: Math.max((action?.adapterSteps ?? []).length, invocation.finalizationCheckpoint?.steps.length ?? 0) }, (_, index) => index)) {
|
|
1759
1834
|
const record = await options.store.getAdapterInvocation(lifecycleId("adp", actionInvocationId, String(stepIndex)), adapterInvocationScope);
|
|
1760
|
-
if (record)
|
|
1835
|
+
if (record) {
|
|
1836
|
+
adapterStatuses.set(record.id, record.status);
|
|
1837
|
+
hasNonSucceededAdapterRow ||= record.status !== "succeeded";
|
|
1838
|
+
}
|
|
1761
1839
|
if (record?.status === "succeeded") hasDurableProviderEffect = true;
|
|
1840
|
+
if (invocation.finalizationCheckpoint && record?.status === "running") runningCheckpoint ??= {
|
|
1841
|
+
stepIndex,
|
|
1842
|
+
record
|
|
1843
|
+
};
|
|
1762
1844
|
if (record?.status !== "ambiguous") continue;
|
|
1763
1845
|
return await settleUnknownOutcome(stepIndex, recordedAdapterReconciliation(record), record.reconciliation?.adapterEventSubject);
|
|
1764
1846
|
}
|
|
1847
|
+
if (runningCheckpoint) {
|
|
1848
|
+
const { stepIndex, record } = runningCheckpoint;
|
|
1849
|
+
return await settleUnknownOutcome(stepIndex, {
|
|
1850
|
+
kind: "adapter_outcome_ambiguous",
|
|
1851
|
+
adapterInvocationId: record.id,
|
|
1852
|
+
adapterType: record.adapterType,
|
|
1853
|
+
operation: record.operation,
|
|
1854
|
+
vendor: record.vendor,
|
|
1855
|
+
message: "Recorded adapter outcome is unknown."
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1765
1858
|
} catch (error) {
|
|
1766
1859
|
if (error instanceof RecoverableFinalizationError) throw error;
|
|
1767
1860
|
throw new RecoverableFinalizationError(caughtError({
|
|
@@ -1771,6 +1864,30 @@ function createGovernedActionHost(options) {
|
|
|
1771
1864
|
}
|
|
1772
1865
|
if (invocation.status === "running" && invocation.pendingCompletion && !invocation.leaseOwner) return withConsistency(actionResult(invocation), invocation.actionId);
|
|
1773
1866
|
if (!action) return hostOperation(() => failExecution(invocation, "failed", `Unknown action: ${invocation.actionId}`));
|
|
1867
|
+
if (invocation.finalizationCheckpoint && !action.finalize) return interruptedNonIdempotent("Action no longer declares its checkpointed finalizer.");
|
|
1868
|
+
let hasFencedAdapterWrites = false;
|
|
1869
|
+
try {
|
|
1870
|
+
assertAdapterStepDeadlines(action);
|
|
1871
|
+
assertActionFinalization(action);
|
|
1872
|
+
if (action.finalize) {
|
|
1873
|
+
const store = asAtomicMutationStore(options.store);
|
|
1874
|
+
if (!store) throw new Error("Action finalization requires atomic mutation transactions.");
|
|
1875
|
+
await store.transactionWithEvents(async (transaction) => {
|
|
1876
|
+
if (typeof transaction.getActionInvocationForUpdate !== "function" || typeof transaction.updateLeasedActionInvocation !== "function" || options.outbox && typeof transaction.appendEventWithOutbox !== "function") throw new Error("Action finalization requires atomic invocation locking and transaction-scoped lease fencing.");
|
|
1877
|
+
hasFencedAdapterWrites = typeof options.store.writeAdapterInvocation === "function" && typeof transaction.writeAdapterInvocation === "function";
|
|
1878
|
+
});
|
|
1879
|
+
}
|
|
1880
|
+
} catch (error) {
|
|
1881
|
+
return interruptedNonIdempotent(caughtError({
|
|
1882
|
+
actionId: action.actionId,
|
|
1883
|
+
source: "execution"
|
|
1884
|
+
}, error));
|
|
1885
|
+
}
|
|
1886
|
+
const replayCheckpoint = action.finalize && (resumingRunningInvocation || invocation.attemptCount > 1) ? invocation.finalizationCheckpoint : void 0;
|
|
1887
|
+
if (replayCheckpoint) {
|
|
1888
|
+
if (replayCheckpoint.protocol !== "atomic-after-adapters-v1" || replayCheckpoint.actionVersion !== action.version || replayCheckpoint.steps.length !== (action.adapterSteps ?? []).length || replayCheckpoint.steps.some((step, index) => step.adapterType !== action.adapterSteps[index]?.adapterType || step.operation !== action.adapterSteps[index]?.operation) || invocation.pendingCompletion) return interruptedNonIdempotent("Finalization checkpoint protocol, action version, step identity or handoff is incompatible with replay.");
|
|
1889
|
+
}
|
|
1890
|
+
if (action.finalize && !replayCheckpoint && (resumingRunningInvocation || invocation.attemptCount > 1) && hasDurableProviderEffect) return interruptedNonIdempotent("Finalizing action recovery requires a preparation checkpoint; reconciliation is required.");
|
|
1774
1891
|
if (action.admitSubmission !== void 0 && !isAdmissionTime(invocation.admittedAt)) return hostOperation(() => failExecution(invocation, "failed", `Invocation of ${action.actionId} was not admitted at submission; an action that declares admitSubmission runs only on invocations its check admitted.`));
|
|
1775
1892
|
if (action.kind === "saga") return hostOperation(() => failExecution(invocation, "failed", `Saga action ${invocation.actionId} requires a durable PlatformActionDispatcher and cannot execute as an atomic Host action.`));
|
|
1776
1893
|
async function interruptedNonIdempotent(message) {
|
|
@@ -1800,8 +1917,9 @@ function createGovernedActionHost(options) {
|
|
|
1800
1917
|
}
|
|
1801
1918
|
return hostOperation(() => failExecution(invocation, "failed", message));
|
|
1802
1919
|
}
|
|
1803
|
-
|
|
1804
|
-
if (invocation.attemptCount
|
|
1920
|
+
const canReplayNonIdempotent = !!replayCheckpoint && !hasNonSucceededAdapterRow && (!executionLease || hasFencedAdapterWrites);
|
|
1921
|
+
if (resumingRunningInvocation && invocation.attemptCount === 0 && !invocation.leaseOwner && !action.idempotent && !canReplayNonIdempotent) return interruptedNonIdempotent(`Interrupted inline action ${invocation.actionId} is not declared idempotent; manual reconciliation is required.`);
|
|
1922
|
+
if (invocation.attemptCount > 1 && !action.idempotent && !canReplayNonIdempotent && invocation.deferredBeforeEffectAttempt !== invocation.attemptCount - 1) return interruptedNonIdempotent(`Interrupted action ${invocation.actionId} is not declared idempotent; manual reconciliation is required.`);
|
|
1805
1923
|
if (invocation.status !== "running") await hostOperation(() => options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, { status: "running" }));
|
|
1806
1924
|
emitPlatformHostTelemetry(options.telemetry, {
|
|
1807
1925
|
kind: "event",
|
|
@@ -2002,144 +2120,209 @@ function createGovernedActionHost(options) {
|
|
|
2002
2120
|
}))));
|
|
2003
2121
|
}
|
|
2004
2122
|
}
|
|
2123
|
+
const preparedInputs = /* @__PURE__ */ new Map();
|
|
2124
|
+
let preparation = replayCheckpoint;
|
|
2005
2125
|
let data;
|
|
2126
|
+
let finalizationInput;
|
|
2006
2127
|
let domainEvents = [];
|
|
2007
2128
|
let pendingTransitionEvent;
|
|
2008
2129
|
try {
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
targetState,
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2130
|
+
if (replayCheckpoint) {
|
|
2131
|
+
data = structuredClone(replayCheckpoint.result);
|
|
2132
|
+
finalizationInput = structuredClone({
|
|
2133
|
+
params: replayCheckpoint.params,
|
|
2134
|
+
handlerData: replayCheckpoint.handlerData
|
|
2135
|
+
});
|
|
2136
|
+
domainEvents = structuredClone(replayCheckpoint.handlerEvents);
|
|
2137
|
+
pendingTransitionEvent = structuredClone(replayCheckpoint.stateTransition);
|
|
2138
|
+
} else {
|
|
2139
|
+
const runHandler = async (db, transaction) => {
|
|
2140
|
+
if (options.outbox && !transaction?.appendEventWithOutbox) throw new HostError("Outbox egress requires transactionWithEvents to provide appendEventWithOutbox before handler execution.");
|
|
2141
|
+
if (action.finalize) {
|
|
2142
|
+
const current = await transaction.getActionInvocationForUpdate(actionInvocationId, tenantId, spaceId);
|
|
2143
|
+
if (!current || current.status !== "running" || current.finalizationCheckpoint || (executionLease ? current.leaseOwner !== executionLease.leaseOwner || current.leaseToken !== executionLease.leaseToken : !!current.leaseOwner)) throw new RecoverableFinalizationError(`Invocation preparation ownership changed: ${actionInvocationId}`);
|
|
2144
|
+
}
|
|
2145
|
+
const binding = action.stateMachine;
|
|
2146
|
+
let replayedTransition;
|
|
2147
|
+
let pendingStateTransition;
|
|
2148
|
+
let validatedTransition;
|
|
2149
|
+
if (binding) {
|
|
2150
|
+
const machine = stateMachineResolver(binding.entityType);
|
|
2151
|
+
const entityId = binding.getEntityId(parsed.data);
|
|
2152
|
+
const getEntityState = transaction?.getEntityState ? transaction.getEntityState.bind(transaction) : options.store.getEntityState.bind(options.store);
|
|
2153
|
+
const currentState = entityId ? await hostOperation(() => getEntityState(tenantId, spaceId, binding.entityType, entityId)) ?? initialState(machine) : initialState(machine);
|
|
2154
|
+
const targetState = typeof binding.targetState === "function" ? binding.targetState(parsed.data) : binding.targetState;
|
|
2155
|
+
if (targetState !== "") {
|
|
2156
|
+
const transition = await evaluateStateMachineTransition(machine, binding.entityType, currentState, targetState, action.actionId, {
|
|
2157
|
+
entity: {
|
|
2158
|
+
...entityId ? { id: entityId } : {},
|
|
2159
|
+
state: currentState
|
|
2160
|
+
},
|
|
2161
|
+
actionInvocationId,
|
|
2162
|
+
actorId: invocation.actorId,
|
|
2163
|
+
parameters: parsed.data,
|
|
2164
|
+
db
|
|
2165
|
+
});
|
|
2166
|
+
validatedTransition = {
|
|
2167
|
+
entityId,
|
|
2168
|
+
currentState,
|
|
2169
|
+
targetState,
|
|
2170
|
+
valid: transition.valid
|
|
2171
|
+
};
|
|
2172
|
+
let replayingAppliedTransition = false;
|
|
2173
|
+
if (action.idempotent && currentState === targetState && !transition.valid && entityId) {
|
|
2174
|
+
let source;
|
|
2175
|
+
if (transaction?.getEntityStateSource) source = await hostOperation(() => transaction.getEntityStateSource(tenantId, spaceId, binding.entityType, entityId));
|
|
2176
|
+
else if (options.store.getEntityStateSource) source = await hostOperation(() => options.store.getEntityStateSource(tenantId, spaceId, binding.entityType, entityId));
|
|
2177
|
+
else {
|
|
2178
|
+
const eventStore = options.store;
|
|
2179
|
+
const listEvents = transaction?.listEvents?.bind(transaction) ?? eventStore.listEvents?.bind(eventStore);
|
|
2180
|
+
const events = listEvents ? await hostOperation(() => listEvents(tenantId, spaceId)) : [];
|
|
2181
|
+
let latest;
|
|
2182
|
+
for (const event of events) 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;
|
|
2183
|
+
if (latest && typeof latest.actionInvocationId === "string") source = {
|
|
2184
|
+
state: latest.payload.toState,
|
|
2185
|
+
actionInvocationId: latest.actionInvocationId
|
|
2186
|
+
};
|
|
2187
|
+
}
|
|
2188
|
+
replayingAppliedTransition = source?.actionInvocationId === actionInvocationId && source.state === currentState;
|
|
2189
|
+
}
|
|
2190
|
+
const pending = invocation.pendingStateTransition;
|
|
2191
|
+
if (replayingAppliedTransition && pending && pending.entityType === binding.entityType && pending.entityId === entityId && pending.toState === currentState) replayedTransition = pending;
|
|
2192
|
+
if (action.idempotent && transition.valid && entityId && action.eventPhase === "after_adapters") {
|
|
2193
|
+
pendingStateTransition = {
|
|
2194
|
+
entityType: binding.entityType,
|
|
2195
|
+
entityId,
|
|
2196
|
+
fromState: currentState,
|
|
2197
|
+
toState: targetState
|
|
2052
2198
|
};
|
|
2199
|
+
if (!transaction) await hostOperation(() => options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, { pendingStateTransition }));
|
|
2053
2200
|
}
|
|
2054
|
-
replayingAppliedTransition
|
|
2201
|
+
if (!transition.valid && !replayingAppliedTransition) throw transition.error != null ? new Error(transition.error) : new HostError("Invalid state transition");
|
|
2055
2202
|
}
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2203
|
+
}
|
|
2204
|
+
const handlerResult = action.handler ? await action.handler({
|
|
2205
|
+
actionInvocationId,
|
|
2206
|
+
tenantId,
|
|
2207
|
+
spaceId,
|
|
2208
|
+
actorId: invocation.actorId,
|
|
2209
|
+
actorType: invocation.actorType,
|
|
2210
|
+
correlationId: invocation.correlationId,
|
|
2211
|
+
...invocation.causationId ? { causationId: invocation.causationId } : {},
|
|
2212
|
+
...invocation.provenance ? { provenance: invocation.provenance } : {},
|
|
2213
|
+
...isAdmissionTime(invocation.admittedAt) ? { admittedAt: invocation.admittedAt } : {},
|
|
2214
|
+
db,
|
|
2215
|
+
services: options.services,
|
|
2216
|
+
...action.finalize ? { finalizationProtocol: "atomic-after-adapters-v1" } : {}
|
|
2217
|
+
}, parsed.data) : {
|
|
2218
|
+
success: true,
|
|
2219
|
+
data: {}
|
|
2220
|
+
};
|
|
2221
|
+
if (!handlerResult.success) throw handlerResult.error != null ? new Error(handlerResult.error) : new HostError("Action handler failed");
|
|
2222
|
+
const handlerData = handlerResult.data ?? {};
|
|
2223
|
+
if (action.resultSchema && !action.finalize) {
|
|
2224
|
+
const publicResult = withoutPrivateHostFields(handlerData, eventResultFields);
|
|
2225
|
+
const parsedResult = action.resultSchema.safeParse(publicResult);
|
|
2226
|
+
if (!parsedResult.success) throw new HostError(`Action result validation failed at ${parsedResult.error.issues.map((issue) => issue.path.map(String).join(".") || "result").join(", ")}`);
|
|
2227
|
+
}
|
|
2228
|
+
const preparedFinalizationInput = action.finalize ? {
|
|
2229
|
+
params: normalizeFinalizationJson(parsed.data),
|
|
2230
|
+
handlerData: normalizeFinalizationJson(withoutPrivateHostFields(handlerData, eventResultFields))
|
|
2231
|
+
} : void 0;
|
|
2232
|
+
const extractedEvents = extractEvents(handlerData);
|
|
2233
|
+
const events = action.finalize ? normalizeFinalizationJson(extractedEvents) : extractedEvents;
|
|
2234
|
+
const undeclaredEvent = events.find((event) => !action.emitsEvents.includes(event.eventType));
|
|
2235
|
+
if (undeclaredEvent) throw new HostError(`${action.actionId} emitted undeclared event type ${undeclaredEvent.eventType}`);
|
|
2236
|
+
if (action.eventPhase !== "after_adapters") for (const [index, event] of events.entries()) await hostOperation(() => appendEvent(invocation, event, `domain:${index}`, action.version, transaction));
|
|
2237
|
+
let transitionEvent;
|
|
2238
|
+
if (binding && validatedTransition?.entityId && (validatedTransition.valid || replayedTransition)) {
|
|
2239
|
+
const { entityId, currentState, targetState } = validatedTransition;
|
|
2240
|
+
if (!events.some((event) => event.eventType === "StateTransitioned" && event.subjectType === binding.entityType && event.subjectId === entityId)) transitionEvent = {
|
|
2241
|
+
eventType: "StateTransitioned",
|
|
2242
|
+
subjectType: binding.entityType,
|
|
2243
|
+
subjectId: entityId,
|
|
2244
|
+
payload: {
|
|
2060
2245
|
entityType: binding.entityType,
|
|
2061
2246
|
entityId,
|
|
2062
|
-
fromState: currentState,
|
|
2063
|
-
toState: targetState
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
}
|
|
2067
|
-
if (!transition.valid && !replayingAppliedTransition) throw transition.error != null ? new Error(transition.error) : new HostError("Invalid state transition");
|
|
2247
|
+
fromState: replayedTransition?.fromState ?? currentState,
|
|
2248
|
+
toState: targetState,
|
|
2249
|
+
causedByAction: action.actionId
|
|
2250
|
+
}
|
|
2251
|
+
};
|
|
2068
2252
|
}
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
...invocation.causationId ? { causationId: invocation.causationId } : {},
|
|
2078
|
-
...invocation.provenance ? { provenance: invocation.provenance } : {},
|
|
2079
|
-
...isAdmissionTime(invocation.admittedAt) ? { admittedAt: invocation.admittedAt } : {},
|
|
2080
|
-
db,
|
|
2081
|
-
services: options.services
|
|
2082
|
-
}, parsed.data) : {
|
|
2083
|
-
success: true,
|
|
2084
|
-
data: {}
|
|
2085
|
-
};
|
|
2086
|
-
if (!handlerResult.success) throw handlerResult.error != null ? new Error(handlerResult.error) : new HostError("Action handler failed");
|
|
2087
|
-
const handlerData = handlerResult.data ?? {};
|
|
2088
|
-
if (action.resultSchema) {
|
|
2089
|
-
const publicResult = withoutPrivateHostFields(handlerData, eventResultFields);
|
|
2090
|
-
const parsedResult = action.resultSchema.safeParse(publicResult);
|
|
2091
|
-
if (!parsedResult.success) throw new HostError(`Action result validation failed at ${parsedResult.error.issues.map((issue) => issue.path.map(String).join(".") || "result").join(", ")}`);
|
|
2092
|
-
}
|
|
2093
|
-
const events = extractEvents(handlerData);
|
|
2094
|
-
const undeclaredEvent = events.find((event) => !action.emitsEvents.includes(event.eventType));
|
|
2095
|
-
if (undeclaredEvent) throw new HostError(`${action.actionId} emitted undeclared event type ${undeclaredEvent.eventType}`);
|
|
2096
|
-
if (action.eventPhase !== "after_adapters") for (const [index, event] of events.entries()) await hostOperation(() => appendEvent(invocation, event, `domain:${index}`, action.version, transaction));
|
|
2097
|
-
let transitionEvent;
|
|
2098
|
-
if (binding && validatedTransition?.entityId && (validatedTransition.valid || replayedTransition)) {
|
|
2099
|
-
const { entityId, currentState, targetState } = validatedTransition;
|
|
2100
|
-
if (!events.some((event) => event.eventType === "StateTransitioned" && event.subjectType === binding.entityType && event.subjectId === entityId)) transitionEvent = {
|
|
2101
|
-
eventType: "StateTransitioned",
|
|
2102
|
-
subjectType: binding.entityType,
|
|
2103
|
-
subjectId: entityId,
|
|
2104
|
-
payload: {
|
|
2105
|
-
entityType: binding.entityType,
|
|
2106
|
-
entityId,
|
|
2107
|
-
fromState: replayedTransition?.fromState ?? currentState,
|
|
2108
|
-
toState: targetState,
|
|
2109
|
-
causedByAction: action.actionId
|
|
2253
|
+
if (transitionEvent && action.eventPhase !== "after_adapters") await hostOperation(() => appendEvent(invocation, transitionEvent, "state:transition", 1, transaction));
|
|
2254
|
+
if (transaction && pendingStateTransition) await hostOperation(() => transaction.updateActionInvocation(actionInvocationId, tenantId, spaceId, { pendingStateTransition }));
|
|
2255
|
+
if (preparedFinalizationInput) {
|
|
2256
|
+
const skipped = [];
|
|
2257
|
+
for (const [index, step] of (action.adapterSteps ?? []).entries()) {
|
|
2258
|
+
const input = step.getInput(structuredClone(preparedFinalizationInput.params), structuredClone(preparedFinalizationInput.handlerData));
|
|
2259
|
+
preparedInputs.set(index, input);
|
|
2260
|
+
if (!input) skipped.push(index);
|
|
2110
2261
|
}
|
|
2262
|
+
preparation = normalizeFinalizationJson({
|
|
2263
|
+
protocol: "atomic-after-adapters-v1",
|
|
2264
|
+
actionVersion: action.version,
|
|
2265
|
+
steps: (action.adapterSteps ?? []).map(({ adapterType, operation }) => ({
|
|
2266
|
+
adapterType,
|
|
2267
|
+
operation
|
|
2268
|
+
})),
|
|
2269
|
+
...preparedFinalizationInput,
|
|
2270
|
+
handlerEvents: events,
|
|
2271
|
+
result: preparedFinalizationInput.handlerData,
|
|
2272
|
+
...transitionEvent ? { stateTransition: transitionEvent } : {},
|
|
2273
|
+
skipped
|
|
2274
|
+
});
|
|
2275
|
+
if (new TextEncoder().encode(canonicalJson(preparation)).byteLength > 262144) throw new HostError("Finalization checkpoint exceeds 256 KiB.");
|
|
2276
|
+
if (executionLease) {
|
|
2277
|
+
if (!await transaction.updateLeasedActionInvocation({
|
|
2278
|
+
id: actionInvocationId,
|
|
2279
|
+
tenantId,
|
|
2280
|
+
spaceId,
|
|
2281
|
+
workerId: executionLease.leaseOwner,
|
|
2282
|
+
leaseToken: executionLease.leaseToken,
|
|
2283
|
+
patch: { finalizationCheckpoint: preparation }
|
|
2284
|
+
})) throw new RecoverableFinalizationError(`Invocation lease lost: ${actionInvocationId}`);
|
|
2285
|
+
} else await transaction.updateActionInvocation(actionInvocationId, tenantId, spaceId, { finalizationCheckpoint: preparation });
|
|
2286
|
+
}
|
|
2287
|
+
return {
|
|
2288
|
+
data: handlerData,
|
|
2289
|
+
domainEvents: events,
|
|
2290
|
+
pendingTransitionEvent: transitionEvent,
|
|
2291
|
+
finalizationInput: preparedFinalizationInput
|
|
2111
2292
|
};
|
|
2112
|
-
}
|
|
2113
|
-
if (transitionEvent && action.eventPhase !== "after_adapters") await hostOperation(() => appendEvent(invocation, transitionEvent, "state:transition", 1, transaction));
|
|
2114
|
-
if (transaction && pendingStateTransition) await hostOperation(() => transaction.updateActionInvocation(actionInvocationId, tenantId, spaceId, { pendingStateTransition }));
|
|
2115
|
-
return {
|
|
2116
|
-
data: handlerData,
|
|
2117
|
-
domainEvents: events,
|
|
2118
|
-
pendingTransitionEvent: transitionEvent
|
|
2119
2293
|
};
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
}
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2294
|
+
const atomicStore = asAtomicMutationStore(options.store);
|
|
2295
|
+
let callbackFailure;
|
|
2296
|
+
const run = async (db, transaction) => {
|
|
2297
|
+
callbackFailure = void 0;
|
|
2298
|
+
try {
|
|
2299
|
+
return await runHandler(db, transaction);
|
|
2300
|
+
} catch (error) {
|
|
2301
|
+
callbackFailure = { error };
|
|
2302
|
+
throw error;
|
|
2303
|
+
}
|
|
2304
|
+
};
|
|
2305
|
+
const executed = await (async () => {
|
|
2306
|
+
try {
|
|
2307
|
+
return atomicStore ? await atomicStore.transactionWithEvents((transaction) => run(transaction.db, transaction)) : await options.store.transaction((db) => run(db));
|
|
2308
|
+
} catch (error) {
|
|
2309
|
+
if (callbackFailure) throw callbackFailure.error;
|
|
2310
|
+
if (action.finalize) throw new RecoverableFinalizationError(caughtError({
|
|
2311
|
+
actionId: action.actionId,
|
|
2312
|
+
source: "execution"
|
|
2313
|
+
}, error));
|
|
2314
|
+
return hostOperation(() => Promise.reject(error));
|
|
2315
|
+
}
|
|
2316
|
+
})();
|
|
2317
|
+
data = executed.data;
|
|
2318
|
+
finalizationInput = executed.finalizationInput;
|
|
2319
|
+
domainEvents = executed.domainEvents;
|
|
2320
|
+
pendingTransitionEvent = executed.pendingTransitionEvent;
|
|
2321
|
+
if (preparation) invocation = {
|
|
2322
|
+
...invocation,
|
|
2323
|
+
finalizationCheckpoint: preparation
|
|
2324
|
+
};
|
|
2325
|
+
}
|
|
2143
2326
|
} catch (error) {
|
|
2144
2327
|
if (error instanceof RecoverableFinalizationError) throw error;
|
|
2145
2328
|
if (isReconciliationRequiredError(error)) {
|
|
@@ -2164,6 +2347,7 @@ function createGovernedActionHost(options) {
|
|
|
2164
2347
|
source: "handler"
|
|
2165
2348
|
}, error)));
|
|
2166
2349
|
}
|
|
2350
|
+
const finalizationSteps = [];
|
|
2167
2351
|
let pendingHandoff = invocation.pendingCompletion;
|
|
2168
2352
|
for (const [stepIndex, step] of (action.adapterSteps ?? []).entries()) {
|
|
2169
2353
|
const adapterInvocationId = lifecycleId("adp", actionInvocationId, String(stepIndex));
|
|
@@ -2233,8 +2417,16 @@ function createGovernedActionHost(options) {
|
|
|
2233
2417
|
}, error));
|
|
2234
2418
|
}
|
|
2235
2419
|
if (previousAdapterInvocation?.status === "ambiguous") return await settleUnknownOutcome(stepIndex, recordedAdapterReconciliation(previousAdapterInvocation), previousAdapterInvocation.reconciliation?.adapterEventSubject);
|
|
2236
|
-
const input = step.getInput(parsed.data, data);
|
|
2237
|
-
if (!input)
|
|
2420
|
+
const input = action.finalize ? preparation.skipped.includes(stepIndex) ? void 0 : replayCheckpoint ? step.getInput(structuredClone(replayCheckpoint.params), structuredClone(replayCheckpoint.handlerData)) : preparedInputs.get(stepIndex) : step.getInput(parsed.data, data);
|
|
2421
|
+
if (!input) {
|
|
2422
|
+
if (action.finalize) finalizationSteps.push({
|
|
2423
|
+
stepIndex,
|
|
2424
|
+
adapterType: step.adapterType,
|
|
2425
|
+
operation: step.operation,
|
|
2426
|
+
status: "skipped"
|
|
2427
|
+
});
|
|
2428
|
+
continue;
|
|
2429
|
+
}
|
|
2238
2430
|
const adapter = adapters.require(step.adapterType, step.operation);
|
|
2239
2431
|
const adapterEventSubject = options.adapterEventSubject?.(action.actionId, parsed.data, data) ?? {
|
|
2240
2432
|
subjectType: "AdapterInvocation",
|
|
@@ -2390,8 +2582,9 @@ function createGovernedActionHost(options) {
|
|
|
2390
2582
|
let thrownOutcome;
|
|
2391
2583
|
let adapterDeadlineTimer;
|
|
2392
2584
|
try {
|
|
2393
|
-
const
|
|
2394
|
-
const
|
|
2585
|
+
const effectiveDeadlineMs = step.deadlineMs ?? options.adapterDeadlineMs;
|
|
2586
|
+
const adapterController = effectiveDeadlineMs ? new AbortController() : void 0;
|
|
2587
|
+
const deadlineMs = effectiveDeadlineMs ? now().getTime() + effectiveDeadlineMs : void 0;
|
|
2395
2588
|
if (adapterController && deadlineMs) {
|
|
2396
2589
|
const remaining = deadlineMs - now().getTime();
|
|
2397
2590
|
if (remaining <= 0) throw new HostError(`Adapter deadline already expired before execution: ${step.adapterType}:${step.operation}`);
|
|
@@ -2576,6 +2769,7 @@ function createGovernedActionHost(options) {
|
|
|
2576
2769
|
...pendingCompletion ? { pendingCompletion } : {}
|
|
2577
2770
|
}, action.actionId);
|
|
2578
2771
|
}
|
|
2772
|
+
if (action.finalize) output = normalizeFinalizationJson(output);
|
|
2579
2773
|
successOutput = output;
|
|
2580
2774
|
if (handoff) {
|
|
2581
2775
|
const reference = typeof handoff.externalReference === "string" ? handoff.externalReference.trim() : "";
|
|
@@ -2809,7 +3003,8 @@ function createGovernedActionHost(options) {
|
|
|
2809
3003
|
return hostOperation(() => failExecution(invocation, "failed", message));
|
|
2810
3004
|
}
|
|
2811
3005
|
}
|
|
2812
|
-
|
|
3006
|
+
if (action.finalize && pendingHandoff) return settleAppliedEffect("Finalizing actions cannot finalize accepted external operations.", pendingHandoff);
|
|
3007
|
+
let result = action.finalize ? { ...replayCheckpoint?.result ?? finalizationInput.handlerData } : withoutPrivateHostFields(data, eventResultFields);
|
|
2813
3008
|
const parked = pendingHandoff ? {
|
|
2814
3009
|
...pendingHandoff,
|
|
2815
3010
|
parkedAt: now()
|
|
@@ -2823,25 +3018,83 @@ function createGovernedActionHost(options) {
|
|
|
2823
3018
|
result
|
|
2824
3019
|
};
|
|
2825
3020
|
let verdictWritten = true;
|
|
3021
|
+
let finalizationCallbackFailure;
|
|
2826
3022
|
try {
|
|
2827
3023
|
const atomicStore = asAtomicMutationStore(options.store);
|
|
2828
3024
|
if (action.eventPhase === "after_adapters" && atomicStore) {
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
if (
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
3025
|
+
if (action.finalize) for (const [stepIndex, step] of (action.adapterSteps ?? []).entries()) {
|
|
3026
|
+
if (finalizationSteps.some((entry) => entry.stepIndex === stepIndex)) continue;
|
|
3027
|
+
const row = await options.store.getAdapterInvocation(lifecycleId("adp", actionInvocationId, String(stepIndex)), adapterInvocationScope);
|
|
3028
|
+
if (row?.status !== "succeeded" || row.output === void 0) {
|
|
3029
|
+
const error = /* @__PURE__ */ new Error("Finalization requires successful stored adapter output.");
|
|
3030
|
+
finalizationCallbackFailure = { error };
|
|
3031
|
+
throw error;
|
|
3032
|
+
}
|
|
3033
|
+
finalizationSteps.push({
|
|
3034
|
+
stepIndex,
|
|
3035
|
+
adapterType: step.adapterType,
|
|
3036
|
+
operation: step.operation,
|
|
3037
|
+
status: "succeeded",
|
|
3038
|
+
adapterInvocationId: row.id,
|
|
3039
|
+
output: finalizationJson(row.output)
|
|
3040
|
+
});
|
|
3041
|
+
}
|
|
3042
|
+
const finalizeSuccess = () => atomicStore.transactionWithEvents(async (transaction) => {
|
|
3043
|
+
try {
|
|
3044
|
+
let finalized;
|
|
3045
|
+
async function assertFinalizationOwner() {
|
|
3046
|
+
const current = await transaction.getActionInvocationForUpdate(actionInvocationId, tenantId, spaceId);
|
|
3047
|
+
if (!current || current.status !== "running" || (executionLease ? current.leaseOwner !== executionLease.leaseOwner || current.leaseToken !== executionLease.leaseToken : !!current.leaseOwner)) throw new RecoverableFinalizationError(`Invocation lease lost or no longer running: ${actionInvocationId}`);
|
|
3048
|
+
}
|
|
3049
|
+
if (action.finalize) {
|
|
3050
|
+
await assertFinalizationOwner();
|
|
3051
|
+
finalized = validateFinalization(await action.finalize({
|
|
3052
|
+
actionInvocationId,
|
|
3053
|
+
tenantId,
|
|
3054
|
+
spaceId,
|
|
3055
|
+
actorId: invocation.actorId,
|
|
3056
|
+
actorType: invocation.actorType,
|
|
3057
|
+
correlationId: invocation.correlationId,
|
|
3058
|
+
causationId: invocation.causationId,
|
|
3059
|
+
provenance: invocation.provenance ? structuredClone(invocation.provenance) : void 0,
|
|
3060
|
+
db: transaction.db
|
|
3061
|
+
}, {
|
|
3062
|
+
...structuredClone(finalizationInput),
|
|
3063
|
+
adapterOutputs: structuredClone(finalizationSteps.sort((a, b) => a.stepIndex - b.stepIndex))
|
|
3064
|
+
}), action, eventResultFields, options.eventContracts);
|
|
3065
|
+
result = withoutPrivateHostFields({
|
|
3066
|
+
...result,
|
|
3067
|
+
...finalized.resultPatch
|
|
3068
|
+
}, eventResultFields);
|
|
3069
|
+
if (action.resultSchema && !action.resultSchema.safeParse(result).success) throw new Error("Finalization merged result does not match resultSchema.");
|
|
3070
|
+
finalPatch.result = result;
|
|
3071
|
+
}
|
|
3072
|
+
for (const [index, event] of domainEvents.entries()) await appendEvent(invocation, event, `domain:${index}`, action.version, transaction);
|
|
3073
|
+
for (const [index, event] of (finalized?.events ?? []).entries()) await appendEvent(invocation, event, `finalize:${index}`, action.version, transaction);
|
|
3074
|
+
if (pendingTransitionEvent) await appendEvent(invocation, pendingTransitionEvent, "state:transition", 1, transaction);
|
|
3075
|
+
if (action.finalize) await assertFinalizationOwner();
|
|
3076
|
+
if (executionLease) {
|
|
3077
|
+
if (!transaction.updateLeasedActionInvocation) throw new HostError("Leased atomic finalization requires transaction-scoped fencing support.");
|
|
3078
|
+
if (!await transaction.updateLeasedActionInvocation({
|
|
3079
|
+
id: actionInvocationId,
|
|
3080
|
+
tenantId,
|
|
3081
|
+
spaceId,
|
|
3082
|
+
workerId: executionLease.leaseOwner,
|
|
3083
|
+
leaseToken: executionLease.leaseToken,
|
|
3084
|
+
patch: finalPatch
|
|
3085
|
+
})) throw new RecoverableFinalizationError(`Invocation lease lost: ${actionInvocationId}`);
|
|
3086
|
+
} else verdictWritten = await writeInlineInvocation(invocation, finalPatch, transaction, onPreservedReconciliation);
|
|
3087
|
+
} catch (error) {
|
|
3088
|
+
finalizationCallbackFailure = { error };
|
|
3089
|
+
throw error;
|
|
3090
|
+
}
|
|
3091
|
+
});
|
|
3092
|
+
if (action.finalize) await finalizeSuccess();
|
|
3093
|
+
else await hostOperation(finalizeSuccess);
|
|
3094
|
+
if (!pendingHandoff && verdictWritten) {
|
|
3095
|
+
if (action.finalize) rememberVerdict(finalPatch);
|
|
3096
|
+
emitInvocationStatusTelemetry(invocation, "completed", options.telemetry, now());
|
|
3097
|
+
}
|
|
2845
3098
|
} else {
|
|
2846
3099
|
if (action.eventPhase === "after_adapters") {
|
|
2847
3100
|
for (const [index, event] of domainEvents.entries()) await hostOperation(() => appendEvent(invocation, event, `domain:${index}`, action.version));
|
|
@@ -2850,6 +3103,18 @@ function createGovernedActionHost(options) {
|
|
|
2850
3103
|
await hostOperation(() => persistExecutionInvocation(invocation, finalPatch));
|
|
2851
3104
|
}
|
|
2852
3105
|
} catch (error) {
|
|
3106
|
+
if (action.finalize) {
|
|
3107
|
+
if (error instanceof RecoverableFinalizationError) throw error;
|
|
3108
|
+
if (!finalizationCallbackFailure || finalizationCallbackFailure.error !== error) throw new RecoverableFinalizationError(caughtError({
|
|
3109
|
+
actionId: action.actionId,
|
|
3110
|
+
source: "execution"
|
|
3111
|
+
}, error));
|
|
3112
|
+
const message = caughtError({
|
|
3113
|
+
actionId: action.actionId,
|
|
3114
|
+
source: "execution"
|
|
3115
|
+
}, error);
|
|
3116
|
+
return hasDurableProviderEffect ? settleAppliedEffect(message) : hostOperation(() => failExecution(invocation, "failed", message));
|
|
3117
|
+
}
|
|
2853
3118
|
if (action.eventPhase === "after_adapters") throw new RecoverableFinalizationError(errorMessage(error));
|
|
2854
3119
|
throw error;
|
|
2855
3120
|
}
|
|
@@ -2864,7 +3129,13 @@ function createGovernedActionHost(options) {
|
|
|
2864
3129
|
} catch (error) {
|
|
2865
3130
|
if (error instanceof RecoverableFinalizationError) throw error;
|
|
2866
3131
|
if (retryableGovernance && isRetryableExecutionError(error)) {
|
|
2867
|
-
|
|
3132
|
+
let reason = executionReasonOverride === "approval_resume" ? "approval resume" : !invocation.leaseOwner || invocation.leaseToken === void 0 ? "not leased" : invocation.attemptCount >= retryableExecutionMaxAttempts ? `attempts exhausted (${invocation.attemptCount}/${retryableExecutionMaxAttempts})` : void 0;
|
|
3133
|
+
if (!reason && !action.idempotent) try {
|
|
3134
|
+
await persistExecutionInvocation(invocation, { deferredBeforeEffectAttempt: invocation.attemptCount });
|
|
3135
|
+
} catch (markerError) {
|
|
3136
|
+
if (markerError instanceof RecoverableFinalizationError) throw markerError;
|
|
3137
|
+
reason = "pre-effect marker could not be recorded";
|
|
3138
|
+
}
|
|
2868
3139
|
if (!reason) throw error;
|
|
2869
3140
|
return hostOperation(() => failExecution(invocation, "failed", caughtError({
|
|
2870
3141
|
actionId: invocation.actionId,
|
|
@@ -3670,6 +3941,11 @@ var MemoryPlatformHostStore = class {
|
|
|
3670
3941
|
const record = this.invocations.find((row) => row.id === id && row.tenantId === tenantId && row.spaceId === spaceId);
|
|
3671
3942
|
if (!record) throw new Error(`ActionInvocation not found: ${id}`);
|
|
3672
3943
|
Object.assign(record, patch, { updatedAt: /* @__PURE__ */ new Date() });
|
|
3944
|
+
if (Object.hasOwn(patch, "finalizationCheckpoint")) {
|
|
3945
|
+
if (patch.finalizationCheckpoint) record.finalizationCheckpoint = structuredClone(patch.finalizationCheckpoint);
|
|
3946
|
+
else delete record.finalizationCheckpoint;
|
|
3947
|
+
}
|
|
3948
|
+
if (Object.hasOwn(patch, "deferredBeforeEffectAttempt") && patch.deferredBeforeEffectAttempt === void 0) delete record.deferredBeforeEffectAttempt;
|
|
3673
3949
|
if (Object.hasOwn(patch, "pendingStateTransition")) {
|
|
3674
3950
|
if (patch.pendingStateTransition) record.pendingStateTransition = { ...patch.pendingStateTransition };
|
|
3675
3951
|
else delete record.pendingStateTransition;
|
|
@@ -4306,6 +4582,16 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
4306
4582
|
sql: `
|
|
4307
4583
|
ALTER TABLE fabric_platform.adapter_invocations ADD COLUMN IF NOT EXISTS reconciliation jsonb;
|
|
4308
4584
|
`
|
|
4585
|
+
},
|
|
4586
|
+
{
|
|
4587
|
+
version: 9,
|
|
4588
|
+
name: "pre_effect_governance_deferral",
|
|
4589
|
+
sql: `ALTER TABLE fabric_platform.action_invocations ADD COLUMN IF NOT EXISTS deferred_before_effect_attempt integer;`
|
|
4590
|
+
},
|
|
4591
|
+
{
|
|
4592
|
+
version: 10,
|
|
4593
|
+
name: "finalization_preparation_checkpoint",
|
|
4594
|
+
sql: `ALTER TABLE fabric_platform.action_invocations ADD COLUMN IF NOT EXISTS finalization_checkpoint jsonb;`
|
|
4309
4595
|
}
|
|
4310
4596
|
]);
|
|
4311
4597
|
}
|
|
@@ -4379,6 +4665,8 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
4379
4665
|
pending_completion=CASE WHEN $12::boolean THEN $13::jsonb ELSE pending_completion END,
|
|
4380
4666
|
completion_due_at=CASE WHEN $12::boolean THEN ($13::jsonb->>'dueAt')::timestamptz ELSE completion_due_at END,
|
|
4381
4667
|
external_completion=CASE WHEN $14::boolean THEN $15::jsonb ELSE external_completion END,
|
|
4668
|
+
${Object.hasOwn(patch, "finalizationCheckpoint") ? `finalization_checkpoint=$${18 + (Object.hasOwn(patch, "deferredBeforeEffectAttempt") ? 2 : 0)}::jsonb,` : ""}
|
|
4669
|
+
${Object.hasOwn(patch, "deferredBeforeEffectAttempt") ? "deferred_before_effect_attempt=CASE WHEN $18::boolean THEN $19::integer ELSE deferred_before_effect_attempt END," : ""}
|
|
4382
4670
|
pending_state_transition=CASE WHEN $16::boolean THEN $17::jsonb ELSE pending_state_transition END,
|
|
4383
4671
|
lease_owner=CASE WHEN ($13::jsonb IS NOT NULL AND $4='running') OR $4 IN ('waiting_for_approval','completed','failed','blocked_by_policy','reconciliation_required','validation_failed')
|
|
4384
4672
|
THEN NULL ELSE lease_owner END,
|
|
@@ -4402,7 +4690,9 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
4402
4690
|
Object.hasOwn(patch, "externalCompletion"),
|
|
4403
4691
|
patch.externalCompletion ? JSON.stringify(patch.externalCompletion) : null,
|
|
4404
4692
|
Object.hasOwn(patch, "pendingStateTransition"),
|
|
4405
|
-
patch.pendingStateTransition ? JSON.stringify(patch.pendingStateTransition) : null
|
|
4693
|
+
patch.pendingStateTransition ? JSON.stringify(patch.pendingStateTransition) : null,
|
|
4694
|
+
...Object.hasOwn(patch, "deferredBeforeEffectAttempt") ? [true, patch.deferredBeforeEffectAttempt ?? null] : [],
|
|
4695
|
+
...Object.hasOwn(patch, "finalizationCheckpoint") ? [patch.finalizationCheckpoint ? JSON.stringify(patch.finalizationCheckpoint) : null] : []
|
|
4406
4696
|
]);
|
|
4407
4697
|
}
|
|
4408
4698
|
async recordHitlDecision(actionInvocationId, tenantId, spaceId, decision) {
|
|
@@ -5032,6 +5322,8 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
5032
5322
|
pending_completion=CASE WHEN $14::boolean THEN $15::jsonb ELSE pending_completion END,
|
|
5033
5323
|
completion_due_at=CASE WHEN $14::boolean THEN ($15::jsonb->>'dueAt')::timestamptz ELSE completion_due_at END,
|
|
5034
5324
|
external_completion=CASE WHEN $16::boolean THEN $17::jsonb ELSE external_completion END,
|
|
5325
|
+
${Object.hasOwn(patch, "finalizationCheckpoint") ? `finalization_checkpoint=$${20 + (Object.hasOwn(patch, "deferredBeforeEffectAttempt") ? 2 : 0)}::jsonb,` : ""}
|
|
5326
|
+
${Object.hasOwn(patch, "deferredBeforeEffectAttempt") ? "deferred_before_effect_attempt=CASE WHEN $20::boolean THEN $21::integer ELSE deferred_before_effect_attempt END," : ""}
|
|
5035
5327
|
pending_state_transition=CASE WHEN $18::boolean THEN $19::jsonb ELSE pending_state_transition END,
|
|
5036
5328
|
lease_owner=CASE WHEN ($15::jsonb IS NOT NULL AND $6='running') OR $6 IN ('waiting_for_approval','completed','failed','blocked_by_policy','reconciliation_required','validation_failed')
|
|
5037
5329
|
THEN NULL ELSE lease_owner END,
|
|
@@ -5059,7 +5351,9 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
5059
5351
|
Object.hasOwn(patch, "externalCompletion"),
|
|
5060
5352
|
patch.externalCompletion ? JSON.stringify(patch.externalCompletion) : null,
|
|
5061
5353
|
Object.hasOwn(patch, "pendingStateTransition"),
|
|
5062
|
-
patch.pendingStateTransition ? JSON.stringify(patch.pendingStateTransition) : null
|
|
5354
|
+
patch.pendingStateTransition ? JSON.stringify(patch.pendingStateTransition) : null,
|
|
5355
|
+
...Object.hasOwn(patch, "deferredBeforeEffectAttempt") ? [true, patch.deferredBeforeEffectAttempt ?? null] : [],
|
|
5356
|
+
...Object.hasOwn(patch, "finalizationCheckpoint") ? [patch.finalizationCheckpoint ? JSON.stringify(patch.finalizationCheckpoint) : null] : []
|
|
5063
5357
|
])).rows.length === 1;
|
|
5064
5358
|
}
|
|
5065
5359
|
async renewActionInvocationLease(input) {
|
|
@@ -5151,11 +5445,13 @@ function toActionRecord(row) {
|
|
|
5151
5445
|
...row.execution_reason ? { executionReason: String(row.execution_reason) } : {},
|
|
5152
5446
|
...row.authorization_reconciliation ? { authorizationReconciliation: row.authorization_reconciliation } : {},
|
|
5153
5447
|
...row.adapter_reconciliation ? { adapterReconciliation: row.adapter_reconciliation } : {},
|
|
5448
|
+
...row.finalization_checkpoint ? { finalizationCheckpoint: row.finalization_checkpoint } : {},
|
|
5154
5449
|
...row.pending_state_transition ? { pendingStateTransition: row.pending_state_transition } : {},
|
|
5155
5450
|
...row.pending_completion ? { pendingCompletion: toPendingCompletion(row.pending_completion) } : {},
|
|
5156
5451
|
...row.external_completion ? { externalCompletion: toExternalCompletion(row.external_completion) } : {},
|
|
5157
5452
|
...row.authorization_binding_id ? { authorizationBindingId: String(row.authorization_binding_id) } : {},
|
|
5158
5453
|
attemptCount: Number(row.attempt_count ?? 0),
|
|
5454
|
+
...row.deferred_before_effect_attempt == null ? {} : { deferredBeforeEffectAttempt: Number(row.deferred_before_effect_attempt) },
|
|
5159
5455
|
leaseToken: Number(row.lease_token ?? 0),
|
|
5160
5456
|
...row.lease_owner ? { leaseOwner: String(row.lease_owner) } : {},
|
|
5161
5457
|
...row.lease_expires_at ? { leaseExpiresAt: new Date(row.lease_expires_at) } : {},
|