@fabricorg/platform-host 8.1.2 → 8.3.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 +79 -0
- package/README.md +288 -18
- package/dist/index.cjs +1640 -173
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +387 -18
- package/dist/index.d.ts +387 -18
- package/dist/index.js +1632 -175
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
package/dist/index.cjs
CHANGED
|
@@ -36,6 +36,18 @@ var ActionAdmissionError = class extends Error {
|
|
|
36
36
|
this.name = "ActionAdmissionError";
|
|
37
37
|
}
|
|
38
38
|
};
|
|
39
|
+
/** A definitive authorization-boundary refusal before an invocation is created. */
|
|
40
|
+
var SubmissionRefusalError = class extends Error {
|
|
41
|
+
actionId;
|
|
42
|
+
refusal;
|
|
43
|
+
code = "ACTION_SUBMISSION_REFUSED";
|
|
44
|
+
constructor(actionId, refusal, message) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.actionId = actionId;
|
|
47
|
+
this.refusal = refusal;
|
|
48
|
+
this.name = "SubmissionRefusalError";
|
|
49
|
+
}
|
|
50
|
+
};
|
|
39
51
|
/**
|
|
40
52
|
* Why a completion was not applied, typed so a webhook handler can answer
|
|
41
53
|
* the external system correctly: `retryable` means the invocation is still
|
|
@@ -96,6 +108,82 @@ function normalizeProvenance(input, options) {
|
|
|
96
108
|
};
|
|
97
109
|
}
|
|
98
110
|
//#endregion
|
|
111
|
+
//#region src/action-finalization.ts
|
|
112
|
+
/** Strict JSON evidence: reject lossy serialization rather than silently dropping values. */
|
|
113
|
+
function finalizationJson(value) {
|
|
114
|
+
const ancestors = /* @__PURE__ */ new Set();
|
|
115
|
+
function check(item) {
|
|
116
|
+
if (item === null || typeof item === "string" || typeof item === "boolean") return;
|
|
117
|
+
if (typeof item === "number" && Number.isFinite(item)) return;
|
|
118
|
+
if (typeof item !== "object" || !item || ancestors.has(item)) throw new Error("Finalization requires JSON-compatible data.");
|
|
119
|
+
if (!Array.isArray(item) && Object.getPrototypeOf(item) !== Object.prototype && Object.getPrototypeOf(item) !== null) throw new Error("Finalization requires plain JSON objects.");
|
|
120
|
+
if (Object.getOwnPropertySymbols(item).length > 0) throw new Error("Finalization requires JSON string keys.");
|
|
121
|
+
if (Array.isArray(item)) {
|
|
122
|
+
const keys = Object.keys(item);
|
|
123
|
+
if (keys.length !== item.length || keys.some((key, index) => key !== String(index))) throw new Error("Finalization requires dense JSON arrays.");
|
|
124
|
+
}
|
|
125
|
+
ancestors.add(item);
|
|
126
|
+
for (const child of Object.values(item)) check(child);
|
|
127
|
+
ancestors.delete(item);
|
|
128
|
+
}
|
|
129
|
+
check(value);
|
|
130
|
+
return JSON.parse(JSON.stringify(value));
|
|
131
|
+
}
|
|
132
|
+
/** Match the persisted JSON representation before validating finalization execution input. */
|
|
133
|
+
function normalizeFinalizationJson(value) {
|
|
134
|
+
return finalizationJson(JSON.parse(JSON.stringify(value)));
|
|
135
|
+
}
|
|
136
|
+
/** Omit only public optional fields; payloads and patch values retain strict JSON validation. */
|
|
137
|
+
function omitUndefinedOptionalFields(value, keys) {
|
|
138
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return value;
|
|
139
|
+
const copy = { ...value };
|
|
140
|
+
for (const key of keys) if (Object.hasOwn(copy, key) && copy[key] === void 0) delete copy[key];
|
|
141
|
+
return copy;
|
|
142
|
+
}
|
|
143
|
+
function validateFinalization(value, action, privateFields, contracts) {
|
|
144
|
+
const normalized = omitUndefinedOptionalFields(value, [
|
|
145
|
+
"events",
|
|
146
|
+
"resultPatch",
|
|
147
|
+
"stagedInvocations"
|
|
148
|
+
]);
|
|
149
|
+
if (normalized !== value && normalized && typeof normalized === "object" && !Array.isArray(normalized)) {
|
|
150
|
+
const record = normalized;
|
|
151
|
+
if (Array.isArray(record.events)) {
|
|
152
|
+
const events = Object.assign(record.events.slice(), record.events);
|
|
153
|
+
for (let index = 0; index < events.length; index++) if (Object.hasOwn(events, index)) events[index] = omitUndefinedOptionalFields(events[index], ["eventSchemaVersion"]);
|
|
154
|
+
record.events = events;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const result = finalizationJson(normalized);
|
|
158
|
+
if (!result || typeof result !== "object" || Array.isArray(result) || Object.keys(result).some((key) => key !== "events" && key !== "resultPatch" && key !== "stagedInvocations")) throw new Error("Finalization must return only events, resultPatch and stagedInvocations.");
|
|
159
|
+
if (result.resultPatch !== void 0) {
|
|
160
|
+
if (!result.resultPatch || typeof result.resultPatch !== "object" || Array.isArray(result.resultPatch)) throw new Error("Finalization resultPatch must be an object.");
|
|
161
|
+
if (Object.keys(result.resultPatch).some((key) => privateFields.includes(key))) throw new Error("Finalization resultPatch contains a private result key.");
|
|
162
|
+
}
|
|
163
|
+
if (result.events !== void 0) {
|
|
164
|
+
if (!Array.isArray(result.events)) throw new Error("Finalization events must be an array.");
|
|
165
|
+
for (const event of result.events) {
|
|
166
|
+
if (!event || typeof event !== "object" || Array.isArray(event) || Object.keys(event).some((key) => ![
|
|
167
|
+
"eventType",
|
|
168
|
+
"subjectType",
|
|
169
|
+
"subjectId",
|
|
170
|
+
"payload",
|
|
171
|
+
"eventSchemaVersion"
|
|
172
|
+
].includes(key))) throw new Error("Invalid finalization event fields.");
|
|
173
|
+
for (const key of [
|
|
174
|
+
"eventType",
|
|
175
|
+
"subjectType",
|
|
176
|
+
"subjectId"
|
|
177
|
+
]) if (typeof event[key] !== "string" || !event[key].trim()) throw new Error("Finalization event identifiers must be non-empty.");
|
|
178
|
+
if (!Object.hasOwn(event, "payload")) throw new Error("Finalization event requires a JSON payload.");
|
|
179
|
+
if (event.eventSchemaVersion !== void 0 && (!Number.isInteger(event.eventSchemaVersion) || event.eventSchemaVersion <= 0)) throw new Error("Finalization eventSchemaVersion must be a positive integer.");
|
|
180
|
+
if (!action.emitsEvents.includes(event.eventType)) throw new Error("Finalization emitted undeclared event type.");
|
|
181
|
+
if (contracts && (!contracts.hasEventType(event.eventType) || !contracts.hasSubjectType(event.subjectType))) throw new Error("Finalization emitted an unregistered event or subject type.");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
//#endregion
|
|
99
187
|
//#region src/observability.ts
|
|
100
188
|
/** Version of the vendor-neutral health/readiness contract. */
|
|
101
189
|
const PLATFORM_HOST_HEALTH_CONTRACT_VERSION = 1;
|
|
@@ -1077,6 +1165,120 @@ function createOutboxRequeue(deps) {
|
|
|
1077
1165
|
};
|
|
1078
1166
|
}
|
|
1079
1167
|
//#endregion
|
|
1168
|
+
//#region src/staged-invocations.ts
|
|
1169
|
+
const MAX_STAGED_INVOCATIONS = 32;
|
|
1170
|
+
const MAX_STAGED_INVOCATION_KEY_LENGTH = 128;
|
|
1171
|
+
const MAX_STAGED_INVOCATION_BYTES = 65536;
|
|
1172
|
+
const MAX_STAGED_INVOCATION_BATCH_BYTES = 262144;
|
|
1173
|
+
const STAGED_INVOCATION_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
|
|
1174
|
+
/** Validate and normalize one immutable batch before its parent transaction commits. */
|
|
1175
|
+
function prepareStagedInvocationBatch(input) {
|
|
1176
|
+
if (!Array.isArray(input.requests)) throw new Error("stagedInvocations must be an array.");
|
|
1177
|
+
if (input.requests.length > 32) throw new Error(`stagedInvocations exceeds the 32 request limit.`);
|
|
1178
|
+
const normalized = input.requests.map((candidate, index) => {
|
|
1179
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate) || Object.keys(candidate).some((key) => ![
|
|
1180
|
+
"key",
|
|
1181
|
+
"actionId",
|
|
1182
|
+
"parameters"
|
|
1183
|
+
].includes(key))) throw new Error(`stagedInvocations[${index}] must contain only key, actionId and parameters.`);
|
|
1184
|
+
const request = {
|
|
1185
|
+
key: candidate.key,
|
|
1186
|
+
actionId: candidate.actionId,
|
|
1187
|
+
parameters: candidate.parameters
|
|
1188
|
+
};
|
|
1189
|
+
if (typeof request.key !== "string" || request.key.length === 0 || request.key.length > 128 || !STAGED_INVOCATION_KEY_PATTERN.test(request.key)) throw new Error(`stagedInvocations[${index}] key must match ${STAGED_INVOCATION_KEY_PATTERN} and be at most 128 characters.`);
|
|
1190
|
+
if (!(input.parentAction.stagesInvocations ?? []).includes(request.actionId)) throw new Error(`Action "${input.parentAction.actionId}" may not stage "${String(request.actionId)}".`);
|
|
1191
|
+
const child = input.resolveAction(request.actionId);
|
|
1192
|
+
if (!child) throw new Error(`Staged action "${String(request.actionId)}" is not registered.`);
|
|
1193
|
+
const parameters = finalizationJson(request.parameters);
|
|
1194
|
+
if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) throw new Error(`stagedInvocations[${index}] parameters must be a JSON object.`);
|
|
1195
|
+
if (!child.schema.safeParse(parameters).success) throw new Error(`stagedInvocations[${index}] parameters do not match action "${child.actionId}".`);
|
|
1196
|
+
let redactedParameters = parameters;
|
|
1197
|
+
if (input.redactParameters && !input.redactedKeys?.includes(request.key)) try {
|
|
1198
|
+
redactedParameters = input.redactParameters(child.actionId, structuredClone(parameters));
|
|
1199
|
+
} catch {
|
|
1200
|
+
throw new Error("Staged invocation parameter redaction failed.");
|
|
1201
|
+
}
|
|
1202
|
+
const durableParameters = finalizationJson(redactedParameters);
|
|
1203
|
+
if (!durableParameters || typeof durableParameters !== "object" || Array.isArray(durableParameters) || !child.schema.safeParse(durableParameters).success) throw new Error(`stagedInvocations[${index}] redacted parameters do not match action "${child.actionId}".`);
|
|
1204
|
+
if (byteLength((0, _fabricorg_platform.canonicalJson)({
|
|
1205
|
+
key: request.key,
|
|
1206
|
+
actionId: child.actionId,
|
|
1207
|
+
parameters: durableParameters
|
|
1208
|
+
})) > 65536) throw new Error(`stagedInvocations[${index}] exceeds ${MAX_STAGED_INVOCATION_BYTES} bytes.`);
|
|
1209
|
+
return {
|
|
1210
|
+
key: request.key,
|
|
1211
|
+
actionId: child.actionId,
|
|
1212
|
+
expectedActionVersion: child.version,
|
|
1213
|
+
parameters: durableParameters,
|
|
1214
|
+
parameterDigest: digestParameters(durableParameters)
|
|
1215
|
+
};
|
|
1216
|
+
});
|
|
1217
|
+
for (const [index, request] of normalized.entries()) if (normalized.findIndex((candidate) => candidate.key === request.key) !== index) throw new Error(`stagedInvocations contains duplicate key "${request.key}".`);
|
|
1218
|
+
normalized.sort((left, right) => compareText(left.key, right.key) || compareText(left.actionId, right.actionId));
|
|
1219
|
+
const batchIdentity = normalized.map(({ key, actionId, expectedActionVersion, parameters, parameterDigest }) => ({
|
|
1220
|
+
key,
|
|
1221
|
+
actionId,
|
|
1222
|
+
expectedActionVersion,
|
|
1223
|
+
parameters,
|
|
1224
|
+
parameterDigest
|
|
1225
|
+
}));
|
|
1226
|
+
if (byteLength((0, _fabricorg_platform.canonicalJson)(batchIdentity)) > 262144) throw new Error(`stagedInvocations batch exceeds ${MAX_STAGED_INVOCATION_BATCH_BYTES} bytes.`);
|
|
1227
|
+
const batchDigest = digestParameters({
|
|
1228
|
+
parentInvocationId: input.parent.id,
|
|
1229
|
+
requests: batchIdentity
|
|
1230
|
+
});
|
|
1231
|
+
return normalized.map((request) => ({
|
|
1232
|
+
id: lifecycleId("stg", input.parent.id, request.key),
|
|
1233
|
+
tenantId: input.parent.tenantId,
|
|
1234
|
+
spaceId: input.parent.spaceId,
|
|
1235
|
+
parentInvocationId: input.parent.id,
|
|
1236
|
+
parentActionId: input.parent.actionId,
|
|
1237
|
+
...request,
|
|
1238
|
+
batchDigest,
|
|
1239
|
+
status: "pending",
|
|
1240
|
+
attemptCount: 0,
|
|
1241
|
+
availableAt: new Date(input.now),
|
|
1242
|
+
leaseToken: 0,
|
|
1243
|
+
createdAt: new Date(input.now),
|
|
1244
|
+
updatedAt: new Date(input.now)
|
|
1245
|
+
}));
|
|
1246
|
+
}
|
|
1247
|
+
function stagedInvocationRequestIdentity(record) {
|
|
1248
|
+
return (0, _fabricorg_platform.canonicalJson)({
|
|
1249
|
+
id: record.id,
|
|
1250
|
+
tenantId: record.tenantId,
|
|
1251
|
+
spaceId: record.spaceId,
|
|
1252
|
+
parentInvocationId: record.parentInvocationId,
|
|
1253
|
+
parentActionId: record.parentActionId,
|
|
1254
|
+
key: record.key,
|
|
1255
|
+
actionId: record.actionId,
|
|
1256
|
+
expectedActionVersion: record.expectedActionVersion,
|
|
1257
|
+
parameters: record.parameters,
|
|
1258
|
+
parameterDigest: record.parameterDigest,
|
|
1259
|
+
batchDigest: record.batchDigest
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
function cloneStagedInvocationRecord(record) {
|
|
1263
|
+
return {
|
|
1264
|
+
...structuredClone(record),
|
|
1265
|
+
availableAt: new Date(record.availableAt),
|
|
1266
|
+
...record.leaseExpiresAt ? { leaseExpiresAt: new Date(record.leaseExpiresAt) } : {},
|
|
1267
|
+
createdAt: new Date(record.createdAt),
|
|
1268
|
+
updatedAt: new Date(record.updatedAt)
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
function asStagedInvocationStore(store) {
|
|
1272
|
+
const candidate = store;
|
|
1273
|
+
return candidate.transactionalStagedInvocations === true && typeof candidate.getActionInvocationByStagedOrigin === "function" && typeof candidate.createActionInvocationForStagedLease === "function" && typeof candidate.claimStagedInvocations === "function" && typeof candidate.markStagedInvocationDispatched === "function" && typeof candidate.markStagedInvocationRejected === "function" && typeof candidate.markStagedInvocationFailed === "function" && typeof candidate.renewStagedInvocationLease === "function" && typeof candidate.rotateStagedInvocationLease === "function" && typeof candidate.cancelStagedInvocationsForFailedParents === "function" && typeof candidate.listStagedInvocations === "function" ? candidate : void 0;
|
|
1274
|
+
}
|
|
1275
|
+
function byteLength(value) {
|
|
1276
|
+
return new TextEncoder().encode(value).byteLength;
|
|
1277
|
+
}
|
|
1278
|
+
function compareText(left, right) {
|
|
1279
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
1280
|
+
}
|
|
1281
|
+
//#endregion
|
|
1080
1282
|
//#region src/host.ts
|
|
1081
1283
|
const DEFAULT_EXTRACT_EVENTS = (data) => {
|
|
1082
1284
|
const value = data._events;
|
|
@@ -1101,6 +1303,25 @@ const ADMISSION_FAILED = Object.freeze({ kind: "failed" });
|
|
|
1101
1303
|
const ADMISSION_TIMED_OUT = Object.freeze({ kind: "timed_out" });
|
|
1102
1304
|
/** What an admission refusal's code may look like; anything else is `invalid_decision`. */
|
|
1103
1305
|
const ADMISSION_REFUSAL_CODE = /^[a-z][A-Za-z0-9_.:-]{0,127}$/;
|
|
1306
|
+
var StagedDispatchRejectionError = class extends Error {
|
|
1307
|
+
name = "StagedDispatchRejectionError";
|
|
1308
|
+
};
|
|
1309
|
+
var StagedDispatchLeaseLostError = class extends Error {
|
|
1310
|
+
name = "StagedDispatchLeaseLostError";
|
|
1311
|
+
};
|
|
1312
|
+
var StagedDispatcherHandoffError = class extends Error {
|
|
1313
|
+
name = "StagedDispatcherHandoffError";
|
|
1314
|
+
};
|
|
1315
|
+
var StagedSubmissionTimeoutError = class extends Error {
|
|
1316
|
+
name = "StagedSubmissionTimeoutError";
|
|
1317
|
+
};
|
|
1318
|
+
/** Fixed stage error texts: a stage records no caught error text and no parameters. */
|
|
1319
|
+
const STAGED_ERROR = Object.freeze({
|
|
1320
|
+
dispatch: "Staged invocation dispatch failed.",
|
|
1321
|
+
refused: "Staged invocation submission was definitively refused.",
|
|
1322
|
+
handoff: "Staged child dispatcher handoff was not confirmed.",
|
|
1323
|
+
lookup: "Staged child lookup failed."
|
|
1324
|
+
});
|
|
1104
1325
|
function createGovernedActionHost(options) {
|
|
1105
1326
|
const retryableExecutionMaxAttempts = options.retryableExecutionMaxAttempts ?? 5;
|
|
1106
1327
|
if (!Number.isInteger(retryableExecutionMaxAttempts) || retryableExecutionMaxAttempts <= 0) throw new Error("retryableExecutionMaxAttempts must be a positive integer.");
|
|
@@ -1130,6 +1351,9 @@ function createGovernedActionHost(options) {
|
|
|
1130
1351
|
const configuredActionResolver = options.resolveAction ?? options.registry?.resolveAction;
|
|
1131
1352
|
if (!configuredActionResolver) throw new Error("Platform Host requires an explicit module registry or resolveAction function.");
|
|
1132
1353
|
const actionResolver = configuredActionResolver;
|
|
1354
|
+
const stagedStore = asStagedInvocationStore(options.store);
|
|
1355
|
+
const declaresStaging = options.registry?.orderedModules?.some((module) => (module.actions ?? []).some((action) => (action.stagesInvocations?.length ?? 0) > 0)) ?? false;
|
|
1356
|
+
if (declaresStaging && (!stagedStore || !asAtomicMutationStore(options.store))) throw new Error("Staged descendant invocations require a store with transactionalStagedInvocations and transactionWithEvents capabilities.");
|
|
1133
1357
|
const stateMachineResolver = options.registry?.resolveStateMachine ?? (() => void 0);
|
|
1134
1358
|
const eventResultFields = options.eventResultFields ?? ["_events"];
|
|
1135
1359
|
const admissionTimeoutMs = options.admissionTimeoutMs ?? DEFAULT_ADMISSION_TIMEOUT_MS;
|
|
@@ -1138,8 +1362,12 @@ function createGovernedActionHost(options) {
|
|
|
1138
1362
|
return error instanceof HostError ? errorMessage(error) : redactErrorText(options.redactErrorText, context, errorMessage(error));
|
|
1139
1363
|
}
|
|
1140
1364
|
async function submitAction(input) {
|
|
1365
|
+
return submitActionInternal(input);
|
|
1366
|
+
}
|
|
1367
|
+
async function submitActionInternal(input, internal = {}) {
|
|
1141
1368
|
const read = snapshotSubmission(input);
|
|
1142
|
-
|
|
1369
|
+
const submittedExecutionReason = read.executionReason;
|
|
1370
|
+
if (submittedExecutionReason && !["initial", "offline_replay"].includes(submittedExecutionReason) && !(internal.staged && submittedExecutionReason === "staged_dispatch")) throw new Error(`Unsupported submitted execution reason: ${submittedExecutionReason}`);
|
|
1143
1371
|
const action = actionResolver(read.actionId);
|
|
1144
1372
|
if (!action) throw new Error(`Unknown action: ${read.actionId}`);
|
|
1145
1373
|
const declaresAdmission = action.admitSubmission !== void 0;
|
|
@@ -1147,15 +1375,15 @@ function createGovernedActionHost(options) {
|
|
|
1147
1375
|
if (action.execution?.connectivity === "online-required" && submission.executionReason === "offline_replay") throw new Error(`Action ${submission.actionId} is online-required and cannot be submitted as an offline replay.`);
|
|
1148
1376
|
if (action.execution?.sensitivity === "restricted" && Object.keys(submission.provenance?.auditAttributes ?? {}).length > 0) throw new Error(`Action ${submission.actionId} declares restricted sensitivity; provenance audit attributes must not be durably recorded.`);
|
|
1149
1377
|
const authorizationInput = toAuthorizationInput(action, submission);
|
|
1150
|
-
if (!await options.authorization.checkEntitlement(authorizationInput)) throw new
|
|
1151
|
-
if (!await options.authorization.authorize(authorizationInput)) throw new
|
|
1378
|
+
if (!await options.authorization.checkEntitlement(authorizationInput)) throw new SubmissionRefusalError(action.actionId, "entitlement", `Module "${action.namespace}" is not enabled for tenant ${submission.tenantId}`);
|
|
1379
|
+
if (!await options.authorization.authorize(authorizationInput)) throw new SubmissionRefusalError(action.actionId, "authorization", `Actor ${submission.actorId} is not authorized for action ${submission.actionId}`);
|
|
1152
1380
|
if (declaresAdmission && (!submission.parametersArePrivate || !isRecord(submission.parameters))) refuseAdmission(action, submission, "invalid_parameters");
|
|
1153
1381
|
const actionInvocationId = (0, _fabricorg_platform.createFabricId)("act");
|
|
1154
1382
|
if (submission.correlationId && submission.provenance?.correlationId && submission.correlationId !== submission.provenance.correlationId) throw new Error("Invocation provenance correlationId does not match submission correlationId.");
|
|
1155
1383
|
if (submission.causationId && submission.provenance?.causationId && submission.causationId !== submission.provenance.causationId) throw new Error("Invocation provenance causationId does not match submission causationId.");
|
|
1156
1384
|
const correlationId = submission.correlationId ?? submission.provenance?.correlationId ?? (0, _fabricorg_platform.createFabricId)("corr");
|
|
1157
1385
|
const prepare = () => {
|
|
1158
|
-
const durableParameters = options.redactActionParameters ? options.redactActionParameters(submission.actionId, submission.parameters) : submission.parameters;
|
|
1386
|
+
const durableParameters = !internal.parametersAlreadyRedacted && options.redactActionParameters ? options.redactActionParameters(submission.actionId, submission.parameters) : submission.parameters;
|
|
1159
1387
|
const parameterDigest = digestParameters(submission.parameters);
|
|
1160
1388
|
if (submission.authorizationBinding) validateAuthorizationBinding(submission, parameterDigest, action.execution?.authorityMoment, now());
|
|
1161
1389
|
return {
|
|
@@ -1176,7 +1404,7 @@ function createGovernedActionHost(options) {
|
|
|
1176
1404
|
(0, _fabricorg_platform.assertGovernanceRuntimeEvidence)(runtimeEvidence);
|
|
1177
1405
|
const admission = declaresAdmission ? await admitSubmission(action, submission) : void 0;
|
|
1178
1406
|
const { durableParameters, parameterDigest } = early ?? prepare();
|
|
1179
|
-
const
|
|
1407
|
+
const invocationInput = {
|
|
1180
1408
|
id: actionInvocationId,
|
|
1181
1409
|
tenantId: submission.tenantId,
|
|
1182
1410
|
spaceId: submission.spaceId,
|
|
@@ -1202,7 +1430,24 @@ function createGovernedActionHost(options) {
|
|
|
1202
1430
|
...submission.executionReason ? { executionReason: submission.executionReason } : {},
|
|
1203
1431
|
...submission.authorizationBindingId ? { authorizationBindingId: submission.authorizationBindingId } : {},
|
|
1204
1432
|
...admission ? { admittedAt: admission.admittedAt } : {}
|
|
1205
|
-
}
|
|
1433
|
+
};
|
|
1434
|
+
const staged = internal.staged;
|
|
1435
|
+
const durableInvocation = staged ? await (async () => {
|
|
1436
|
+
if (!stagedStore) throw new Error("Staged invocation store capability disappeared.");
|
|
1437
|
+
const created = await stagedStore.createActionInvocationForStagedLease({
|
|
1438
|
+
stageId: staged.id,
|
|
1439
|
+
workerId: staged.workerId,
|
|
1440
|
+
leaseToken: staged.leaseToken,
|
|
1441
|
+
now: now(),
|
|
1442
|
+
invocation: {
|
|
1443
|
+
...invocationInput,
|
|
1444
|
+
stagedOrigin: staged.origin
|
|
1445
|
+
}
|
|
1446
|
+
});
|
|
1447
|
+
if (created.kind === "lease_lost") throw new StagedDispatchLeaseLostError("Staged invocation lease lost before child creation.");
|
|
1448
|
+
staged.onChild(created.invocation);
|
|
1449
|
+
return created.invocation;
|
|
1450
|
+
})() : await options.store.createActionInvocation(invocationInput);
|
|
1206
1451
|
emitPlatformHostTelemetry(options.telemetry, {
|
|
1207
1452
|
kind: "event",
|
|
1208
1453
|
name: "invocation.submitted",
|
|
@@ -1236,6 +1481,7 @@ function createGovernedActionHost(options) {
|
|
|
1236
1481
|
}
|
|
1237
1482
|
}
|
|
1238
1483
|
const durableWorkflowId = `action-invocation-${durableInvocation.id}`;
|
|
1484
|
+
if (internal.staged && durableInvocation.id !== actionInvocationId) return replayExistingStagedInvocation(durableInvocation);
|
|
1239
1485
|
if (durableInvocation.id !== actionInvocationId) {
|
|
1240
1486
|
if (options.dispatcher && durableInvocation.status === "pending") {
|
|
1241
1487
|
const dispatched = await options.dispatcher.dispatch({
|
|
@@ -1284,6 +1530,7 @@ function createGovernedActionHost(options) {
|
|
|
1284
1530
|
...durableInvocation.hitlRiskTier ? { hitlRiskTier: durableInvocation.hitlRiskTier } : {}
|
|
1285
1531
|
}, submission.actionId);
|
|
1286
1532
|
} catch (error) {
|
|
1533
|
+
if (internal.staged) throw new StagedDispatcherHandoffError("Staged child dispatcher handoff failed.");
|
|
1287
1534
|
throw error;
|
|
1288
1535
|
}
|
|
1289
1536
|
return {
|
|
@@ -1292,6 +1539,48 @@ function createGovernedActionHost(options) {
|
|
|
1292
1539
|
};
|
|
1293
1540
|
}
|
|
1294
1541
|
/**
|
|
1542
|
+
* Hand an existing staged child to the dispatcher again under its stable
|
|
1543
|
+
* workflow id, as the public replay branch above does, when the child is
|
|
1544
|
+
* still pending and a dispatcher is configured. Returns undefined when no
|
|
1545
|
+
* handoff is needed. Raw dispatcher errors are replaced by a fixed one.
|
|
1546
|
+
*/
|
|
1547
|
+
async function handOffExistingStagedChild(existing) {
|
|
1548
|
+
if (!options.dispatcher || existing.status !== "pending") return void 0;
|
|
1549
|
+
try {
|
|
1550
|
+
return await options.dispatcher.dispatch({
|
|
1551
|
+
actionInvocationId: existing.id,
|
|
1552
|
+
actionId: existing.actionId,
|
|
1553
|
+
tenantId: existing.tenantId,
|
|
1554
|
+
spaceId: existing.spaceId,
|
|
1555
|
+
workflowId: `action-invocation-${existing.id}`
|
|
1556
|
+
});
|
|
1557
|
+
} catch {
|
|
1558
|
+
throw new StagedDispatcherHandoffError("Staged child dispatcher handoff failed.");
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
async function replayExistingStagedInvocation(existing) {
|
|
1562
|
+
const workflowId = `action-invocation-${existing.id}`;
|
|
1563
|
+
const dispatched = await handOffExistingStagedChild(existing);
|
|
1564
|
+
if (dispatched) return withConsistency({
|
|
1565
|
+
actionInvocationId: existing.id,
|
|
1566
|
+
status: existing.status,
|
|
1567
|
+
workflowId: dispatched.workflowId,
|
|
1568
|
+
...dispatched.runId ? { runId: dispatched.runId } : {}
|
|
1569
|
+
}, existing.actionId);
|
|
1570
|
+
return withConsistency({
|
|
1571
|
+
actionInvocationId: existing.id,
|
|
1572
|
+
status: existing.status,
|
|
1573
|
+
workflowId,
|
|
1574
|
+
result: existing.result,
|
|
1575
|
+
...existing.error ? { error: existing.error } : {},
|
|
1576
|
+
...existing.hitlRoute ? { hitlRoute: existing.hitlRoute } : {},
|
|
1577
|
+
...existing.hitlRiskTier ? { hitlRiskTier: existing.hitlRiskTier } : {},
|
|
1578
|
+
...existing.authorizationReconciliation ? { reconciliation: existing.authorizationReconciliation } : {},
|
|
1579
|
+
...existing.adapterReconciliation ? { adapterReconciliation: existing.adapterReconciliation } : {},
|
|
1580
|
+
...existing.pendingCompletion ? { pendingCompletion: existing.pendingCompletion } : {}
|
|
1581
|
+
}, existing.actionId);
|
|
1582
|
+
}
|
|
1583
|
+
/**
|
|
1295
1584
|
* Run the action's `admitSubmission` check on a copy of the submission
|
|
1296
1585
|
* snapshot's parameters, with the snapshot's scope and actor, bounded by
|
|
1297
1586
|
* `admissionTimeoutMs`. Returns when it was admitted; anything but
|
|
@@ -1463,6 +1752,7 @@ function createGovernedActionHost(options) {
|
|
|
1463
1752
|
await attemptEvidenceWrites(evidenceWrites);
|
|
1464
1753
|
}
|
|
1465
1754
|
async function failExecution(record, status, error) {
|
|
1755
|
+
if (record.finalizationCheckpoint && hasDurableProviderEffect) return settleAppliedEffect(error);
|
|
1466
1756
|
await persistExecutionInvocation(record, {
|
|
1467
1757
|
status,
|
|
1468
1758
|
error
|
|
@@ -1755,14 +2045,34 @@ function createGovernedActionHost(options) {
|
|
|
1755
2045
|
};
|
|
1756
2046
|
}
|
|
1757
2047
|
const action = actionResolver(invocation.actionId);
|
|
1758
|
-
|
|
1759
|
-
|
|
2048
|
+
let hasNonSucceededAdapterRow = false;
|
|
2049
|
+
if ((action || invocation.finalizationCheckpoint) && (resumingRunningInvocation || invocation.attemptCount > 1)) try {
|
|
2050
|
+
let runningCheckpoint;
|
|
2051
|
+
for (const stepIndex of Array.from({ length: Math.max((action?.adapterSteps ?? []).length, invocation.finalizationCheckpoint?.steps.length ?? 0) }, (_, index) => index)) {
|
|
1760
2052
|
const record = await options.store.getAdapterInvocation(lifecycleId("adp", actionInvocationId, String(stepIndex)), adapterInvocationScope);
|
|
1761
|
-
if (record)
|
|
2053
|
+
if (record) {
|
|
2054
|
+
adapterStatuses.set(record.id, record.status);
|
|
2055
|
+
hasNonSucceededAdapterRow ||= record.status !== "succeeded";
|
|
2056
|
+
}
|
|
1762
2057
|
if (record?.status === "succeeded") hasDurableProviderEffect = true;
|
|
2058
|
+
if (invocation.finalizationCheckpoint && record?.status === "running") runningCheckpoint ??= {
|
|
2059
|
+
stepIndex,
|
|
2060
|
+
record
|
|
2061
|
+
};
|
|
1763
2062
|
if (record?.status !== "ambiguous") continue;
|
|
1764
2063
|
return await settleUnknownOutcome(stepIndex, recordedAdapterReconciliation(record), record.reconciliation?.adapterEventSubject);
|
|
1765
2064
|
}
|
|
2065
|
+
if (runningCheckpoint) {
|
|
2066
|
+
const { stepIndex, record } = runningCheckpoint;
|
|
2067
|
+
return await settleUnknownOutcome(stepIndex, {
|
|
2068
|
+
kind: "adapter_outcome_ambiguous",
|
|
2069
|
+
adapterInvocationId: record.id,
|
|
2070
|
+
adapterType: record.adapterType,
|
|
2071
|
+
operation: record.operation,
|
|
2072
|
+
vendor: record.vendor,
|
|
2073
|
+
message: "Recorded adapter outcome is unknown."
|
|
2074
|
+
});
|
|
2075
|
+
}
|
|
1766
2076
|
} catch (error) {
|
|
1767
2077
|
if (error instanceof RecoverableFinalizationError) throw error;
|
|
1768
2078
|
throw new RecoverableFinalizationError(caughtError({
|
|
@@ -1772,6 +2082,37 @@ function createGovernedActionHost(options) {
|
|
|
1772
2082
|
}
|
|
1773
2083
|
if (invocation.status === "running" && invocation.pendingCompletion && !invocation.leaseOwner) return withConsistency(actionResult(invocation), invocation.actionId);
|
|
1774
2084
|
if (!action) return hostOperation(() => failExecution(invocation, "failed", `Unknown action: ${invocation.actionId}`));
|
|
2085
|
+
if (invocation.finalizationCheckpoint && !action.finalize) return interruptedNonIdempotent("Action no longer declares its checkpointed finalizer.");
|
|
2086
|
+
let hasFencedAdapterWrites = false;
|
|
2087
|
+
try {
|
|
2088
|
+
(0, _fabricorg_platform.assertAdapterStepDeadlines)(action);
|
|
2089
|
+
(0, _fabricorg_platform.assertActionFinalization)(action);
|
|
2090
|
+
if ((action.stagesInvocations?.length ?? 0) > 0) {
|
|
2091
|
+
const store = asAtomicMutationStore(options.store);
|
|
2092
|
+
if (!stagedStore || !store) throw new Error("Staged descendant invocations require an atomic staging-capable store.");
|
|
2093
|
+
await store.transactionWithEvents(async (transaction) => {
|
|
2094
|
+
if (typeof transaction.stageInvocations !== "function") throw new Error("Staged descendant invocations require transaction-scoped stageInvocations support.");
|
|
2095
|
+
});
|
|
2096
|
+
}
|
|
2097
|
+
if (action.finalize) {
|
|
2098
|
+
const store = asAtomicMutationStore(options.store);
|
|
2099
|
+
if (!store) throw new Error("Action finalization requires atomic mutation transactions.");
|
|
2100
|
+
await store.transactionWithEvents(async (transaction) => {
|
|
2101
|
+
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.");
|
|
2102
|
+
hasFencedAdapterWrites = typeof options.store.writeAdapterInvocation === "function" && typeof transaction.writeAdapterInvocation === "function";
|
|
2103
|
+
});
|
|
2104
|
+
}
|
|
2105
|
+
} catch (error) {
|
|
2106
|
+
return interruptedNonIdempotent(caughtError({
|
|
2107
|
+
actionId: action.actionId,
|
|
2108
|
+
source: "execution"
|
|
2109
|
+
}, error));
|
|
2110
|
+
}
|
|
2111
|
+
const replayCheckpoint = action.finalize && (resumingRunningInvocation || invocation.attemptCount > 1) ? invocation.finalizationCheckpoint : void 0;
|
|
2112
|
+
if (replayCheckpoint) {
|
|
2113
|
+
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.");
|
|
2114
|
+
}
|
|
2115
|
+
if (action.finalize && !replayCheckpoint && (resumingRunningInvocation || invocation.attemptCount > 1) && hasDurableProviderEffect) return interruptedNonIdempotent("Finalizing action recovery requires a preparation checkpoint; reconciliation is required.");
|
|
1775
2116
|
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.`));
|
|
1776
2117
|
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.`));
|
|
1777
2118
|
async function interruptedNonIdempotent(message) {
|
|
@@ -1801,8 +2142,9 @@ function createGovernedActionHost(options) {
|
|
|
1801
2142
|
}
|
|
1802
2143
|
return hostOperation(() => failExecution(invocation, "failed", message));
|
|
1803
2144
|
}
|
|
1804
|
-
|
|
1805
|
-
if (invocation.attemptCount
|
|
2145
|
+
const canReplayNonIdempotent = !!replayCheckpoint && !hasNonSucceededAdapterRow && (!executionLease || hasFencedAdapterWrites);
|
|
2146
|
+
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.`);
|
|
2147
|
+
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.`);
|
|
1806
2148
|
if (invocation.status !== "running") await hostOperation(() => options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, { status: "running" }));
|
|
1807
2149
|
emitPlatformHostTelemetry(options.telemetry, {
|
|
1808
2150
|
kind: "event",
|
|
@@ -2003,144 +2345,229 @@ function createGovernedActionHost(options) {
|
|
|
2003
2345
|
}))));
|
|
2004
2346
|
}
|
|
2005
2347
|
}
|
|
2348
|
+
const preparedInputs = /* @__PURE__ */ new Map();
|
|
2349
|
+
let preparation = replayCheckpoint;
|
|
2006
2350
|
let data;
|
|
2351
|
+
let finalizationInput;
|
|
2007
2352
|
let domainEvents = [];
|
|
2008
2353
|
let pendingTransitionEvent;
|
|
2354
|
+
let handlerStagedInvocations = replayCheckpoint?.stagedInvocations ?? [];
|
|
2009
2355
|
try {
|
|
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
|
-
|
|
2036
|
-
targetState,
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2356
|
+
if (replayCheckpoint) {
|
|
2357
|
+
data = structuredClone(replayCheckpoint.result);
|
|
2358
|
+
finalizationInput = structuredClone({
|
|
2359
|
+
params: replayCheckpoint.params,
|
|
2360
|
+
handlerData: replayCheckpoint.handlerData
|
|
2361
|
+
});
|
|
2362
|
+
domainEvents = structuredClone(replayCheckpoint.handlerEvents);
|
|
2363
|
+
pendingTransitionEvent = structuredClone(replayCheckpoint.stateTransition);
|
|
2364
|
+
} else {
|
|
2365
|
+
const runHandler = async (db, transaction) => {
|
|
2366
|
+
if (options.outbox && !transaction?.appendEventWithOutbox) throw new HostError("Outbox egress requires transactionWithEvents to provide appendEventWithOutbox before handler execution.");
|
|
2367
|
+
if (action.finalize) {
|
|
2368
|
+
const current = await transaction.getActionInvocationForUpdate(actionInvocationId, tenantId, spaceId);
|
|
2369
|
+
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}`);
|
|
2370
|
+
}
|
|
2371
|
+
const binding = action.stateMachine;
|
|
2372
|
+
let replayedTransition;
|
|
2373
|
+
let pendingStateTransition;
|
|
2374
|
+
let validatedTransition;
|
|
2375
|
+
if (binding) {
|
|
2376
|
+
const machine = stateMachineResolver(binding.entityType);
|
|
2377
|
+
const entityId = binding.getEntityId(parsed.data);
|
|
2378
|
+
const getEntityState = transaction?.getEntityState ? transaction.getEntityState.bind(transaction) : options.store.getEntityState.bind(options.store);
|
|
2379
|
+
const currentState = entityId ? await hostOperation(() => getEntityState(tenantId, spaceId, binding.entityType, entityId)) ?? initialState(machine) : initialState(machine);
|
|
2380
|
+
const targetState = typeof binding.targetState === "function" ? binding.targetState(parsed.data) : binding.targetState;
|
|
2381
|
+
if (targetState !== "") {
|
|
2382
|
+
const transition = await (0, _fabricorg_platform.evaluateStateMachineTransition)(machine, binding.entityType, currentState, targetState, action.actionId, {
|
|
2383
|
+
entity: {
|
|
2384
|
+
...entityId ? { id: entityId } : {},
|
|
2385
|
+
state: currentState
|
|
2386
|
+
},
|
|
2387
|
+
actionInvocationId,
|
|
2388
|
+
actorId: invocation.actorId,
|
|
2389
|
+
parameters: parsed.data,
|
|
2390
|
+
db
|
|
2391
|
+
});
|
|
2392
|
+
validatedTransition = {
|
|
2393
|
+
entityId,
|
|
2394
|
+
currentState,
|
|
2395
|
+
targetState,
|
|
2396
|
+
valid: transition.valid
|
|
2397
|
+
};
|
|
2398
|
+
let replayingAppliedTransition = false;
|
|
2399
|
+
if (action.idempotent && currentState === targetState && !transition.valid && entityId) {
|
|
2400
|
+
let source;
|
|
2401
|
+
if (transaction?.getEntityStateSource) source = await hostOperation(() => transaction.getEntityStateSource(tenantId, spaceId, binding.entityType, entityId));
|
|
2402
|
+
else if (options.store.getEntityStateSource) source = await hostOperation(() => options.store.getEntityStateSource(tenantId, spaceId, binding.entityType, entityId));
|
|
2403
|
+
else {
|
|
2404
|
+
const eventStore = options.store;
|
|
2405
|
+
const listEvents = transaction?.listEvents?.bind(transaction) ?? eventStore.listEvents?.bind(eventStore);
|
|
2406
|
+
const events = listEvents ? await hostOperation(() => listEvents(tenantId, spaceId)) : [];
|
|
2407
|
+
let latest;
|
|
2408
|
+
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;
|
|
2409
|
+
if (latest && typeof latest.actionInvocationId === "string") source = {
|
|
2410
|
+
state: latest.payload.toState,
|
|
2411
|
+
actionInvocationId: latest.actionInvocationId
|
|
2412
|
+
};
|
|
2413
|
+
}
|
|
2414
|
+
replayingAppliedTransition = source?.actionInvocationId === actionInvocationId && source.state === currentState;
|
|
2415
|
+
}
|
|
2416
|
+
const pending = invocation.pendingStateTransition;
|
|
2417
|
+
if (replayingAppliedTransition && pending && pending.entityType === binding.entityType && pending.entityId === entityId && pending.toState === currentState) replayedTransition = pending;
|
|
2418
|
+
if (action.idempotent && transition.valid && entityId && action.eventPhase === "after_adapters") {
|
|
2419
|
+
pendingStateTransition = {
|
|
2420
|
+
entityType: binding.entityType,
|
|
2421
|
+
entityId,
|
|
2422
|
+
fromState: currentState,
|
|
2423
|
+
toState: targetState
|
|
2053
2424
|
};
|
|
2425
|
+
if (!transaction) await hostOperation(() => options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, { pendingStateTransition }));
|
|
2054
2426
|
}
|
|
2055
|
-
replayingAppliedTransition
|
|
2427
|
+
if (!transition.valid && !replayingAppliedTransition) throw transition.error != null ? new Error(transition.error) : new HostError("Invalid state transition");
|
|
2056
2428
|
}
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2429
|
+
}
|
|
2430
|
+
const handlerResult = action.handler ? await action.handler({
|
|
2431
|
+
actionInvocationId,
|
|
2432
|
+
tenantId,
|
|
2433
|
+
spaceId,
|
|
2434
|
+
actorId: invocation.actorId,
|
|
2435
|
+
actorType: invocation.actorType,
|
|
2436
|
+
correlationId: invocation.correlationId,
|
|
2437
|
+
...invocation.causationId ? { causationId: invocation.causationId } : {},
|
|
2438
|
+
...invocation.provenance ? { provenance: invocation.provenance } : {},
|
|
2439
|
+
...isAdmissionTime(invocation.admittedAt) ? { admittedAt: invocation.admittedAt } : {},
|
|
2440
|
+
db,
|
|
2441
|
+
services: options.services,
|
|
2442
|
+
...action.finalize ? { finalizationProtocol: "atomic-after-adapters-v1" } : {}
|
|
2443
|
+
}, parsed.data) : {
|
|
2444
|
+
success: true,
|
|
2445
|
+
data: {}
|
|
2446
|
+
};
|
|
2447
|
+
if (!handlerResult.success) throw handlerResult.error != null ? new Error(handlerResult.error) : new HostError("Action handler failed");
|
|
2448
|
+
const handlerData = handlerResult.data ?? {};
|
|
2449
|
+
const rawStagedInvocations = handlerResult.stagedInvocations ?? [];
|
|
2450
|
+
const stagedRecords = prepareStagedInvocationBatch({
|
|
2451
|
+
parent: invocation,
|
|
2452
|
+
parentAction: action,
|
|
2453
|
+
requests: rawStagedInvocations,
|
|
2454
|
+
resolveAction: actionResolver,
|
|
2455
|
+
redactParameters: options.redactActionParameters,
|
|
2456
|
+
now: now()
|
|
2457
|
+
});
|
|
2458
|
+
handlerStagedInvocations = stagedRecords.map((record) => ({
|
|
2459
|
+
key: record.key,
|
|
2460
|
+
actionId: record.actionId,
|
|
2461
|
+
parameters: structuredClone(record.parameters)
|
|
2462
|
+
}));
|
|
2463
|
+
if (stagedRecords.length > 0 && action.eventPhase !== "after_adapters") {
|
|
2464
|
+
if (!transaction?.stageInvocations) throw new Error("Staged descendant invocations require an atomic staging transaction.");
|
|
2465
|
+
await transaction.stageInvocations(stagedRecords);
|
|
2466
|
+
}
|
|
2467
|
+
if (action.resultSchema && !action.finalize) {
|
|
2468
|
+
const publicResult = withoutPrivateHostFields(handlerData, eventResultFields);
|
|
2469
|
+
const parsedResult = action.resultSchema.safeParse(publicResult);
|
|
2470
|
+
if (!parsedResult.success) throw new HostError(`Action result validation failed at ${parsedResult.error.issues.map((issue) => issue.path.map(String).join(".") || "result").join(", ")}`);
|
|
2471
|
+
}
|
|
2472
|
+
const preparedFinalizationInput = action.finalize ? {
|
|
2473
|
+
params: normalizeFinalizationJson(parsed.data),
|
|
2474
|
+
handlerData: normalizeFinalizationJson(withoutPrivateHostFields(handlerData, eventResultFields))
|
|
2475
|
+
} : void 0;
|
|
2476
|
+
const extractedEvents = extractEvents(handlerData);
|
|
2477
|
+
const events = action.finalize ? normalizeFinalizationJson(extractedEvents) : extractedEvents;
|
|
2478
|
+
const undeclaredEvent = events.find((event) => !action.emitsEvents.includes(event.eventType));
|
|
2479
|
+
if (undeclaredEvent) throw new HostError(`${action.actionId} emitted undeclared event type ${undeclaredEvent.eventType}`);
|
|
2480
|
+
if (action.eventPhase !== "after_adapters") for (const [index, event] of events.entries()) await hostOperation(() => appendEvent(invocation, event, `domain:${index}`, action.version, transaction));
|
|
2481
|
+
let transitionEvent;
|
|
2482
|
+
if (binding && validatedTransition?.entityId && (validatedTransition.valid || replayedTransition)) {
|
|
2483
|
+
const { entityId, currentState, targetState } = validatedTransition;
|
|
2484
|
+
if (!events.some((event) => event.eventType === "StateTransitioned" && event.subjectType === binding.entityType && event.subjectId === entityId)) transitionEvent = {
|
|
2485
|
+
eventType: "StateTransitioned",
|
|
2486
|
+
subjectType: binding.entityType,
|
|
2487
|
+
subjectId: entityId,
|
|
2488
|
+
payload: {
|
|
2061
2489
|
entityType: binding.entityType,
|
|
2062
2490
|
entityId,
|
|
2063
|
-
fromState: currentState,
|
|
2064
|
-
toState: targetState
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
}
|
|
2068
|
-
if (!transition.valid && !replayingAppliedTransition) throw transition.error != null ? new Error(transition.error) : new HostError("Invalid state transition");
|
|
2491
|
+
fromState: replayedTransition?.fromState ?? currentState,
|
|
2492
|
+
toState: targetState,
|
|
2493
|
+
causedByAction: action.actionId
|
|
2494
|
+
}
|
|
2495
|
+
};
|
|
2069
2496
|
}
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
...invocation.causationId ? { causationId: invocation.causationId } : {},
|
|
2079
|
-
...invocation.provenance ? { provenance: invocation.provenance } : {},
|
|
2080
|
-
...isAdmissionTime(invocation.admittedAt) ? { admittedAt: invocation.admittedAt } : {},
|
|
2081
|
-
db,
|
|
2082
|
-
services: options.services
|
|
2083
|
-
}, parsed.data) : {
|
|
2084
|
-
success: true,
|
|
2085
|
-
data: {}
|
|
2086
|
-
};
|
|
2087
|
-
if (!handlerResult.success) throw handlerResult.error != null ? new Error(handlerResult.error) : new HostError("Action handler failed");
|
|
2088
|
-
const handlerData = handlerResult.data ?? {};
|
|
2089
|
-
if (action.resultSchema) {
|
|
2090
|
-
const publicResult = withoutPrivateHostFields(handlerData, eventResultFields);
|
|
2091
|
-
const parsedResult = action.resultSchema.safeParse(publicResult);
|
|
2092
|
-
if (!parsedResult.success) throw new HostError(`Action result validation failed at ${parsedResult.error.issues.map((issue) => issue.path.map(String).join(".") || "result").join(", ")}`);
|
|
2093
|
-
}
|
|
2094
|
-
const events = extractEvents(handlerData);
|
|
2095
|
-
const undeclaredEvent = events.find((event) => !action.emitsEvents.includes(event.eventType));
|
|
2096
|
-
if (undeclaredEvent) throw new HostError(`${action.actionId} emitted undeclared event type ${undeclaredEvent.eventType}`);
|
|
2097
|
-
if (action.eventPhase !== "after_adapters") for (const [index, event] of events.entries()) await hostOperation(() => appendEvent(invocation, event, `domain:${index}`, action.version, transaction));
|
|
2098
|
-
let transitionEvent;
|
|
2099
|
-
if (binding && validatedTransition?.entityId && (validatedTransition.valid || replayedTransition)) {
|
|
2100
|
-
const { entityId, currentState, targetState } = validatedTransition;
|
|
2101
|
-
if (!events.some((event) => event.eventType === "StateTransitioned" && event.subjectType === binding.entityType && event.subjectId === entityId)) transitionEvent = {
|
|
2102
|
-
eventType: "StateTransitioned",
|
|
2103
|
-
subjectType: binding.entityType,
|
|
2104
|
-
subjectId: entityId,
|
|
2105
|
-
payload: {
|
|
2106
|
-
entityType: binding.entityType,
|
|
2107
|
-
entityId,
|
|
2108
|
-
fromState: replayedTransition?.fromState ?? currentState,
|
|
2109
|
-
toState: targetState,
|
|
2110
|
-
causedByAction: action.actionId
|
|
2497
|
+
if (transitionEvent && action.eventPhase !== "after_adapters") await hostOperation(() => appendEvent(invocation, transitionEvent, "state:transition", 1, transaction));
|
|
2498
|
+
if (transaction && pendingStateTransition) await hostOperation(() => transaction.updateActionInvocation(actionInvocationId, tenantId, spaceId, { pendingStateTransition }));
|
|
2499
|
+
if (preparedFinalizationInput) {
|
|
2500
|
+
const skipped = [];
|
|
2501
|
+
for (const [index, step] of (action.adapterSteps ?? []).entries()) {
|
|
2502
|
+
const input = step.getInput(structuredClone(preparedFinalizationInput.params), structuredClone(preparedFinalizationInput.handlerData));
|
|
2503
|
+
preparedInputs.set(index, input);
|
|
2504
|
+
if (!input) skipped.push(index);
|
|
2111
2505
|
}
|
|
2506
|
+
preparation = normalizeFinalizationJson({
|
|
2507
|
+
protocol: "atomic-after-adapters-v1",
|
|
2508
|
+
actionVersion: action.version,
|
|
2509
|
+
steps: (action.adapterSteps ?? []).map(({ adapterType, operation }) => ({
|
|
2510
|
+
adapterType,
|
|
2511
|
+
operation
|
|
2512
|
+
})),
|
|
2513
|
+
...preparedFinalizationInput,
|
|
2514
|
+
handlerEvents: events,
|
|
2515
|
+
result: preparedFinalizationInput.handlerData,
|
|
2516
|
+
...transitionEvent ? { stateTransition: transitionEvent } : {},
|
|
2517
|
+
skipped,
|
|
2518
|
+
...handlerStagedInvocations.length > 0 ? { stagedInvocations: handlerStagedInvocations } : {}
|
|
2519
|
+
});
|
|
2520
|
+
if (new TextEncoder().encode((0, _fabricorg_platform.canonicalJson)(preparation)).byteLength > 262144) throw new HostError("Finalization checkpoint exceeds 256 KiB.");
|
|
2521
|
+
if (executionLease) {
|
|
2522
|
+
if (!await transaction.updateLeasedActionInvocation({
|
|
2523
|
+
id: actionInvocationId,
|
|
2524
|
+
tenantId,
|
|
2525
|
+
spaceId,
|
|
2526
|
+
workerId: executionLease.leaseOwner,
|
|
2527
|
+
leaseToken: executionLease.leaseToken,
|
|
2528
|
+
patch: { finalizationCheckpoint: preparation }
|
|
2529
|
+
})) throw new RecoverableFinalizationError(`Invocation lease lost: ${actionInvocationId}`);
|
|
2530
|
+
} else await transaction.updateActionInvocation(actionInvocationId, tenantId, spaceId, { finalizationCheckpoint: preparation });
|
|
2531
|
+
}
|
|
2532
|
+
return {
|
|
2533
|
+
data: handlerData,
|
|
2534
|
+
domainEvents: events,
|
|
2535
|
+
pendingTransitionEvent: transitionEvent,
|
|
2536
|
+
finalizationInput: preparedFinalizationInput
|
|
2112
2537
|
};
|
|
2113
|
-
}
|
|
2114
|
-
if (transitionEvent && action.eventPhase !== "after_adapters") await hostOperation(() => appendEvent(invocation, transitionEvent, "state:transition", 1, transaction));
|
|
2115
|
-
if (transaction && pendingStateTransition) await hostOperation(() => transaction.updateActionInvocation(actionInvocationId, tenantId, spaceId, { pendingStateTransition }));
|
|
2116
|
-
return {
|
|
2117
|
-
data: handlerData,
|
|
2118
|
-
domainEvents: events,
|
|
2119
|
-
pendingTransitionEvent: transitionEvent
|
|
2120
2538
|
};
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
}
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2539
|
+
const atomicStore = asAtomicMutationStore(options.store);
|
|
2540
|
+
let callbackFailure;
|
|
2541
|
+
const run = async (db, transaction) => {
|
|
2542
|
+
callbackFailure = void 0;
|
|
2543
|
+
try {
|
|
2544
|
+
return await runHandler(db, transaction);
|
|
2545
|
+
} catch (error) {
|
|
2546
|
+
callbackFailure = { error };
|
|
2547
|
+
throw error;
|
|
2548
|
+
}
|
|
2549
|
+
};
|
|
2550
|
+
const executed = await (async () => {
|
|
2551
|
+
try {
|
|
2552
|
+
return atomicStore ? await atomicStore.transactionWithEvents((transaction) => run(transaction.db, transaction)) : await options.store.transaction((db) => run(db));
|
|
2553
|
+
} catch (error) {
|
|
2554
|
+
if (callbackFailure) throw callbackFailure.error;
|
|
2555
|
+
if (action.finalize) throw new RecoverableFinalizationError(caughtError({
|
|
2556
|
+
actionId: action.actionId,
|
|
2557
|
+
source: "execution"
|
|
2558
|
+
}, error));
|
|
2559
|
+
return hostOperation(() => Promise.reject(error));
|
|
2560
|
+
}
|
|
2561
|
+
})();
|
|
2562
|
+
data = executed.data;
|
|
2563
|
+
finalizationInput = executed.finalizationInput;
|
|
2564
|
+
domainEvents = executed.domainEvents;
|
|
2565
|
+
pendingTransitionEvent = executed.pendingTransitionEvent;
|
|
2566
|
+
if (preparation) invocation = {
|
|
2567
|
+
...invocation,
|
|
2568
|
+
finalizationCheckpoint: preparation
|
|
2569
|
+
};
|
|
2570
|
+
}
|
|
2144
2571
|
} catch (error) {
|
|
2145
2572
|
if (error instanceof RecoverableFinalizationError) throw error;
|
|
2146
2573
|
if ((0, _fabricorg_platform.isReconciliationRequiredError)(error)) {
|
|
@@ -2165,6 +2592,7 @@ function createGovernedActionHost(options) {
|
|
|
2165
2592
|
source: "handler"
|
|
2166
2593
|
}, error)));
|
|
2167
2594
|
}
|
|
2595
|
+
const finalizationSteps = [];
|
|
2168
2596
|
let pendingHandoff = invocation.pendingCompletion;
|
|
2169
2597
|
for (const [stepIndex, step] of (action.adapterSteps ?? []).entries()) {
|
|
2170
2598
|
const adapterInvocationId = lifecycleId("adp", actionInvocationId, String(stepIndex));
|
|
@@ -2234,8 +2662,16 @@ function createGovernedActionHost(options) {
|
|
|
2234
2662
|
}, error));
|
|
2235
2663
|
}
|
|
2236
2664
|
if (previousAdapterInvocation?.status === "ambiguous") return await settleUnknownOutcome(stepIndex, recordedAdapterReconciliation(previousAdapterInvocation), previousAdapterInvocation.reconciliation?.adapterEventSubject);
|
|
2237
|
-
const input = step.getInput(parsed.data, data);
|
|
2238
|
-
if (!input)
|
|
2665
|
+
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);
|
|
2666
|
+
if (!input) {
|
|
2667
|
+
if (action.finalize) finalizationSteps.push({
|
|
2668
|
+
stepIndex,
|
|
2669
|
+
adapterType: step.adapterType,
|
|
2670
|
+
operation: step.operation,
|
|
2671
|
+
status: "skipped"
|
|
2672
|
+
});
|
|
2673
|
+
continue;
|
|
2674
|
+
}
|
|
2239
2675
|
const adapter = adapters.require(step.adapterType, step.operation);
|
|
2240
2676
|
const adapterEventSubject = options.adapterEventSubject?.(action.actionId, parsed.data, data) ?? {
|
|
2241
2677
|
subjectType: "AdapterInvocation",
|
|
@@ -2391,8 +2827,9 @@ function createGovernedActionHost(options) {
|
|
|
2391
2827
|
let thrownOutcome;
|
|
2392
2828
|
let adapterDeadlineTimer;
|
|
2393
2829
|
try {
|
|
2394
|
-
const
|
|
2395
|
-
const
|
|
2830
|
+
const effectiveDeadlineMs = step.deadlineMs ?? options.adapterDeadlineMs;
|
|
2831
|
+
const adapterController = effectiveDeadlineMs ? new AbortController() : void 0;
|
|
2832
|
+
const deadlineMs = effectiveDeadlineMs ? now().getTime() + effectiveDeadlineMs : void 0;
|
|
2396
2833
|
if (adapterController && deadlineMs) {
|
|
2397
2834
|
const remaining = deadlineMs - now().getTime();
|
|
2398
2835
|
if (remaining <= 0) throw new HostError(`Adapter deadline already expired before execution: ${step.adapterType}:${step.operation}`);
|
|
@@ -2577,6 +3014,7 @@ function createGovernedActionHost(options) {
|
|
|
2577
3014
|
...pendingCompletion ? { pendingCompletion } : {}
|
|
2578
3015
|
}, action.actionId);
|
|
2579
3016
|
}
|
|
3017
|
+
if (action.finalize) output = normalizeFinalizationJson(output);
|
|
2580
3018
|
successOutput = output;
|
|
2581
3019
|
if (handoff) {
|
|
2582
3020
|
const reference = typeof handoff.externalReference === "string" ? handoff.externalReference.trim() : "";
|
|
@@ -2810,7 +3248,8 @@ function createGovernedActionHost(options) {
|
|
|
2810
3248
|
return hostOperation(() => failExecution(invocation, "failed", message));
|
|
2811
3249
|
}
|
|
2812
3250
|
}
|
|
2813
|
-
|
|
3251
|
+
if (action.finalize && pendingHandoff) return settleAppliedEffect("Finalizing actions cannot finalize accepted external operations.", pendingHandoff);
|
|
3252
|
+
let result = action.finalize ? { ...replayCheckpoint?.result ?? finalizationInput.handlerData } : withoutPrivateHostFields(data, eventResultFields);
|
|
2814
3253
|
const parked = pendingHandoff ? {
|
|
2815
3254
|
...pendingHandoff,
|
|
2816
3255
|
parkedAt: now()
|
|
@@ -2824,25 +3263,96 @@ function createGovernedActionHost(options) {
|
|
|
2824
3263
|
result
|
|
2825
3264
|
};
|
|
2826
3265
|
let verdictWritten = true;
|
|
3266
|
+
let finalizationCallbackFailure;
|
|
2827
3267
|
try {
|
|
2828
3268
|
const atomicStore = asAtomicMutationStore(options.store);
|
|
2829
3269
|
if (action.eventPhase === "after_adapters" && atomicStore) {
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
if (
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
3270
|
+
if (action.finalize) for (const [stepIndex, step] of (action.adapterSteps ?? []).entries()) {
|
|
3271
|
+
if (finalizationSteps.some((entry) => entry.stepIndex === stepIndex)) continue;
|
|
3272
|
+
const row = await options.store.getAdapterInvocation(lifecycleId("adp", actionInvocationId, String(stepIndex)), adapterInvocationScope);
|
|
3273
|
+
if (row?.status !== "succeeded" || row.output === void 0) {
|
|
3274
|
+
const error = /* @__PURE__ */ new Error("Finalization requires successful stored adapter output.");
|
|
3275
|
+
finalizationCallbackFailure = { error };
|
|
3276
|
+
throw error;
|
|
3277
|
+
}
|
|
3278
|
+
finalizationSteps.push({
|
|
3279
|
+
stepIndex,
|
|
3280
|
+
adapterType: step.adapterType,
|
|
3281
|
+
operation: step.operation,
|
|
3282
|
+
status: "succeeded",
|
|
3283
|
+
adapterInvocationId: row.id,
|
|
3284
|
+
output: finalizationJson(row.output)
|
|
3285
|
+
});
|
|
3286
|
+
}
|
|
3287
|
+
const finalizeSuccess = () => atomicStore.transactionWithEvents(async (transaction) => {
|
|
3288
|
+
try {
|
|
3289
|
+
let finalized;
|
|
3290
|
+
async function assertFinalizationOwner() {
|
|
3291
|
+
const current = await transaction.getActionInvocationForUpdate(actionInvocationId, tenantId, spaceId);
|
|
3292
|
+
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}`);
|
|
3293
|
+
}
|
|
3294
|
+
if (action.finalize) {
|
|
3295
|
+
await assertFinalizationOwner();
|
|
3296
|
+
finalized = validateFinalization(await action.finalize({
|
|
3297
|
+
actionInvocationId,
|
|
3298
|
+
tenantId,
|
|
3299
|
+
spaceId,
|
|
3300
|
+
actorId: invocation.actorId,
|
|
3301
|
+
actorType: invocation.actorType,
|
|
3302
|
+
correlationId: invocation.correlationId,
|
|
3303
|
+
causationId: invocation.causationId,
|
|
3304
|
+
provenance: invocation.provenance ? structuredClone(invocation.provenance) : void 0,
|
|
3305
|
+
db: transaction.db
|
|
3306
|
+
}, {
|
|
3307
|
+
...structuredClone(finalizationInput),
|
|
3308
|
+
adapterOutputs: structuredClone(finalizationSteps.sort((a, b) => a.stepIndex - b.stepIndex))
|
|
3309
|
+
}), action, eventResultFields, options.eventContracts);
|
|
3310
|
+
result = withoutPrivateHostFields({
|
|
3311
|
+
...result,
|
|
3312
|
+
...finalized.resultPatch
|
|
3313
|
+
}, eventResultFields);
|
|
3314
|
+
if (action.resultSchema && !action.resultSchema.safeParse(result).success) throw new Error("Finalization merged result does not match resultSchema.");
|
|
3315
|
+
finalPatch.result = result;
|
|
3316
|
+
}
|
|
3317
|
+
const stagedRequests = [...handlerStagedInvocations, ...finalized?.stagedInvocations ?? []];
|
|
3318
|
+
if (stagedRequests.length > 0) {
|
|
3319
|
+
if (!transaction.stageInvocations) throw new Error("Staged descendant invocations require an atomic staging transaction.");
|
|
3320
|
+
await transaction.stageInvocations(prepareStagedInvocationBatch({
|
|
3321
|
+
parent: invocation,
|
|
3322
|
+
parentAction: action,
|
|
3323
|
+
requests: stagedRequests,
|
|
3324
|
+
resolveAction: actionResolver,
|
|
3325
|
+
redactParameters: options.redactActionParameters,
|
|
3326
|
+
redactedKeys: handlerStagedInvocations.map((request) => request.key),
|
|
3327
|
+
now: now()
|
|
3328
|
+
}));
|
|
3329
|
+
}
|
|
3330
|
+
for (const [index, event] of domainEvents.entries()) await appendEvent(invocation, event, `domain:${index}`, action.version, transaction);
|
|
3331
|
+
for (const [index, event] of (finalized?.events ?? []).entries()) await appendEvent(invocation, event, `finalize:${index}`, action.version, transaction);
|
|
3332
|
+
if (pendingTransitionEvent) await appendEvent(invocation, pendingTransitionEvent, "state:transition", 1, transaction);
|
|
3333
|
+
if (action.finalize) await assertFinalizationOwner();
|
|
3334
|
+
if (executionLease) {
|
|
3335
|
+
if (!transaction.updateLeasedActionInvocation) throw new HostError("Leased atomic finalization requires transaction-scoped fencing support.");
|
|
3336
|
+
if (!await transaction.updateLeasedActionInvocation({
|
|
3337
|
+
id: actionInvocationId,
|
|
3338
|
+
tenantId,
|
|
3339
|
+
spaceId,
|
|
3340
|
+
workerId: executionLease.leaseOwner,
|
|
3341
|
+
leaseToken: executionLease.leaseToken,
|
|
3342
|
+
patch: finalPatch
|
|
3343
|
+
})) throw new RecoverableFinalizationError(`Invocation lease lost: ${actionInvocationId}`);
|
|
3344
|
+
} else verdictWritten = await writeInlineInvocation(invocation, finalPatch, transaction, onPreservedReconciliation);
|
|
3345
|
+
} catch (error) {
|
|
3346
|
+
finalizationCallbackFailure = { error };
|
|
3347
|
+
throw error;
|
|
3348
|
+
}
|
|
3349
|
+
});
|
|
3350
|
+
if (action.finalize) await finalizeSuccess();
|
|
3351
|
+
else await hostOperation(finalizeSuccess);
|
|
3352
|
+
if (!pendingHandoff && verdictWritten) {
|
|
3353
|
+
if (action.finalize) rememberVerdict(finalPatch);
|
|
3354
|
+
emitInvocationStatusTelemetry(invocation, "completed", options.telemetry, now());
|
|
3355
|
+
}
|
|
2846
3356
|
} else {
|
|
2847
3357
|
if (action.eventPhase === "after_adapters") {
|
|
2848
3358
|
for (const [index, event] of domainEvents.entries()) await hostOperation(() => appendEvent(invocation, event, `domain:${index}`, action.version));
|
|
@@ -2851,6 +3361,33 @@ function createGovernedActionHost(options) {
|
|
|
2851
3361
|
await hostOperation(() => persistExecutionInvocation(invocation, finalPatch));
|
|
2852
3362
|
}
|
|
2853
3363
|
} catch (error) {
|
|
3364
|
+
if ((0, _fabricorg_platform.isReconciliationRequiredError)(error) && error.message.startsWith("Staged invocation batch drift")) {
|
|
3365
|
+
const message = caughtError({
|
|
3366
|
+
actionId: action.actionId,
|
|
3367
|
+
source: "execution"
|
|
3368
|
+
}, error);
|
|
3369
|
+
await hostOperation(() => persistExecutionInvocation(invocation, {
|
|
3370
|
+
status: "reconciliation_required",
|
|
3371
|
+
error: message
|
|
3372
|
+
}));
|
|
3373
|
+
return withConsistency({
|
|
3374
|
+
actionInvocationId,
|
|
3375
|
+
status: "reconciliation_required",
|
|
3376
|
+
error: message
|
|
3377
|
+
}, action.actionId);
|
|
3378
|
+
}
|
|
3379
|
+
if (action.finalize) {
|
|
3380
|
+
if (error instanceof RecoverableFinalizationError) throw error;
|
|
3381
|
+
if (!finalizationCallbackFailure || finalizationCallbackFailure.error !== error) throw new RecoverableFinalizationError(caughtError({
|
|
3382
|
+
actionId: action.actionId,
|
|
3383
|
+
source: "execution"
|
|
3384
|
+
}, error));
|
|
3385
|
+
const message = caughtError({
|
|
3386
|
+
actionId: action.actionId,
|
|
3387
|
+
source: "execution"
|
|
3388
|
+
}, error);
|
|
3389
|
+
return hasDurableProviderEffect ? settleAppliedEffect(message) : hostOperation(() => failExecution(invocation, "failed", message));
|
|
3390
|
+
}
|
|
2854
3391
|
if (action.eventPhase === "after_adapters") throw new RecoverableFinalizationError(errorMessage(error));
|
|
2855
3392
|
throw error;
|
|
2856
3393
|
}
|
|
@@ -2865,7 +3402,13 @@ function createGovernedActionHost(options) {
|
|
|
2865
3402
|
} catch (error) {
|
|
2866
3403
|
if (error instanceof RecoverableFinalizationError) throw error;
|
|
2867
3404
|
if (retryableGovernance && (0, _fabricorg_platform.isRetryableExecutionError)(error)) {
|
|
2868
|
-
|
|
3405
|
+
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;
|
|
3406
|
+
if (!reason && !action.idempotent) try {
|
|
3407
|
+
await persistExecutionInvocation(invocation, { deferredBeforeEffectAttempt: invocation.attemptCount });
|
|
3408
|
+
} catch (markerError) {
|
|
3409
|
+
if (markerError instanceof RecoverableFinalizationError) throw markerError;
|
|
3410
|
+
reason = "pre-effect marker could not be recorded";
|
|
3411
|
+
}
|
|
2869
3412
|
if (!reason) throw error;
|
|
2870
3413
|
return hostOperation(() => failExecution(invocation, "failed", caughtError({
|
|
2871
3414
|
actionId: invocation.actionId,
|
|
@@ -3129,8 +3672,282 @@ function createGovernedActionHost(options) {
|
|
|
3129
3672
|
actionResult,
|
|
3130
3673
|
withConsistency
|
|
3131
3674
|
});
|
|
3675
|
+
async function dispatchStagedInvocations(input) {
|
|
3676
|
+
if (!stagedStore) throw new Error("This Platform Host store does not support staged descendant invocations.");
|
|
3677
|
+
const leaseDurationMs = input.leaseDurationMs ?? 3e4;
|
|
3678
|
+
const submissionTimeoutMs = input.submissionTimeoutMs ?? 1e4;
|
|
3679
|
+
const maxAttempts = input.maxAttempts ?? 10;
|
|
3680
|
+
for (const [name, value] of [
|
|
3681
|
+
["leaseDurationMs", leaseDurationMs],
|
|
3682
|
+
["submissionTimeoutMs", submissionTimeoutMs],
|
|
3683
|
+
["maxAttempts", maxAttempts]
|
|
3684
|
+
]) if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer.`);
|
|
3685
|
+
const cancelled = await stagedStore.cancelStagedInvocationsForFailedParents({
|
|
3686
|
+
...input.tenantId ? { tenantId: input.tenantId } : {},
|
|
3687
|
+
...input.spaceId ? { spaceId: input.spaceId } : {},
|
|
3688
|
+
now: now()
|
|
3689
|
+
});
|
|
3690
|
+
const records = await stagedStore.claimStagedInvocations({
|
|
3691
|
+
workerId: input.workerId,
|
|
3692
|
+
leaseDurationMs,
|
|
3693
|
+
limit: input.limit ?? 10,
|
|
3694
|
+
...input.tenantId ? { tenantId: input.tenantId } : {},
|
|
3695
|
+
...input.spaceId ? { spaceId: input.spaceId } : {},
|
|
3696
|
+
now: now()
|
|
3697
|
+
});
|
|
3698
|
+
const result = {
|
|
3699
|
+
claimed: records.length,
|
|
3700
|
+
dispatched: 0,
|
|
3701
|
+
rejected: 0,
|
|
3702
|
+
failed: 0,
|
|
3703
|
+
deadLettered: 0,
|
|
3704
|
+
cancelled
|
|
3705
|
+
};
|
|
3706
|
+
const sweep = {
|
|
3707
|
+
store: stagedStore,
|
|
3708
|
+
input,
|
|
3709
|
+
leaseDurationMs,
|
|
3710
|
+
submissionTimeoutMs,
|
|
3711
|
+
maxAttempts
|
|
3712
|
+
};
|
|
3713
|
+
for (const record of records) {
|
|
3714
|
+
let outcome;
|
|
3715
|
+
try {
|
|
3716
|
+
outcome = await dispatchStagedRow(record, sweep);
|
|
3717
|
+
} catch (error) {
|
|
3718
|
+
reportStagedError(input, error);
|
|
3719
|
+
outcome = "unsettled";
|
|
3720
|
+
}
|
|
3721
|
+
if (outcome === "dispatched") result.dispatched += 1;
|
|
3722
|
+
else if (outcome === "rejected") result.rejected += 1;
|
|
3723
|
+
else {
|
|
3724
|
+
result.failed += 1;
|
|
3725
|
+
if (outcome === "dead_lettered") result.deadLettered += 1;
|
|
3726
|
+
}
|
|
3727
|
+
}
|
|
3728
|
+
return result;
|
|
3729
|
+
}
|
|
3730
|
+
/**
|
|
3731
|
+
* Settle one claimed row through a single sequence:
|
|
3732
|
+
*
|
|
3733
|
+
* 1. Renew the claim's lease, then look the child up by staged origin. An
|
|
3734
|
+
* earlier attempt's creation still in flight holds an older lease token
|
|
3735
|
+
* and fails the creation fence, so this lookup is authoritative for
|
|
3736
|
+
* earlier attempts. A child found here goes to step 4: no
|
|
3737
|
+
* pre-submission checks, no governance.
|
|
3738
|
+
* 2. No child: pre-submission checks and the shared submission pipeline.
|
|
3739
|
+
* 3. Unless this attempt's own create returned the child, rotate the lease
|
|
3740
|
+
* token. Only this attempt's own timed-out submission could still create
|
|
3741
|
+
* the child, it holds the old token, and the store orders the rotation
|
|
3742
|
+
* after any create the fence already admitted. The lookup that follows is
|
|
3743
|
+
* authoritative; a child found there goes to step 4, otherwise the
|
|
3744
|
+
* outcome is classified as rejected, retry or dead letter.
|
|
3745
|
+
* 4. A known child that is pending is handed to the configured dispatcher
|
|
3746
|
+
* again under its stable workflow id; the stage settles with its id.
|
|
3747
|
+
*
|
|
3748
|
+
* Each settlement is one compare-and-set on the current owner and token. A
|
|
3749
|
+
* lookup that fails settles the row for retry, never terminally.
|
|
3750
|
+
*/
|
|
3751
|
+
async function dispatchStagedRow(record, sweep) {
|
|
3752
|
+
const { store, input, leaseDurationMs, maxAttempts } = sweep;
|
|
3753
|
+
const claimLease = {
|
|
3754
|
+
id: record.id,
|
|
3755
|
+
workerId: input.workerId,
|
|
3756
|
+
leaseToken: record.leaseToken
|
|
3757
|
+
};
|
|
3758
|
+
if (!await store.renewStagedInvocationLease({
|
|
3759
|
+
...claimLease,
|
|
3760
|
+
leaseDurationMs,
|
|
3761
|
+
now: now()
|
|
3762
|
+
})) return "unsettled";
|
|
3763
|
+
const prior = await lookUpStagedChild(record, sweep);
|
|
3764
|
+
if (prior.kind === "unreadable") return settleStagedRetry(record, claimLease, sweep, {
|
|
3765
|
+
error: STAGED_ERROR.lookup,
|
|
3766
|
+
terminal: false
|
|
3767
|
+
});
|
|
3768
|
+
if (prior.kind === "found") return settleExistingStagedChild(record, claimLease, prior.child, sweep);
|
|
3769
|
+
const known = {};
|
|
3770
|
+
let failure;
|
|
3771
|
+
let submitted = false;
|
|
3772
|
+
const stopHeartbeat = startStagedHeartbeat(store, claimLease, input, leaseDurationMs);
|
|
3773
|
+
try {
|
|
3774
|
+
await withSubmissionTimeout(submitStagedChild(record, claimLease, (child) => {
|
|
3775
|
+
known.child = child;
|
|
3776
|
+
}), sweep.submissionTimeoutMs);
|
|
3777
|
+
submitted = true;
|
|
3778
|
+
} catch (error) {
|
|
3779
|
+
failure = error;
|
|
3780
|
+
} finally {
|
|
3781
|
+
await stopHeartbeat();
|
|
3782
|
+
}
|
|
3783
|
+
const createdChild = known.child;
|
|
3784
|
+
if (createdChild) {
|
|
3785
|
+
if (submitted || !options.dispatcher) return settleStagedDispatched(claimLease, createdChild.id, sweep);
|
|
3786
|
+
return settleStagedRetry(record, claimLease, sweep, {
|
|
3787
|
+
error: STAGED_ERROR.handoff,
|
|
3788
|
+
childInvocationId: createdChild.id,
|
|
3789
|
+
terminal: record.attemptCount >= maxAttempts
|
|
3790
|
+
});
|
|
3791
|
+
}
|
|
3792
|
+
const rotatedToken = await store.rotateStagedInvocationLease({
|
|
3793
|
+
...claimLease,
|
|
3794
|
+
leaseDurationMs,
|
|
3795
|
+
now: now()
|
|
3796
|
+
});
|
|
3797
|
+
if (rotatedToken === void 0) return "unsettled";
|
|
3798
|
+
const lease = {
|
|
3799
|
+
...claimLease,
|
|
3800
|
+
leaseToken: rotatedToken
|
|
3801
|
+
};
|
|
3802
|
+
const after = await lookUpStagedChild(record, sweep);
|
|
3803
|
+
if (after.kind === "unreadable") return settleStagedRetry(record, lease, sweep, {
|
|
3804
|
+
error: STAGED_ERROR.lookup,
|
|
3805
|
+
terminal: false
|
|
3806
|
+
});
|
|
3807
|
+
if (after.kind === "found") return settleExistingStagedChild(record, lease, after.child, sweep);
|
|
3808
|
+
if (isDefinitiveStagedRefusal(failure)) return await store.markStagedInvocationRejected({
|
|
3809
|
+
...lease,
|
|
3810
|
+
error: STAGED_ERROR.refused,
|
|
3811
|
+
now: now()
|
|
3812
|
+
}) ? "rejected" : "unsettled";
|
|
3813
|
+
return settleStagedRetry(record, lease, sweep, {
|
|
3814
|
+
error: STAGED_ERROR.dispatch,
|
|
3815
|
+
terminal: record.attemptCount >= maxAttempts
|
|
3816
|
+
});
|
|
3817
|
+
}
|
|
3818
|
+
/** Pre-submission checks, then the shared submission pipeline under the claim's lease. */
|
|
3819
|
+
async function submitStagedChild(record, lease, onChild) {
|
|
3820
|
+
const parent = await options.store.getActionInvocation(record.parentInvocationId, record.tenantId, record.spaceId);
|
|
3821
|
+
if (!parent || parent.status !== "completed") throw new Error("Staged parent is not completed.");
|
|
3822
|
+
const child = actionResolver(record.actionId);
|
|
3823
|
+
if (!child) throw new StagedDispatchRejectionError("Staged action is no longer registered.");
|
|
3824
|
+
if (child.execution?.authorityMoment === "capture") throw new StagedDispatchRejectionError("Staged action now requires capture-time authority.");
|
|
3825
|
+
if (!child.schema.safeParse(structuredClone(record.parameters)).success) throw new StagedDispatchRejectionError("Staged parameters no longer match the registered action.");
|
|
3826
|
+
return submitActionInternal({
|
|
3827
|
+
actionId: record.actionId,
|
|
3828
|
+
parameters: structuredClone(record.parameters),
|
|
3829
|
+
tenantId: parent.tenantId,
|
|
3830
|
+
spaceId: parent.spaceId,
|
|
3831
|
+
actorId: parent.actorId,
|
|
3832
|
+
actorType: parent.actorType,
|
|
3833
|
+
correlationId: parent.correlationId,
|
|
3834
|
+
causationId: parent.id,
|
|
3835
|
+
provenance: {
|
|
3836
|
+
source: "worker",
|
|
3837
|
+
correlationId: parent.correlationId,
|
|
3838
|
+
causationId: parent.id
|
|
3839
|
+
},
|
|
3840
|
+
executionReason: "staged_dispatch"
|
|
3841
|
+
}, {
|
|
3842
|
+
staged: {
|
|
3843
|
+
id: record.id,
|
|
3844
|
+
origin: {
|
|
3845
|
+
parentInvocationId: parent.id,
|
|
3846
|
+
stageKey: record.key
|
|
3847
|
+
},
|
|
3848
|
+
workerId: lease.workerId,
|
|
3849
|
+
leaseToken: lease.leaseToken,
|
|
3850
|
+
onChild
|
|
3851
|
+
},
|
|
3852
|
+
parametersAlreadyRedacted: true
|
|
3853
|
+
});
|
|
3854
|
+
}
|
|
3855
|
+
async function lookUpStagedChild(record, sweep) {
|
|
3856
|
+
try {
|
|
3857
|
+
const child = await sweep.store.getActionInvocationByStagedOrigin(record.tenantId, record.spaceId, {
|
|
3858
|
+
parentInvocationId: record.parentInvocationId,
|
|
3859
|
+
stageKey: record.key
|
|
3860
|
+
});
|
|
3861
|
+
return child ? {
|
|
3862
|
+
kind: "found",
|
|
3863
|
+
child
|
|
3864
|
+
} : { kind: "absent" };
|
|
3865
|
+
} catch (error) {
|
|
3866
|
+
reportStagedError(sweep.input, error);
|
|
3867
|
+
return { kind: "unreadable" };
|
|
3868
|
+
}
|
|
3869
|
+
}
|
|
3870
|
+
async function settleExistingStagedChild(record, lease, child, sweep) {
|
|
3871
|
+
if (options.dispatcher && child.status === "pending") {
|
|
3872
|
+
const stopHeartbeat = startStagedHeartbeat(sweep.store, lease, sweep.input, sweep.leaseDurationMs);
|
|
3873
|
+
let handedOff = false;
|
|
3874
|
+
try {
|
|
3875
|
+
await withSubmissionTimeout(handOffExistingStagedChild(child), sweep.submissionTimeoutMs);
|
|
3876
|
+
handedOff = true;
|
|
3877
|
+
} catch {} finally {
|
|
3878
|
+
await stopHeartbeat();
|
|
3879
|
+
}
|
|
3880
|
+
if (!handedOff) return settleStagedRetry(record, lease, sweep, {
|
|
3881
|
+
error: STAGED_ERROR.handoff,
|
|
3882
|
+
childInvocationId: child.id,
|
|
3883
|
+
terminal: record.attemptCount >= sweep.maxAttempts
|
|
3884
|
+
});
|
|
3885
|
+
}
|
|
3886
|
+
return settleStagedDispatched(lease, child.id, sweep);
|
|
3887
|
+
}
|
|
3888
|
+
async function settleStagedDispatched(lease, childInvocationId, sweep) {
|
|
3889
|
+
return await sweep.store.markStagedInvocationDispatched({
|
|
3890
|
+
...lease,
|
|
3891
|
+
childInvocationId,
|
|
3892
|
+
now: now()
|
|
3893
|
+
}) ? "dispatched" : "unsettled";
|
|
3894
|
+
}
|
|
3895
|
+
async function settleStagedRetry(record, lease, sweep, settlement) {
|
|
3896
|
+
if (!await sweep.store.markStagedInvocationFailed({
|
|
3897
|
+
...lease,
|
|
3898
|
+
error: settlement.error,
|
|
3899
|
+
availableAt: new Date(now().getTime() + (sweep.input.retryDelayMs?.(record.attemptCount) ?? 1e3)),
|
|
3900
|
+
deadLetter: settlement.terminal,
|
|
3901
|
+
...settlement.childInvocationId ? { childInvocationId: settlement.childInvocationId } : {},
|
|
3902
|
+
now: now()
|
|
3903
|
+
})) return "unsettled";
|
|
3904
|
+
return settlement.terminal ? "dead_lettered" : "retry";
|
|
3905
|
+
}
|
|
3906
|
+
function isDefinitiveStagedRefusal(error) {
|
|
3907
|
+
return error instanceof SubmissionRefusalError || error instanceof StagedDispatchRejectionError || error instanceof ActionAdmissionError && (error.refusal === "refused" || error.refusal === "invalid_parameters");
|
|
3908
|
+
}
|
|
3909
|
+
function reportStagedError(input, error) {
|
|
3910
|
+
try {
|
|
3911
|
+
input.onError?.(error);
|
|
3912
|
+
} catch {}
|
|
3913
|
+
}
|
|
3914
|
+
/** Renew one lease token while a slow step runs; the returned stop awaits a renewal in flight. */
|
|
3915
|
+
function startStagedHeartbeat(store, lease, input, leaseDurationMs) {
|
|
3916
|
+
const intervalMs = input.leaseRenewalIntervalMs ?? Math.max(1, Math.floor(leaseDurationMs / 3));
|
|
3917
|
+
let stopped = false;
|
|
3918
|
+
let timer;
|
|
3919
|
+
let running;
|
|
3920
|
+
const schedule = () => {
|
|
3921
|
+
if (!stopped) timer = setTimeout(tick, intervalMs);
|
|
3922
|
+
};
|
|
3923
|
+
const tick = () => {
|
|
3924
|
+
running = store.renewStagedInvocationLease({
|
|
3925
|
+
...lease,
|
|
3926
|
+
leaseDurationMs,
|
|
3927
|
+
now: now()
|
|
3928
|
+
}).then(() => void 0).catch(() => void 0).finally(schedule);
|
|
3929
|
+
};
|
|
3930
|
+
schedule();
|
|
3931
|
+
return async () => {
|
|
3932
|
+
stopped = true;
|
|
3933
|
+
if (timer) clearTimeout(timer);
|
|
3934
|
+
await running;
|
|
3935
|
+
};
|
|
3936
|
+
}
|
|
3937
|
+
async function withSubmissionTimeout(submission, timeoutMs) {
|
|
3938
|
+
let timer;
|
|
3939
|
+
try {
|
|
3940
|
+
return await Promise.race([submission, new Promise((_, reject) => {
|
|
3941
|
+
timer = setTimeout(() => reject(new StagedSubmissionTimeoutError("Staged invocation submission timed out.")), timeoutMs);
|
|
3942
|
+
})]);
|
|
3943
|
+
} finally {
|
|
3944
|
+
if (timer) clearTimeout(timer);
|
|
3945
|
+
}
|
|
3946
|
+
}
|
|
3132
3947
|
return {
|
|
3133
3948
|
submitAction,
|
|
3949
|
+
hasStagedInvocationActions: declaresStaging,
|
|
3950
|
+
...stagedStore ? { dispatchStagedInvocations } : {},
|
|
3134
3951
|
executeInvocation,
|
|
3135
3952
|
resumeApprovedInvocation,
|
|
3136
3953
|
recordExecutionAttestation,
|
|
@@ -3460,6 +4277,7 @@ function emitRelayTelemetry(telemetry, name, occurredAt, workerId, attributes) {
|
|
|
3460
4277
|
var MemoryPlatformHostStore = class {
|
|
3461
4278
|
db;
|
|
3462
4279
|
transactionalOutbox;
|
|
4280
|
+
transactionalStagedInvocations;
|
|
3463
4281
|
adapterCommit;
|
|
3464
4282
|
transactionTail = Promise.resolve();
|
|
3465
4283
|
invocations = [];
|
|
@@ -3470,6 +4288,7 @@ var MemoryPlatformHostStore = class {
|
|
|
3470
4288
|
executionAttestations = [];
|
|
3471
4289
|
externalReconciliations = [];
|
|
3472
4290
|
outbox = [];
|
|
4291
|
+
stagedInvocations = [];
|
|
3473
4292
|
/**
|
|
3474
4293
|
* Requeue evidence is append-only audit history, so unlike the ledger
|
|
3475
4294
|
* arrays above, which tests have always inspected and seeded directly, it
|
|
@@ -3480,6 +4299,7 @@ var MemoryPlatformHostStore = class {
|
|
|
3480
4299
|
constructor(db, transactionProvider) {
|
|
3481
4300
|
this.db = db;
|
|
3482
4301
|
this.transactionalOutbox = transactionProvider !== void 0;
|
|
4302
|
+
this.transactionalStagedInvocations = transactionProvider !== void 0;
|
|
3483
4303
|
if (transactionProvider) this.transactionWithEvents = (run) => this.runTransactionWithEvents(run, transactionProvider);
|
|
3484
4304
|
}
|
|
3485
4305
|
async transaction(run) {
|
|
@@ -3505,6 +4325,7 @@ var MemoryPlatformHostStore = class {
|
|
|
3505
4325
|
const conditionalAdapters = [];
|
|
3506
4326
|
const leasedUpdates = [];
|
|
3507
4327
|
const pendingReconciliations = [];
|
|
4328
|
+
const pendingStagedBatches = [];
|
|
3508
4329
|
const appendPending = async (event, metadata) => {
|
|
3509
4330
|
if (this.events.some((candidate) => candidate.id === event.id) || pendingEvents.some((candidate) => candidate.event.id === event.id)) return;
|
|
3510
4331
|
pendingEvents.push({
|
|
@@ -3561,6 +4382,9 @@ var MemoryPlatformHostStore = class {
|
|
|
3561
4382
|
appendExternalReconciliation: async (record) => {
|
|
3562
4383
|
pendingReconciliations.push(record);
|
|
3563
4384
|
},
|
|
4385
|
+
stageInvocations: async (records) => {
|
|
4386
|
+
pendingStagedBatches.push(records.map(cloneStagedInvocationRecord));
|
|
4387
|
+
},
|
|
3564
4388
|
updateActionInvocation: async (id, tenantId, spaceId, patch) => {
|
|
3565
4389
|
pendingUpdates.push({
|
|
3566
4390
|
id,
|
|
@@ -3614,6 +4438,15 @@ var MemoryPlatformHostStore = class {
|
|
|
3614
4438
|
rememberAppend(this.externalReconciliations, record.id);
|
|
3615
4439
|
await this.appendExternalReconciliation(record);
|
|
3616
4440
|
}
|
|
4441
|
+
for (const records of pendingStagedBatches) {
|
|
4442
|
+
const appended = this.applyStagedBatch(records);
|
|
4443
|
+
if (appended.length > 0) undoLedgerWrites.push(() => {
|
|
4444
|
+
for (const id of appended) {
|
|
4445
|
+
const index = this.stagedInvocations.findIndex((record) => record.id === id);
|
|
4446
|
+
if (index !== -1) this.stagedInvocations.splice(index, 1);
|
|
4447
|
+
}
|
|
4448
|
+
});
|
|
4449
|
+
}
|
|
3617
4450
|
for (const [id, patch, scope] of pendingAdapterUpdates) {
|
|
3618
4451
|
const record = await this.getAdapterInvocation(id, scope);
|
|
3619
4452
|
if (!record) throw new Error(`AdapterInvocation not found: ${id}`);
|
|
@@ -3664,13 +4497,59 @@ var MemoryPlatformHostStore = class {
|
|
|
3664
4497
|
this.invocations.push(record);
|
|
3665
4498
|
return record;
|
|
3666
4499
|
}
|
|
4500
|
+
async createActionInvocationForStagedLease(input) {
|
|
4501
|
+
const { invocation, now = /* @__PURE__ */ new Date() } = input;
|
|
4502
|
+
const existing = this.invocations.find((record) => record.tenantId === invocation.tenantId && record.spaceId === invocation.spaceId && record.stagedOrigin?.parentInvocationId === invocation.stagedOrigin.parentInvocationId && record.stagedOrigin.stageKey === invocation.stagedOrigin.stageKey);
|
|
4503
|
+
if (existing) return {
|
|
4504
|
+
kind: "existing",
|
|
4505
|
+
invocation: existing
|
|
4506
|
+
};
|
|
4507
|
+
if (!this.stagedInvocations.find((record) => record.id === input.stageId && record.tenantId === invocation.tenantId && record.spaceId === invocation.spaceId && record.parentInvocationId === invocation.stagedOrigin.parentInvocationId && record.key === invocation.stagedOrigin.stageKey && record.actionId === invocation.actionId && record.status === "pending" && record.leaseOwner === input.workerId && record.leaseToken === input.leaseToken && record.leaseExpiresAt !== void 0 && record.leaseExpiresAt > now)) return { kind: "lease_lost" };
|
|
4508
|
+
const createdAt = /* @__PURE__ */ new Date();
|
|
4509
|
+
const record = {
|
|
4510
|
+
...invocation,
|
|
4511
|
+
attemptCount: 0,
|
|
4512
|
+
leaseToken: 0,
|
|
4513
|
+
createdAt,
|
|
4514
|
+
updatedAt: createdAt
|
|
4515
|
+
};
|
|
4516
|
+
this.invocations.push(record);
|
|
4517
|
+
return {
|
|
4518
|
+
kind: "created",
|
|
4519
|
+
invocation: record
|
|
4520
|
+
};
|
|
4521
|
+
}
|
|
4522
|
+
applyStagedBatch(records) {
|
|
4523
|
+
if (records.length === 0) return [];
|
|
4524
|
+
const first = records[0];
|
|
4525
|
+
const existing = this.stagedInvocations.filter((record) => record.tenantId === first.tenantId && record.spaceId === first.spaceId && record.parentInvocationId === first.parentInvocationId);
|
|
4526
|
+
if (existing.length > 0) {
|
|
4527
|
+
if (!(existing.length === records.length && records.every((record) => {
|
|
4528
|
+
const stored = existing.find((candidate) => candidate.key === record.key);
|
|
4529
|
+
return stored !== void 0 && stored.batchDigest === record.batchDigest && stagedInvocationRequestIdentity(stored) === stagedInvocationRequestIdentity(record);
|
|
4530
|
+
}))) throw new _fabricorg_platform.ReconciliationRequiredError(`Staged invocation batch drift for parent ${first.parentInvocationId}.`);
|
|
4531
|
+
return [];
|
|
4532
|
+
}
|
|
4533
|
+
const keys = records.map((record) => record.key);
|
|
4534
|
+
if (new Set(keys).size !== keys.length || records.some((record) => record.tenantId !== first.tenantId || record.spaceId !== first.spaceId || record.parentInvocationId !== first.parentInvocationId || record.batchDigest !== first.batchDigest)) throw new Error(`Invalid staged invocation batch for parent ${first.parentInvocationId}.`);
|
|
4535
|
+
for (const record of records) this.stagedInvocations.push(cloneStagedInvocationRecord(record));
|
|
4536
|
+
return records.map((record) => record.id);
|
|
4537
|
+
}
|
|
3667
4538
|
async getActionInvocation(id, tenantId, spaceId) {
|
|
3668
4539
|
return this.invocations.find((record) => record.id === id && record.tenantId === tenantId && record.spaceId === spaceId);
|
|
3669
4540
|
}
|
|
4541
|
+
async getActionInvocationByStagedOrigin(tenantId, spaceId, origin) {
|
|
4542
|
+
return this.invocations.find((record) => record.tenantId === tenantId && record.spaceId === spaceId && record.stagedOrigin?.parentInvocationId === origin.parentInvocationId && record.stagedOrigin.stageKey === origin.stageKey);
|
|
4543
|
+
}
|
|
3670
4544
|
async updateActionInvocation(id, tenantId, spaceId, patch) {
|
|
3671
4545
|
const record = this.invocations.find((row) => row.id === id && row.tenantId === tenantId && row.spaceId === spaceId);
|
|
3672
4546
|
if (!record) throw new Error(`ActionInvocation not found: ${id}`);
|
|
3673
4547
|
Object.assign(record, patch, { updatedAt: /* @__PURE__ */ new Date() });
|
|
4548
|
+
if (Object.hasOwn(patch, "finalizationCheckpoint")) {
|
|
4549
|
+
if (patch.finalizationCheckpoint) record.finalizationCheckpoint = structuredClone(patch.finalizationCheckpoint);
|
|
4550
|
+
else delete record.finalizationCheckpoint;
|
|
4551
|
+
}
|
|
4552
|
+
if (Object.hasOwn(patch, "deferredBeforeEffectAttempt") && patch.deferredBeforeEffectAttempt === void 0) delete record.deferredBeforeEffectAttempt;
|
|
3674
4553
|
if (Object.hasOwn(patch, "pendingStateTransition")) {
|
|
3675
4554
|
if (patch.pendingStateTransition) record.pendingStateTransition = { ...patch.pendingStateTransition };
|
|
3676
4555
|
else delete record.pendingStateTransition;
|
|
@@ -3991,6 +4870,96 @@ var MemoryPlatformHostStore = class {
|
|
|
3991
4870
|
record.updatedAt = current;
|
|
3992
4871
|
return true;
|
|
3993
4872
|
}
|
|
4873
|
+
async claimStagedInvocations(input) {
|
|
4874
|
+
while (this.adapterCommit) await this.adapterCommit;
|
|
4875
|
+
const current = input.now ?? /* @__PURE__ */ new Date();
|
|
4876
|
+
const eligible = this.stagedInvocations.filter((record) => {
|
|
4877
|
+
return this.invocations.find((candidate) => candidate.id === record.parentInvocationId && candidate.tenantId === record.tenantId && candidate.spaceId === record.spaceId)?.status === "completed" && record.status === "pending" && record.availableAt <= current && (!record.leaseExpiresAt || record.leaseExpiresAt <= current) && (!input.tenantId || record.tenantId === input.tenantId) && (!input.spaceId || record.spaceId === input.spaceId);
|
|
4878
|
+
}).sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime() || left.id.localeCompare(right.id)).slice(0, Math.max(1, Math.min(input.limit ?? 100, 1e3)));
|
|
4879
|
+
for (const record of eligible) {
|
|
4880
|
+
record.leaseOwner = input.workerId;
|
|
4881
|
+
record.leaseExpiresAt = new Date(current.getTime() + input.leaseDurationMs);
|
|
4882
|
+
record.leaseToken += 1;
|
|
4883
|
+
record.attemptCount += 1;
|
|
4884
|
+
record.updatedAt = current;
|
|
4885
|
+
}
|
|
4886
|
+
return eligible.map(cloneStagedInvocationRecord);
|
|
4887
|
+
}
|
|
4888
|
+
async markStagedInvocationDispatched(input) {
|
|
4889
|
+
return this.settleStagedInvocation(input, "dispatched", input.childInvocationId, void 0);
|
|
4890
|
+
}
|
|
4891
|
+
async markStagedInvocationRejected(input) {
|
|
4892
|
+
return this.settleStagedInvocation(input, "rejected", void 0, input.error);
|
|
4893
|
+
}
|
|
4894
|
+
async markStagedInvocationFailed(input) {
|
|
4895
|
+
const record = this.leasedStagedInvocation(input.id, input.workerId, input.leaseToken);
|
|
4896
|
+
if (!record) return false;
|
|
4897
|
+
record.status = input.deadLetter ? "dead_lettered" : "pending";
|
|
4898
|
+
record.lastError = input.error;
|
|
4899
|
+
if (input.childInvocationId) record.childInvocationId = input.childInvocationId;
|
|
4900
|
+
record.availableAt = new Date(input.availableAt);
|
|
4901
|
+
record.updatedAt = input.now ?? /* @__PURE__ */ new Date();
|
|
4902
|
+
delete record.leaseOwner;
|
|
4903
|
+
delete record.leaseExpiresAt;
|
|
4904
|
+
return true;
|
|
4905
|
+
}
|
|
4906
|
+
async renewStagedInvocationLease(input) {
|
|
4907
|
+
const current = input.now ?? /* @__PURE__ */ new Date();
|
|
4908
|
+
const record = this.leasedStagedInvocation(input.id, input.workerId, input.leaseToken);
|
|
4909
|
+
if (!record || !record.leaseExpiresAt || record.leaseExpiresAt <= current) return false;
|
|
4910
|
+
record.leaseExpiresAt = new Date(current.getTime() + input.leaseDurationMs);
|
|
4911
|
+
record.updatedAt = current;
|
|
4912
|
+
return true;
|
|
4913
|
+
}
|
|
4914
|
+
/** Await-free, like the fenced create above, so a create runs wholly before or after it. */
|
|
4915
|
+
async rotateStagedInvocationLease(input) {
|
|
4916
|
+
const current = input.now ?? /* @__PURE__ */ new Date();
|
|
4917
|
+
const record = this.leasedStagedInvocation(input.id, input.workerId, input.leaseToken);
|
|
4918
|
+
if (!record || !record.leaseExpiresAt || record.leaseExpiresAt <= current) return void 0;
|
|
4919
|
+
record.leaseToken += 1;
|
|
4920
|
+
record.leaseExpiresAt = new Date(current.getTime() + input.leaseDurationMs);
|
|
4921
|
+
record.updatedAt = current;
|
|
4922
|
+
return record.leaseToken;
|
|
4923
|
+
}
|
|
4924
|
+
async cancelStagedInvocationsForFailedParents(input = {}) {
|
|
4925
|
+
const current = input.now ?? /* @__PURE__ */ new Date();
|
|
4926
|
+
let cancelled = 0;
|
|
4927
|
+
for (const record of this.stagedInvocations) {
|
|
4928
|
+
if (record.status !== "pending" || input.tenantId && record.tenantId !== input.tenantId || input.spaceId && record.spaceId !== input.spaceId) continue;
|
|
4929
|
+
const parent = this.invocations.find((candidate) => candidate.id === record.parentInvocationId && candidate.tenantId === record.tenantId && candidate.spaceId === record.spaceId);
|
|
4930
|
+
if (!parent || ![
|
|
4931
|
+
"failed",
|
|
4932
|
+
"blocked_by_policy",
|
|
4933
|
+
"validation_failed"
|
|
4934
|
+
].includes(parent.status)) continue;
|
|
4935
|
+
record.status = "cancelled";
|
|
4936
|
+
record.lastError = `Parent invocation ended with ${parent.status}.`;
|
|
4937
|
+
record.updatedAt = current;
|
|
4938
|
+
delete record.leaseOwner;
|
|
4939
|
+
delete record.leaseExpiresAt;
|
|
4940
|
+
cancelled += 1;
|
|
4941
|
+
}
|
|
4942
|
+
return cancelled;
|
|
4943
|
+
}
|
|
4944
|
+
async listStagedInvocations(input) {
|
|
4945
|
+
return this.stagedInvocations.filter((record) => record.tenantId === input.tenantId && record.spaceId === input.spaceId && (input.parentInvocationId === void 0 || record.parentInvocationId === input.parentInvocationId) && (!input.statuses?.length || input.statuses.includes(record.status))).sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime() || left.id.localeCompare(right.id)).map(cloneStagedInvocationRecord);
|
|
4946
|
+
}
|
|
4947
|
+
leasedStagedInvocation(id, workerId, leaseToken) {
|
|
4948
|
+
return this.stagedInvocations.find((record) => record.id === id && record.status === "pending" && record.leaseOwner === workerId && record.leaseToken === leaseToken);
|
|
4949
|
+
}
|
|
4950
|
+
/** Mirrors PostgreSQL: a missing child id keeps the recorded one; `lastError` is replaced. */
|
|
4951
|
+
settleStagedInvocation(input, status, childInvocationId, lastError) {
|
|
4952
|
+
const record = this.leasedStagedInvocation(input.id, input.workerId, input.leaseToken);
|
|
4953
|
+
if (!record) return false;
|
|
4954
|
+
record.status = status;
|
|
4955
|
+
if (childInvocationId !== void 0) record.childInvocationId = childInvocationId;
|
|
4956
|
+
if (lastError !== void 0) record.lastError = lastError;
|
|
4957
|
+
else delete record.lastError;
|
|
4958
|
+
record.updatedAt = input.now ?? /* @__PURE__ */ new Date();
|
|
4959
|
+
delete record.leaseOwner;
|
|
4960
|
+
delete record.leaseExpiresAt;
|
|
4961
|
+
return true;
|
|
4962
|
+
}
|
|
3994
4963
|
async listEvents(tenantId, spaceId) {
|
|
3995
4964
|
return this.events.filter((event) => event.tenantId === tenantId && event.spaceId === spaceId).sort((left, right) => left.sequence - right.sequence);
|
|
3996
4965
|
}
|
|
@@ -4084,10 +5053,12 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
4084
5053
|
sql;
|
|
4085
5054
|
transactionWithEvents;
|
|
4086
5055
|
transactionalOutbox;
|
|
5056
|
+
transactionalStagedInvocations;
|
|
4087
5057
|
constructor(db, sql, transactionProvider) {
|
|
4088
5058
|
this.db = db;
|
|
4089
5059
|
this.sql = sql;
|
|
4090
5060
|
this.transactionalOutbox = transactionProvider !== void 0;
|
|
5061
|
+
this.transactionalStagedInvocations = transactionProvider !== void 0;
|
|
4091
5062
|
if (transactionProvider) this.transactionWithEvents = async (run) => transactionProvider.run(async ({ db, sql }) => {
|
|
4092
5063
|
const scoped = new PostgresPlatformHostStore(db, sql);
|
|
4093
5064
|
return run({
|
|
@@ -4113,7 +5084,8 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
4113
5084
|
updateLeasedActionInvocation: (input) => scoped.updateLeasedActionInvocation(input),
|
|
4114
5085
|
updateAdapterInvocation: (id, patch, scope) => scoped.updateAdapterInvocation(id, patch, scope),
|
|
4115
5086
|
writeAdapterInvocation: (input) => scoped.writeAdapterInvocation(input),
|
|
4116
|
-
appendExternalReconciliation: (record) => scoped.appendExternalReconciliation(record)
|
|
5087
|
+
appendExternalReconciliation: (record) => scoped.appendExternalReconciliation(record),
|
|
5088
|
+
stageInvocations: (records) => scoped.stageInvocations(records)
|
|
4117
5089
|
});
|
|
4118
5090
|
});
|
|
4119
5091
|
}
|
|
@@ -4307,6 +5279,48 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
4307
5279
|
sql: `
|
|
4308
5280
|
ALTER TABLE fabric_platform.adapter_invocations ADD COLUMN IF NOT EXISTS reconciliation jsonb;
|
|
4309
5281
|
`
|
|
5282
|
+
},
|
|
5283
|
+
{
|
|
5284
|
+
version: 9,
|
|
5285
|
+
name: "pre_effect_governance_deferral",
|
|
5286
|
+
sql: `ALTER TABLE fabric_platform.action_invocations ADD COLUMN IF NOT EXISTS deferred_before_effect_attempt integer;`
|
|
5287
|
+
},
|
|
5288
|
+
{
|
|
5289
|
+
version: 10,
|
|
5290
|
+
name: "finalization_preparation_checkpoint",
|
|
5291
|
+
sql: `ALTER TABLE fabric_platform.action_invocations ADD COLUMN IF NOT EXISTS finalization_checkpoint jsonb;`
|
|
5292
|
+
},
|
|
5293
|
+
{
|
|
5294
|
+
version: 11,
|
|
5295
|
+
name: "staged_descendant_invocations",
|
|
5296
|
+
sql: `
|
|
5297
|
+
ALTER TABLE fabric_platform.action_invocations
|
|
5298
|
+
ADD COLUMN IF NOT EXISTS staged_parent_invocation_id text,
|
|
5299
|
+
ADD COLUMN IF NOT EXISTS staged_stage_key text;
|
|
5300
|
+
CREATE UNIQUE INDEX IF NOT EXISTS action_invocations_staged_origin_idx
|
|
5301
|
+
ON fabric_platform.action_invocations
|
|
5302
|
+
(tenant_id, space_id, staged_parent_invocation_id, staged_stage_key)
|
|
5303
|
+
WHERE staged_parent_invocation_id IS NOT NULL AND staged_stage_key IS NOT NULL;
|
|
5304
|
+
CREATE TABLE IF NOT EXISTS fabric_platform.staged_invocations (
|
|
5305
|
+
id text PRIMARY KEY,
|
|
5306
|
+
tenant_id text NOT NULL, space_id text NOT NULL,
|
|
5307
|
+
parent_invocation_id text NOT NULL, parent_action_id text NOT NULL,
|
|
5308
|
+
stage_key text NOT NULL, action_id text NOT NULL,
|
|
5309
|
+
expected_action_version integer NOT NULL,
|
|
5310
|
+
parameters jsonb NOT NULL, parameter_digest text NOT NULL, batch_digest text NOT NULL,
|
|
5311
|
+
status text NOT NULL, child_invocation_id text,
|
|
5312
|
+
attempt_count integer NOT NULL DEFAULT 0,
|
|
5313
|
+
available_at timestamptz NOT NULL,
|
|
5314
|
+
lease_owner text, lease_expires_at timestamptz,
|
|
5315
|
+
lease_token bigint NOT NULL DEFAULT 0,
|
|
5316
|
+
last_error text, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
|
|
5317
|
+
UNIQUE (tenant_id, space_id, parent_invocation_id, stage_key)
|
|
5318
|
+
);
|
|
5319
|
+
CREATE INDEX IF NOT EXISTS staged_invocations_claim_idx
|
|
5320
|
+
ON fabric_platform.staged_invocations (status, available_at, lease_expires_at, created_at);
|
|
5321
|
+
CREATE INDEX IF NOT EXISTS staged_invocations_parent_idx
|
|
5322
|
+
ON fabric_platform.staged_invocations (tenant_id, space_id, parent_invocation_id, created_at);
|
|
5323
|
+
`
|
|
4310
5324
|
}
|
|
4311
5325
|
]);
|
|
4312
5326
|
}
|
|
@@ -4322,11 +5336,11 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
4322
5336
|
try {
|
|
4323
5337
|
result = await this.sql.query(`INSERT INTO fabric_platform.action_invocations
|
|
4324
5338
|
(id,tenant_id,space_id,action_id,action_version,actor_id,actor_type,status,
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
5339
|
+
parameters,result,correlation_id,causation_id,idempotency_key,
|
|
5340
|
+
parameter_digest,parameter_digest_algorithm,idempotency_actor_id,
|
|
5341
|
+
idempotency_authorization_binding_id,invocation_provenance,authorization_binding,execution_reason,
|
|
5342
|
+
authorization_binding_id,error,runtime_evidence,${admittedColumn}created_at,updated_at)
|
|
5343
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10::jsonb,$11,$12,$13,$14,$15,$16,$17,$18::jsonb,$19::jsonb,$20,$21,$22,$23::jsonb,${admittedValue}$24,$24)
|
|
4330
5344
|
ON CONFLICT (tenant_id,space_id,action_id,idempotency_key)
|
|
4331
5345
|
WHERE idempotency_key IS NOT NULL DO UPDATE SET id=fabric_platform.action_invocations.id
|
|
4332
5346
|
RETURNING *`, [
|
|
@@ -4362,6 +5376,140 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
4362
5376
|
}
|
|
4363
5377
|
return toActionRecord(result.rows[0]);
|
|
4364
5378
|
}
|
|
5379
|
+
async createActionInvocationForStagedLease(input) {
|
|
5380
|
+
const { invocation } = input;
|
|
5381
|
+
const current = input.now ?? /* @__PURE__ */ new Date();
|
|
5382
|
+
const admitted = invocation.admittedAt !== void 0;
|
|
5383
|
+
const admittedColumn = admitted ? ",admitted_at" : "";
|
|
5384
|
+
const admittedValue = admitted ? ",$30" : "";
|
|
5385
|
+
const values = [
|
|
5386
|
+
invocation.id,
|
|
5387
|
+
invocation.tenantId,
|
|
5388
|
+
invocation.spaceId,
|
|
5389
|
+
invocation.actionId,
|
|
5390
|
+
invocation.actionVersion,
|
|
5391
|
+
invocation.actorId,
|
|
5392
|
+
invocation.actorType,
|
|
5393
|
+
invocation.status,
|
|
5394
|
+
JSON.stringify(invocation.parameters),
|
|
5395
|
+
JSON.stringify(invocation.result),
|
|
5396
|
+
invocation.correlationId,
|
|
5397
|
+
invocation.causationId ?? null,
|
|
5398
|
+
invocation.idempotencyKey ?? null,
|
|
5399
|
+
invocation.parameterDigest ?? null,
|
|
5400
|
+
invocation.parameterDigestAlgorithm ?? null,
|
|
5401
|
+
invocation.idempotencyActorId ?? null,
|
|
5402
|
+
invocation.idempotencyAuthorizationBindingId ?? null,
|
|
5403
|
+
invocation.provenance ? JSON.stringify(invocation.provenance) : null,
|
|
5404
|
+
invocation.authorizationBinding ? JSON.stringify(invocation.authorizationBinding) : null,
|
|
5405
|
+
invocation.executionReason ?? null,
|
|
5406
|
+
invocation.authorizationBindingId ?? null,
|
|
5407
|
+
invocation.error ?? null,
|
|
5408
|
+
invocation.runtimeEvidence ? JSON.stringify(invocation.runtimeEvidence) : null,
|
|
5409
|
+
invocation.stagedOrigin.parentInvocationId,
|
|
5410
|
+
invocation.stagedOrigin.stageKey,
|
|
5411
|
+
input.stageId,
|
|
5412
|
+
input.workerId,
|
|
5413
|
+
input.leaseToken,
|
|
5414
|
+
current,
|
|
5415
|
+
...admitted ? [invocation.admittedAt] : []
|
|
5416
|
+
];
|
|
5417
|
+
let result;
|
|
5418
|
+
try {
|
|
5419
|
+
result = await this.sql.query(`WITH fence AS (
|
|
5420
|
+
SELECT stage.id
|
|
5421
|
+
FROM fabric_platform.staged_invocations AS stage
|
|
5422
|
+
WHERE stage.id=$26 AND stage.tenant_id=$2 AND stage.space_id=$3
|
|
5423
|
+
AND stage.parent_invocation_id=$24 AND stage.stage_key=$25 AND stage.action_id=$4
|
|
5424
|
+
AND stage.status='pending' AND stage.lease_owner=$27 AND stage.lease_token=$28
|
|
5425
|
+
AND stage.lease_expires_at IS NOT NULL AND stage.lease_expires_at > $29
|
|
5426
|
+
FOR SHARE
|
|
5427
|
+
)
|
|
5428
|
+
INSERT INTO fabric_platform.action_invocations
|
|
5429
|
+
(id,tenant_id,space_id,action_id,action_version,actor_id,actor_type,status,
|
|
5430
|
+
parameters,result,correlation_id,causation_id,idempotency_key,parameter_digest,
|
|
5431
|
+
parameter_digest_algorithm,idempotency_actor_id,idempotency_authorization_binding_id,
|
|
5432
|
+
invocation_provenance,authorization_binding,execution_reason,authorization_binding_id,error,
|
|
5433
|
+
runtime_evidence,staged_parent_invocation_id,staged_stage_key${admittedColumn},created_at,updated_at)
|
|
5434
|
+
SELECT $1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10::jsonb,$11,$12,$13,$14,$15,$16,$17,
|
|
5435
|
+
$18::jsonb,$19::jsonb,$20,$21,$22,$23::jsonb,$24,$25${admittedValue},$29,$29
|
|
5436
|
+
FROM fence
|
|
5437
|
+
ON CONFLICT (tenant_id,space_id,staged_parent_invocation_id,staged_stage_key)
|
|
5438
|
+
WHERE staged_parent_invocation_id IS NOT NULL AND staged_stage_key IS NOT NULL
|
|
5439
|
+
DO UPDATE SET id=fabric_platform.action_invocations.id
|
|
5440
|
+
RETURNING *`, values);
|
|
5441
|
+
} catch (error) {
|
|
5442
|
+
if (error?.code === "42703" || error?.code === "42P01") throw new Error("A staged child needs PostgreSQL migration 11 (staged_descendant_invocations); run ensureSchema() before dispatching staged invocations.", { cause: error });
|
|
5443
|
+
throw error;
|
|
5444
|
+
}
|
|
5445
|
+
if (result.rows[0]) {
|
|
5446
|
+
const record = toActionRecord(result.rows[0]);
|
|
5447
|
+
return {
|
|
5448
|
+
kind: record.id === invocation.id ? "created" : "existing",
|
|
5449
|
+
invocation: record
|
|
5450
|
+
};
|
|
5451
|
+
}
|
|
5452
|
+
const existing = await this.getActionInvocationByStagedOrigin(invocation.tenantId, invocation.spaceId, invocation.stagedOrigin);
|
|
5453
|
+
return existing ? {
|
|
5454
|
+
kind: "existing",
|
|
5455
|
+
invocation: existing
|
|
5456
|
+
} : { kind: "lease_lost" };
|
|
5457
|
+
}
|
|
5458
|
+
async stageInvocations(records) {
|
|
5459
|
+
if (records.length === 0) return;
|
|
5460
|
+
const first = records[0];
|
|
5461
|
+
if (!(await this.sql.query(`SELECT id FROM fabric_platform.action_invocations
|
|
5462
|
+
WHERE id=$1 AND tenant_id=$2 AND space_id=$3 FOR UPDATE`, [
|
|
5463
|
+
first.parentInvocationId,
|
|
5464
|
+
first.tenantId,
|
|
5465
|
+
first.spaceId
|
|
5466
|
+
])).rows[0]) throw new Error(`ActionInvocation not found: ${first.parentInvocationId}`);
|
|
5467
|
+
const current = await this.sql.query(`SELECT * FROM fabric_platform.staged_invocations
|
|
5468
|
+
WHERE tenant_id=$1 AND space_id=$2 AND parent_invocation_id=$3
|
|
5469
|
+
ORDER BY stage_key`, [
|
|
5470
|
+
first.tenantId,
|
|
5471
|
+
first.spaceId,
|
|
5472
|
+
first.parentInvocationId
|
|
5473
|
+
]);
|
|
5474
|
+
if (current.rows.length > 0) {
|
|
5475
|
+
const stored = current.rows.map(toStagedInvocationRecord);
|
|
5476
|
+
if (!(stored.length === records.length && records.every((record) => {
|
|
5477
|
+
const candidate = stored.find((item) => item.key === record.key);
|
|
5478
|
+
return candidate !== void 0 && candidate.batchDigest === record.batchDigest && stagedInvocationRequestIdentity(candidate) === stagedInvocationRequestIdentity(record);
|
|
5479
|
+
}))) throw new _fabricorg_platform.ReconciliationRequiredError(`Staged invocation batch drift for parent ${first.parentInvocationId}.`);
|
|
5480
|
+
return;
|
|
5481
|
+
}
|
|
5482
|
+
for (const record of records) {
|
|
5483
|
+
if (record.tenantId !== first.tenantId || record.spaceId !== first.spaceId || record.parentInvocationId !== first.parentInvocationId || record.batchDigest !== first.batchDigest) throw new Error(`Invalid staged invocation batch for parent ${first.parentInvocationId}.`);
|
|
5484
|
+
await this.sql.query(`INSERT INTO fabric_platform.staged_invocations
|
|
5485
|
+
(id,tenant_id,space_id,parent_invocation_id,parent_action_id,stage_key,action_id,
|
|
5486
|
+
expected_action_version,parameters,parameter_digest,batch_digest,status,child_invocation_id,
|
|
5487
|
+
attempt_count,available_at,lease_owner,lease_expires_at,lease_token,last_error,created_at,updated_at)
|
|
5488
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)`, [
|
|
5489
|
+
record.id,
|
|
5490
|
+
record.tenantId,
|
|
5491
|
+
record.spaceId,
|
|
5492
|
+
record.parentInvocationId,
|
|
5493
|
+
record.parentActionId,
|
|
5494
|
+
record.key,
|
|
5495
|
+
record.actionId,
|
|
5496
|
+
record.expectedActionVersion,
|
|
5497
|
+
JSON.stringify(record.parameters),
|
|
5498
|
+
record.parameterDigest,
|
|
5499
|
+
record.batchDigest,
|
|
5500
|
+
record.status,
|
|
5501
|
+
record.childInvocationId ?? null,
|
|
5502
|
+
record.attemptCount,
|
|
5503
|
+
record.availableAt,
|
|
5504
|
+
record.leaseOwner ?? null,
|
|
5505
|
+
record.leaseExpiresAt ?? null,
|
|
5506
|
+
record.leaseToken,
|
|
5507
|
+
record.lastError ?? null,
|
|
5508
|
+
record.createdAt,
|
|
5509
|
+
record.updatedAt
|
|
5510
|
+
]);
|
|
5511
|
+
}
|
|
5512
|
+
}
|
|
4365
5513
|
async getActionInvocation(id, tenantId, spaceId) {
|
|
4366
5514
|
const result = await this.sql.query(`SELECT * FROM fabric_platform.action_invocations
|
|
4367
5515
|
WHERE id=$1 AND tenant_id=$2 AND space_id=$3`, [
|
|
@@ -4371,6 +5519,17 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
4371
5519
|
]);
|
|
4372
5520
|
return result.rows[0] ? toActionRecord(result.rows[0]) : void 0;
|
|
4373
5521
|
}
|
|
5522
|
+
async getActionInvocationByStagedOrigin(tenantId, spaceId, origin) {
|
|
5523
|
+
const result = await this.sql.query(`SELECT * FROM fabric_platform.action_invocations
|
|
5524
|
+
WHERE tenant_id=$1 AND space_id=$2
|
|
5525
|
+
AND staged_parent_invocation_id=$3 AND staged_stage_key=$4`, [
|
|
5526
|
+
tenantId,
|
|
5527
|
+
spaceId,
|
|
5528
|
+
origin.parentInvocationId,
|
|
5529
|
+
origin.stageKey
|
|
5530
|
+
]);
|
|
5531
|
+
return result.rows[0] ? toActionRecord(result.rows[0]) : void 0;
|
|
5532
|
+
}
|
|
4374
5533
|
async updateActionInvocation(id, tenantId, spaceId, patch) {
|
|
4375
5534
|
await this.sql.query(`UPDATE fabric_platform.action_invocations SET
|
|
4376
5535
|
status=COALESCE($4,status), result=COALESCE($5::jsonb,result),
|
|
@@ -4380,6 +5539,8 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
4380
5539
|
pending_completion=CASE WHEN $12::boolean THEN $13::jsonb ELSE pending_completion END,
|
|
4381
5540
|
completion_due_at=CASE WHEN $12::boolean THEN ($13::jsonb->>'dueAt')::timestamptz ELSE completion_due_at END,
|
|
4382
5541
|
external_completion=CASE WHEN $14::boolean THEN $15::jsonb ELSE external_completion END,
|
|
5542
|
+
${Object.hasOwn(patch, "finalizationCheckpoint") ? `finalization_checkpoint=$${18 + (Object.hasOwn(patch, "deferredBeforeEffectAttempt") ? 2 : 0)}::jsonb,` : ""}
|
|
5543
|
+
${Object.hasOwn(patch, "deferredBeforeEffectAttempt") ? "deferred_before_effect_attempt=CASE WHEN $18::boolean THEN $19::integer ELSE deferred_before_effect_attempt END," : ""}
|
|
4383
5544
|
pending_state_transition=CASE WHEN $16::boolean THEN $17::jsonb ELSE pending_state_transition END,
|
|
4384
5545
|
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')
|
|
4385
5546
|
THEN NULL ELSE lease_owner END,
|
|
@@ -4403,7 +5564,9 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
4403
5564
|
Object.hasOwn(patch, "externalCompletion"),
|
|
4404
5565
|
patch.externalCompletion ? JSON.stringify(patch.externalCompletion) : null,
|
|
4405
5566
|
Object.hasOwn(patch, "pendingStateTransition"),
|
|
4406
|
-
patch.pendingStateTransition ? JSON.stringify(patch.pendingStateTransition) : null
|
|
5567
|
+
patch.pendingStateTransition ? JSON.stringify(patch.pendingStateTransition) : null,
|
|
5568
|
+
...Object.hasOwn(patch, "deferredBeforeEffectAttempt") ? [true, patch.deferredBeforeEffectAttempt ?? null] : [],
|
|
5569
|
+
...Object.hasOwn(patch, "finalizationCheckpoint") ? [patch.finalizationCheckpoint ? JSON.stringify(patch.finalizationCheckpoint) : null] : []
|
|
4407
5570
|
]);
|
|
4408
5571
|
}
|
|
4409
5572
|
async recordHitlDecision(actionInvocationId, tenantId, spaceId, decision) {
|
|
@@ -4897,6 +6060,139 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
4897
6060
|
input.outboxId ?? null
|
|
4898
6061
|
])).rows.map((row) => toOutboxRequeueRecord(row, ""));
|
|
4899
6062
|
}
|
|
6063
|
+
async claimStagedInvocations(input) {
|
|
6064
|
+
const current = input.now ?? /* @__PURE__ */ new Date();
|
|
6065
|
+
const leaseExpiresAt = new Date(current.getTime() + input.leaseDurationMs);
|
|
6066
|
+
const limit = Math.max(1, Math.min(input.limit ?? 100, 1e3));
|
|
6067
|
+
return (await this.sql.query(`WITH claimable AS (
|
|
6068
|
+
SELECT stage.id
|
|
6069
|
+
FROM fabric_platform.staged_invocations AS stage
|
|
6070
|
+
JOIN fabric_platform.action_invocations AS parent
|
|
6071
|
+
ON parent.id=stage.parent_invocation_id
|
|
6072
|
+
AND parent.tenant_id=stage.tenant_id AND parent.space_id=stage.space_id
|
|
6073
|
+
WHERE stage.status='pending' AND parent.status='completed'
|
|
6074
|
+
AND stage.available_at <= $1
|
|
6075
|
+
AND (stage.lease_expires_at IS NULL OR stage.lease_expires_at <= $1)
|
|
6076
|
+
AND ($2::text IS NULL OR stage.tenant_id=$2)
|
|
6077
|
+
AND ($3::text IS NULL OR stage.space_id=$3)
|
|
6078
|
+
ORDER BY stage.created_at,stage.id
|
|
6079
|
+
FOR UPDATE OF stage SKIP LOCKED LIMIT $4
|
|
6080
|
+
)
|
|
6081
|
+
UPDATE fabric_platform.staged_invocations AS stage
|
|
6082
|
+
SET lease_owner=$5, lease_expires_at=$6, lease_token=stage.lease_token+1,
|
|
6083
|
+
attempt_count=stage.attempt_count+1, updated_at=$1
|
|
6084
|
+
FROM claimable WHERE stage.id=claimable.id
|
|
6085
|
+
RETURNING stage.*`, [
|
|
6086
|
+
current,
|
|
6087
|
+
input.tenantId ?? null,
|
|
6088
|
+
input.spaceId ?? null,
|
|
6089
|
+
limit,
|
|
6090
|
+
input.workerId,
|
|
6091
|
+
leaseExpiresAt
|
|
6092
|
+
])).rows.map(toStagedInvocationRecord);
|
|
6093
|
+
}
|
|
6094
|
+
async markStagedInvocationDispatched(input) {
|
|
6095
|
+
return this.settleStagedInvocation(input.id, input.workerId, input.leaseToken, "dispatched", input.now ?? /* @__PURE__ */ new Date(), input.childInvocationId, null);
|
|
6096
|
+
}
|
|
6097
|
+
async markStagedInvocationRejected(input) {
|
|
6098
|
+
return this.settleStagedInvocation(input.id, input.workerId, input.leaseToken, "rejected", input.now ?? /* @__PURE__ */ new Date(), null, input.error);
|
|
6099
|
+
}
|
|
6100
|
+
async markStagedInvocationFailed(input) {
|
|
6101
|
+
return (await this.sql.query(`UPDATE fabric_platform.staged_invocations
|
|
6102
|
+
SET status=$4, last_error=$5, available_at=$6,
|
|
6103
|
+
child_invocation_id=COALESCE($7,child_invocation_id),
|
|
6104
|
+
lease_owner=NULL, lease_expires_at=NULL, updated_at=$8
|
|
6105
|
+
WHERE id=$1 AND status='pending' AND lease_owner=$2 AND lease_token=$3 RETURNING id`, [
|
|
6106
|
+
input.id,
|
|
6107
|
+
input.workerId,
|
|
6108
|
+
input.leaseToken,
|
|
6109
|
+
input.deadLetter ? "dead_lettered" : "pending",
|
|
6110
|
+
input.error,
|
|
6111
|
+
input.availableAt,
|
|
6112
|
+
input.childInvocationId ?? null,
|
|
6113
|
+
input.now ?? /* @__PURE__ */ new Date()
|
|
6114
|
+
])).rows.length === 1;
|
|
6115
|
+
}
|
|
6116
|
+
async renewStagedInvocationLease(input) {
|
|
6117
|
+
const current = input.now ?? /* @__PURE__ */ new Date();
|
|
6118
|
+
return (await this.sql.query(`UPDATE fabric_platform.staged_invocations
|
|
6119
|
+
SET lease_expires_at=$4, updated_at=$5
|
|
6120
|
+
WHERE id=$1 AND status='pending' AND lease_owner=$2 AND lease_token=$3
|
|
6121
|
+
AND lease_expires_at IS NOT NULL AND lease_expires_at > $5 RETURNING id`, [
|
|
6122
|
+
input.id,
|
|
6123
|
+
input.workerId,
|
|
6124
|
+
input.leaseToken,
|
|
6125
|
+
new Date(current.getTime() + input.leaseDurationMs),
|
|
6126
|
+
current
|
|
6127
|
+
])).rows.length === 1;
|
|
6128
|
+
}
|
|
6129
|
+
/**
|
|
6130
|
+
* The UPDATE needs a row lock that conflicts with the `FOR SHARE` lock the
|
|
6131
|
+
* fenced child insert takes on this row, so it waits for an in-flight
|
|
6132
|
+
* insert to commit. A fenced insert under the old token that reaches the
|
|
6133
|
+
* row while this rotation is uncommitted waits for it, rechecks the new row
|
|
6134
|
+
* version and inserts nothing (live tests "orders a token rotation after an
|
|
6135
|
+
* in-flight fenced create ..." and "refuses a fenced create under the old
|
|
6136
|
+
* token once a token rotation is ordered before it").
|
|
6137
|
+
*/
|
|
6138
|
+
async rotateStagedInvocationLease(input) {
|
|
6139
|
+
const current = input.now ?? /* @__PURE__ */ new Date();
|
|
6140
|
+
const result = await this.sql.query(`UPDATE fabric_platform.staged_invocations
|
|
6141
|
+
SET lease_token=lease_token+1, lease_expires_at=$4, updated_at=$5
|
|
6142
|
+
WHERE id=$1 AND status='pending' AND lease_owner=$2 AND lease_token=$3
|
|
6143
|
+
AND lease_expires_at IS NOT NULL AND lease_expires_at > $5
|
|
6144
|
+
RETURNING lease_token`, [
|
|
6145
|
+
input.id,
|
|
6146
|
+
input.workerId,
|
|
6147
|
+
input.leaseToken,
|
|
6148
|
+
new Date(current.getTime() + input.leaseDurationMs),
|
|
6149
|
+
current
|
|
6150
|
+
]);
|
|
6151
|
+
return result.rows[0] ? Number(result.rows[0].lease_token) : void 0;
|
|
6152
|
+
}
|
|
6153
|
+
async cancelStagedInvocationsForFailedParents(input = {}) {
|
|
6154
|
+
return (await this.sql.query(`UPDATE fabric_platform.staged_invocations AS stage
|
|
6155
|
+
SET status='cancelled', last_error='Parent invocation ended with ' || parent.status || '.',
|
|
6156
|
+
lease_owner=NULL, lease_expires_at=NULL, updated_at=$3
|
|
6157
|
+
FROM fabric_platform.action_invocations AS parent
|
|
6158
|
+
WHERE parent.id=stage.parent_invocation_id
|
|
6159
|
+
AND parent.tenant_id=stage.tenant_id AND parent.space_id=stage.space_id
|
|
6160
|
+
AND parent.status IN ('failed','blocked_by_policy','validation_failed')
|
|
6161
|
+
AND stage.status='pending'
|
|
6162
|
+
AND ($1::text IS NULL OR stage.tenant_id=$1)
|
|
6163
|
+
AND ($2::text IS NULL OR stage.space_id=$2)
|
|
6164
|
+
RETURNING stage.id`, [
|
|
6165
|
+
input.tenantId ?? null,
|
|
6166
|
+
input.spaceId ?? null,
|
|
6167
|
+
input.now ?? /* @__PURE__ */ new Date()
|
|
6168
|
+
])).rows.length;
|
|
6169
|
+
}
|
|
6170
|
+
async listStagedInvocations(input) {
|
|
6171
|
+
return (await this.sql.query(`SELECT * FROM fabric_platform.staged_invocations
|
|
6172
|
+
WHERE tenant_id=$1 AND space_id=$2
|
|
6173
|
+
AND ($3::text IS NULL OR parent_invocation_id=$3)
|
|
6174
|
+
AND ($4::text[] IS NULL OR status=ANY($4::text[]))
|
|
6175
|
+
ORDER BY created_at,id`, [
|
|
6176
|
+
input.tenantId,
|
|
6177
|
+
input.spaceId,
|
|
6178
|
+
input.parentInvocationId ?? null,
|
|
6179
|
+
input.statuses?.length ? [...input.statuses] : null
|
|
6180
|
+
])).rows.map(toStagedInvocationRecord);
|
|
6181
|
+
}
|
|
6182
|
+
async settleStagedInvocation(id, workerId, leaseToken, status, now, childInvocationId, lastError) {
|
|
6183
|
+
return (await this.sql.query(`UPDATE fabric_platform.staged_invocations
|
|
6184
|
+
SET status=$4, child_invocation_id=COALESCE($5,child_invocation_id), last_error=$6,
|
|
6185
|
+
lease_owner=NULL, lease_expires_at=NULL, updated_at=$7
|
|
6186
|
+
WHERE id=$1 AND status='pending' AND lease_owner=$2 AND lease_token=$3 RETURNING id`, [
|
|
6187
|
+
id,
|
|
6188
|
+
workerId,
|
|
6189
|
+
leaseToken,
|
|
6190
|
+
status,
|
|
6191
|
+
childInvocationId,
|
|
6192
|
+
lastError,
|
|
6193
|
+
now
|
|
6194
|
+
])).rows.length === 1;
|
|
6195
|
+
}
|
|
4900
6196
|
async nextEventSequence(tenantId, spaceId) {
|
|
4901
6197
|
const result = await this.sql.query(`INSERT INTO fabric_platform.event_sequences (tenant_id,space_id,next_sequence)
|
|
4902
6198
|
VALUES ($1,$2,1) ON CONFLICT (tenant_id,space_id) DO UPDATE
|
|
@@ -5033,6 +6329,8 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
5033
6329
|
pending_completion=CASE WHEN $14::boolean THEN $15::jsonb ELSE pending_completion END,
|
|
5034
6330
|
completion_due_at=CASE WHEN $14::boolean THEN ($15::jsonb->>'dueAt')::timestamptz ELSE completion_due_at END,
|
|
5035
6331
|
external_completion=CASE WHEN $16::boolean THEN $17::jsonb ELSE external_completion END,
|
|
6332
|
+
${Object.hasOwn(patch, "finalizationCheckpoint") ? `finalization_checkpoint=$${20 + (Object.hasOwn(patch, "deferredBeforeEffectAttempt") ? 2 : 0)}::jsonb,` : ""}
|
|
6333
|
+
${Object.hasOwn(patch, "deferredBeforeEffectAttempt") ? "deferred_before_effect_attempt=CASE WHEN $20::boolean THEN $21::integer ELSE deferred_before_effect_attempt END," : ""}
|
|
5036
6334
|
pending_state_transition=CASE WHEN $18::boolean THEN $19::jsonb ELSE pending_state_transition END,
|
|
5037
6335
|
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')
|
|
5038
6336
|
THEN NULL ELSE lease_owner END,
|
|
@@ -5060,7 +6358,9 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
5060
6358
|
Object.hasOwn(patch, "externalCompletion"),
|
|
5061
6359
|
patch.externalCompletion ? JSON.stringify(patch.externalCompletion) : null,
|
|
5062
6360
|
Object.hasOwn(patch, "pendingStateTransition"),
|
|
5063
|
-
patch.pendingStateTransition ? JSON.stringify(patch.pendingStateTransition) : null
|
|
6361
|
+
patch.pendingStateTransition ? JSON.stringify(patch.pendingStateTransition) : null,
|
|
6362
|
+
...Object.hasOwn(patch, "deferredBeforeEffectAttempt") ? [true, patch.deferredBeforeEffectAttempt ?? null] : [],
|
|
6363
|
+
...Object.hasOwn(patch, "finalizationCheckpoint") ? [patch.finalizationCheckpoint ? JSON.stringify(patch.finalizationCheckpoint) : null] : []
|
|
5064
6364
|
])).rows.length === 1;
|
|
5065
6365
|
}
|
|
5066
6366
|
async renewActionInvocationLease(input) {
|
|
@@ -5147,16 +6447,22 @@ function toActionRecord(row) {
|
|
|
5147
6447
|
...row.parameter_digest_algorithm ? { parameterDigestAlgorithm: String(row.parameter_digest_algorithm) } : {},
|
|
5148
6448
|
...row.idempotency_actor_id ? { idempotencyActorId: String(row.idempotency_actor_id) } : {},
|
|
5149
6449
|
...row.idempotency_authorization_binding_id ? { idempotencyAuthorizationBindingId: String(row.idempotency_authorization_binding_id) } : {},
|
|
6450
|
+
...row.staged_parent_invocation_id && row.staged_stage_key ? { stagedOrigin: {
|
|
6451
|
+
parentInvocationId: String(row.staged_parent_invocation_id),
|
|
6452
|
+
stageKey: String(row.staged_stage_key)
|
|
6453
|
+
} } : {},
|
|
5150
6454
|
...row.invocation_provenance ? { provenance: row.invocation_provenance } : {},
|
|
5151
6455
|
...row.authorization_binding ? { authorizationBinding: row.authorization_binding } : {},
|
|
5152
6456
|
...row.execution_reason ? { executionReason: String(row.execution_reason) } : {},
|
|
5153
6457
|
...row.authorization_reconciliation ? { authorizationReconciliation: row.authorization_reconciliation } : {},
|
|
5154
6458
|
...row.adapter_reconciliation ? { adapterReconciliation: row.adapter_reconciliation } : {},
|
|
6459
|
+
...row.finalization_checkpoint ? { finalizationCheckpoint: row.finalization_checkpoint } : {},
|
|
5155
6460
|
...row.pending_state_transition ? { pendingStateTransition: row.pending_state_transition } : {},
|
|
5156
6461
|
...row.pending_completion ? { pendingCompletion: toPendingCompletion(row.pending_completion) } : {},
|
|
5157
6462
|
...row.external_completion ? { externalCompletion: toExternalCompletion(row.external_completion) } : {},
|
|
5158
6463
|
...row.authorization_binding_id ? { authorizationBindingId: String(row.authorization_binding_id) } : {},
|
|
5159
6464
|
attemptCount: Number(row.attempt_count ?? 0),
|
|
6465
|
+
...row.deferred_before_effect_attempt == null ? {} : { deferredBeforeEffectAttempt: Number(row.deferred_before_effect_attempt) },
|
|
5160
6466
|
leaseToken: Number(row.lease_token ?? 0),
|
|
5161
6467
|
...row.lease_owner ? { leaseOwner: String(row.lease_owner) } : {},
|
|
5162
6468
|
...row.lease_expires_at ? { leaseExpiresAt: new Date(row.lease_expires_at) } : {},
|
|
@@ -5174,6 +6480,31 @@ function toActionRecord(row) {
|
|
|
5174
6480
|
updatedAt: new Date(row.updated_at)
|
|
5175
6481
|
};
|
|
5176
6482
|
}
|
|
6483
|
+
function toStagedInvocationRecord(row) {
|
|
6484
|
+
return {
|
|
6485
|
+
id: String(row.id),
|
|
6486
|
+
tenantId: String(row.tenant_id),
|
|
6487
|
+
spaceId: String(row.space_id),
|
|
6488
|
+
parentInvocationId: String(row.parent_invocation_id),
|
|
6489
|
+
parentActionId: String(row.parent_action_id),
|
|
6490
|
+
key: String(row.stage_key),
|
|
6491
|
+
actionId: String(row.action_id),
|
|
6492
|
+
expectedActionVersion: Number(row.expected_action_version),
|
|
6493
|
+
parameters: row.parameters,
|
|
6494
|
+
parameterDigest: String(row.parameter_digest),
|
|
6495
|
+
batchDigest: String(row.batch_digest),
|
|
6496
|
+
status: String(row.status),
|
|
6497
|
+
...row.child_invocation_id ? { childInvocationId: String(row.child_invocation_id) } : {},
|
|
6498
|
+
attemptCount: Number(row.attempt_count),
|
|
6499
|
+
availableAt: new Date(row.available_at),
|
|
6500
|
+
...row.lease_owner ? { leaseOwner: String(row.lease_owner) } : {},
|
|
6501
|
+
...row.lease_expires_at ? { leaseExpiresAt: new Date(row.lease_expires_at) } : {},
|
|
6502
|
+
leaseToken: Number(row.lease_token ?? 0),
|
|
6503
|
+
...row.last_error ? { lastError: String(row.last_error) } : {},
|
|
6504
|
+
createdAt: new Date(row.created_at),
|
|
6505
|
+
updatedAt: new Date(row.updated_at)
|
|
6506
|
+
};
|
|
6507
|
+
}
|
|
5177
6508
|
function toObligationRecord(row) {
|
|
5178
6509
|
return {
|
|
5179
6510
|
id: String(row.id),
|
|
@@ -5313,6 +6644,23 @@ function createStoreBackedActionDispatcher() {
|
|
|
5313
6644
|
async function runPlatformActionWorkerCycle(options) {
|
|
5314
6645
|
const leaseDurationMs = options.leaseDurationMs ?? DEFAULT_LEASE_DURATION_MS;
|
|
5315
6646
|
emitWorkerTelemetry(options.telemetry, "worker.cycle.started", options.workerId);
|
|
6647
|
+
let staged;
|
|
6648
|
+
if ((options.dispatchStagedInvocations === true || options.dispatchStagedInvocations !== false && options.host.hasStagedInvocationActions === true) && typeof options.host.dispatchStagedInvocations === "function") try {
|
|
6649
|
+
staged = await options.host.dispatchStagedInvocations({
|
|
6650
|
+
workerId: options.workerId,
|
|
6651
|
+
...options.stagedInvocationBatchSize !== void 0 ? { limit: options.stagedInvocationBatchSize } : {},
|
|
6652
|
+
...options.stagedInvocationLeaseDurationMs !== void 0 ? { leaseDurationMs: options.stagedInvocationLeaseDurationMs } : {},
|
|
6653
|
+
...options.stagedInvocationLeaseRenewalIntervalMs !== void 0 ? { leaseRenewalIntervalMs: options.stagedInvocationLeaseRenewalIntervalMs } : {},
|
|
6654
|
+
...options.stagedInvocationSubmissionTimeoutMs !== void 0 ? { submissionTimeoutMs: options.stagedInvocationSubmissionTimeoutMs } : {},
|
|
6655
|
+
...options.stagedInvocationMaxAttempts !== void 0 ? { maxAttempts: options.stagedInvocationMaxAttempts } : {},
|
|
6656
|
+
...options.stagedInvocationRetryDelayMs ? { retryDelayMs: options.stagedInvocationRetryDelayMs } : {},
|
|
6657
|
+
...options.tenantId ? { tenantId: options.tenantId } : {},
|
|
6658
|
+
...options.spaceId ? { spaceId: options.spaceId } : {},
|
|
6659
|
+
...options.onError ? { onError: (error) => options.onError?.(error) } : {}
|
|
6660
|
+
});
|
|
6661
|
+
} catch (error) {
|
|
6662
|
+
options.onError?.(error);
|
|
6663
|
+
}
|
|
5316
6664
|
if (options.sweepOverdueCompletions !== false && typeof options.host.reconcileOverdueCompletions === "function") try {
|
|
5317
6665
|
await options.host.reconcileOverdueCompletions({
|
|
5318
6666
|
...options.tenantId ? { tenantId: options.tenantId } : {},
|
|
@@ -5370,9 +6718,20 @@ async function runPlatformActionWorkerCycle(options) {
|
|
|
5370
6718
|
completed,
|
|
5371
6719
|
failed,
|
|
5372
6720
|
...waitingForApproval > 0 ? { waitingForApproval } : {},
|
|
5373
|
-
...deferred > 0 ? { deferred } : {}
|
|
6721
|
+
...deferred > 0 ? { deferred } : {},
|
|
6722
|
+
...staged && Object.values(staged).some((value) => value > 0) ? { staged } : {}
|
|
5374
6723
|
};
|
|
5375
|
-
emitWorkerTelemetry(options.telemetry, "worker.cycle.completed", options.workerId,
|
|
6724
|
+
emitWorkerTelemetry(options.telemetry, "worker.cycle.completed", options.workerId, {
|
|
6725
|
+
claimed: result.claimed,
|
|
6726
|
+
completed: result.completed,
|
|
6727
|
+
failed: result.failed,
|
|
6728
|
+
...result.waitingForApproval !== void 0 ? { waitingForApproval: result.waitingForApproval } : {},
|
|
6729
|
+
...result.deferred !== void 0 ? { deferred: result.deferred } : {},
|
|
6730
|
+
...result.staged ? {
|
|
6731
|
+
stagedClaimed: result.staged.claimed,
|
|
6732
|
+
stagedDispatched: result.staged.dispatched
|
|
6733
|
+
} : {}
|
|
6734
|
+
});
|
|
5376
6735
|
return result;
|
|
5377
6736
|
}
|
|
5378
6737
|
function startLeaseHeartbeat(options, invocation, leaseDurationMs) {
|
|
@@ -5630,15 +6989,16 @@ async function adapterProbe(subject) {
|
|
|
5630
6989
|
};
|
|
5631
6990
|
}
|
|
5632
6991
|
/**
|
|
5633
|
-
* The suite
|
|
6992
|
+
* The suite a deployment of the host must pass before it claims isolation.
|
|
5634
6993
|
*
|
|
5635
|
-
*
|
|
6994
|
+
* Nine checks: an invocation cannot be read, executed, or completed under
|
|
5636
6995
|
* another tenant's scope; an idempotency key is a tenant's own, so the same
|
|
5637
6996
|
* key in two tenants is two invocations; a tenant's event stream carries no
|
|
5638
6997
|
* other tenant's events; a worker scoped to one tenant does not claim another's
|
|
5639
|
-
* work;
|
|
5640
|
-
*
|
|
5641
|
-
* the
|
|
6998
|
+
* work; staged listing and claiming retain tenant and space scope; an adapter
|
|
6999
|
+
* checkpoint cannot be read or written under another tenant, space, or owning
|
|
7000
|
+
* invocation; and, when the subject lends a read, a viewer in the other tenant
|
|
7001
|
+
* sees nothing of ours.
|
|
5642
7002
|
*/
|
|
5643
7003
|
function tenantIsolationChecks() {
|
|
5644
7004
|
return [
|
|
@@ -5682,6 +7042,103 @@ function tenantIsolationChecks() {
|
|
|
5682
7042
|
return ["one idempotency key: one invocation within a tenant, two across tenants"];
|
|
5683
7043
|
}
|
|
5684
7044
|
},
|
|
7045
|
+
{
|
|
7046
|
+
id: "fabric.tenancy.staged-invocation-scoped.v1",
|
|
7047
|
+
async run(subject) {
|
|
7048
|
+
await subject.reset?.();
|
|
7049
|
+
const store = subject.store;
|
|
7050
|
+
if (store.transactionalStagedInvocations !== true || typeof store.transactionWithEvents !== "function" || typeof store.listStagedInvocations !== "function" || typeof store.claimStagedInvocations !== "function") return ["skipped: store does not expose transactional staged invocations"];
|
|
7051
|
+
const suffix = globalThis.crypto.randomUUID();
|
|
7052
|
+
const probeTenant = `iso-stage-tenant-${suffix}`;
|
|
7053
|
+
const otherProbeTenant = `iso-stage-tenant-other-${suffix}`;
|
|
7054
|
+
const probeSpace = `iso-stage-space-${suffix}`;
|
|
7055
|
+
const otherProbeSpace = `iso-stage-space-other-${suffix}`;
|
|
7056
|
+
const parents = [
|
|
7057
|
+
{
|
|
7058
|
+
id: `iso_stage_parent_ours_${suffix}`,
|
|
7059
|
+
tenantId: probeTenant,
|
|
7060
|
+
spaceId: probeSpace,
|
|
7061
|
+
actorId: subject.ours.actorId,
|
|
7062
|
+
actorType: subject.ours.actorType
|
|
7063
|
+
},
|
|
7064
|
+
{
|
|
7065
|
+
id: `iso_stage_parent_theirs_${suffix}`,
|
|
7066
|
+
tenantId: otherProbeTenant,
|
|
7067
|
+
spaceId: probeSpace,
|
|
7068
|
+
actorId: subject.theirs.actorId,
|
|
7069
|
+
actorType: subject.theirs.actorType
|
|
7070
|
+
},
|
|
7071
|
+
{
|
|
7072
|
+
id: `iso_stage_parent_space_${suffix}`,
|
|
7073
|
+
tenantId: probeTenant,
|
|
7074
|
+
spaceId: otherProbeSpace,
|
|
7075
|
+
actorId: subject.ours.actorId,
|
|
7076
|
+
actorType: subject.ours.actorType
|
|
7077
|
+
}
|
|
7078
|
+
];
|
|
7079
|
+
for (const parent of parents) await store.createActionInvocation({
|
|
7080
|
+
...parent,
|
|
7081
|
+
actionId: subject.actionId,
|
|
7082
|
+
actionVersion: 1,
|
|
7083
|
+
status: "completed",
|
|
7084
|
+
parameters: {},
|
|
7085
|
+
result: {},
|
|
7086
|
+
correlationId: suffix
|
|
7087
|
+
});
|
|
7088
|
+
const now = /* @__PURE__ */ new Date();
|
|
7089
|
+
const records = parents.map((parent, index) => ({
|
|
7090
|
+
id: `iso_stage_${index}_${suffix}`,
|
|
7091
|
+
tenantId: parent.tenantId,
|
|
7092
|
+
spaceId: parent.spaceId,
|
|
7093
|
+
parentInvocationId: parent.id,
|
|
7094
|
+
parentActionId: subject.actionId,
|
|
7095
|
+
key: "child",
|
|
7096
|
+
actionId: subject.actionId,
|
|
7097
|
+
expectedActionVersion: 1,
|
|
7098
|
+
parameters: {},
|
|
7099
|
+
parameterDigest: `digest-${index}`,
|
|
7100
|
+
batchDigest: `batch-${index}`,
|
|
7101
|
+
status: "pending",
|
|
7102
|
+
attemptCount: 0,
|
|
7103
|
+
availableAt: now,
|
|
7104
|
+
leaseToken: 0,
|
|
7105
|
+
createdAt: now,
|
|
7106
|
+
updatedAt: now
|
|
7107
|
+
}));
|
|
7108
|
+
try {
|
|
7109
|
+
await store.transactionWithEvents(async (transaction) => {
|
|
7110
|
+
if (!transaction.stageInvocations) fail$1("transactionalStagedInvocations did not supply stageInvocations");
|
|
7111
|
+
for (const record of records) await transaction.stageInvocations([record]);
|
|
7112
|
+
});
|
|
7113
|
+
const ours = await store.listStagedInvocations({
|
|
7114
|
+
tenantId: probeTenant,
|
|
7115
|
+
spaceId: probeSpace
|
|
7116
|
+
});
|
|
7117
|
+
if (!ours.some((record) => record.id === records[0].id)) fail$1("our staged invocation was not listable under our scope, so the check proves nothing");
|
|
7118
|
+
if (ours.some((record) => record.id === records[1].id || record.id === records[2].id)) fail$1("staged invocation listing crossed tenant or space");
|
|
7119
|
+
if ((await store.listStagedInvocations({
|
|
7120
|
+
tenantId: otherProbeTenant,
|
|
7121
|
+
spaceId: probeSpace,
|
|
7122
|
+
parentInvocationId: parents[0].id
|
|
7123
|
+
})).length !== 0) fail$1("a staged invocation was listable under another tenant's scope");
|
|
7124
|
+
const claimed = await store.claimStagedInvocations({
|
|
7125
|
+
workerId: `iso-stage-worker-${suffix}`,
|
|
7126
|
+
tenantId: probeTenant,
|
|
7127
|
+
spaceId: probeSpace,
|
|
7128
|
+
leaseDurationMs: 6e4,
|
|
7129
|
+
now
|
|
7130
|
+
});
|
|
7131
|
+
if (!claimed.some((record) => record.id === records[0].id)) fail$1("our staged invocation was not claimable under our scope, so the check proves nothing");
|
|
7132
|
+
if (claimed.some((record) => record.tenantId !== probeTenant || record.spaceId !== probeSpace)) fail$1("staged invocation claim crossed tenant or space");
|
|
7133
|
+
return ["staged listing and claiming retained both tenant and space scope"];
|
|
7134
|
+
} finally {
|
|
7135
|
+
for (const parent of parents) await store.updateActionInvocation(parent.id, parent.tenantId, parent.spaceId, {
|
|
7136
|
+
status: "failed",
|
|
7137
|
+
error: "tenant isolation probe; retired"
|
|
7138
|
+
});
|
|
7139
|
+
}
|
|
7140
|
+
}
|
|
7141
|
+
},
|
|
5685
7142
|
{
|
|
5686
7143
|
id: "fabric.tenancy.events-scoped.v1",
|
|
5687
7144
|
async run(subject) {
|
|
@@ -6479,13 +7936,20 @@ async function runEventAppendChecks(subject) {
|
|
|
6479
7936
|
exports.ActionAdmissionError = ActionAdmissionError;
|
|
6480
7937
|
exports.ExternalCompletionError = ExternalCompletionError;
|
|
6481
7938
|
exports.IdempotencyConflictError = IdempotencyConflictError;
|
|
7939
|
+
exports.MAX_STAGED_INVOCATIONS = MAX_STAGED_INVOCATIONS;
|
|
7940
|
+
exports.MAX_STAGED_INVOCATION_BATCH_BYTES = MAX_STAGED_INVOCATION_BATCH_BYTES;
|
|
7941
|
+
exports.MAX_STAGED_INVOCATION_BYTES = MAX_STAGED_INVOCATION_BYTES;
|
|
7942
|
+
exports.MAX_STAGED_INVOCATION_KEY_LENGTH = MAX_STAGED_INVOCATION_KEY_LENGTH;
|
|
6482
7943
|
exports.MemoryPlatformHostStore = MemoryPlatformHostStore;
|
|
6483
7944
|
exports.PARAMETER_DIGEST_ALGORITHM = PARAMETER_DIGEST_ALGORITHM;
|
|
6484
7945
|
exports.PLATFORM_HOST_CONTRACT_VERSION = PLATFORM_HOST_CONTRACT_VERSION;
|
|
6485
7946
|
exports.PLATFORM_HOST_HEALTH_CONTRACT_VERSION = PLATFORM_HOST_HEALTH_CONTRACT_VERSION;
|
|
6486
7947
|
exports.PLATFORM_HOST_METRIC_NAMES = PLATFORM_HOST_METRIC_NAMES;
|
|
6487
7948
|
exports.PostgresPlatformHostStore = PostgresPlatformHostStore;
|
|
7949
|
+
exports.STAGED_INVOCATION_KEY_PATTERN = STAGED_INVOCATION_KEY_PATTERN;
|
|
7950
|
+
exports.SubmissionRefusalError = SubmissionRefusalError;
|
|
6488
7951
|
exports.applyPostgresMigrations = applyPostgresMigrations;
|
|
7952
|
+
exports.asStagedInvocationStore = asStagedInvocationStore;
|
|
6489
7953
|
Object.defineProperty(exports, "canonicalJson", {
|
|
6490
7954
|
enumerable: true,
|
|
6491
7955
|
get: function() {
|
|
@@ -6493,6 +7957,7 @@ Object.defineProperty(exports, "canonicalJson", {
|
|
|
6493
7957
|
}
|
|
6494
7958
|
});
|
|
6495
7959
|
exports.cloneOutboxRecord = cloneOutboxRecord;
|
|
7960
|
+
exports.cloneStagedInvocationRecord = cloneStagedInvocationRecord;
|
|
6496
7961
|
exports.createDurableSagaParentLifecycle = createDurableSagaParentLifecycle;
|
|
6497
7962
|
exports.createGovernedActionHost = createGovernedActionHost;
|
|
6498
7963
|
exports.createStoreBackedActionDispatcher = createStoreBackedActionDispatcher;
|
|
@@ -6501,11 +7966,13 @@ exports.emitPlatformHostTelemetry = emitPlatformHostTelemetry;
|
|
|
6501
7966
|
exports.eventAppendChecks = eventAppendChecks;
|
|
6502
7967
|
exports.getPlatformHostHealthSnapshot = getPlatformHostHealthSnapshot;
|
|
6503
7968
|
exports.outboxActorDisclosure = outboxActorDisclosure;
|
|
7969
|
+
exports.prepareStagedInvocationBatch = prepareStagedInvocationBatch;
|
|
6504
7970
|
exports.runEventAppendChecks = runEventAppendChecks;
|
|
6505
7971
|
exports.runOutboxRelayCycle = runOutboxRelayCycle;
|
|
6506
7972
|
exports.runPlatformActionWorker = runPlatformActionWorker;
|
|
6507
7973
|
exports.runPlatformActionWorkerCycle = runPlatformActionWorkerCycle;
|
|
6508
7974
|
exports.runTenantIsolationChecks = runTenantIsolationChecks;
|
|
7975
|
+
exports.stagedInvocationRequestIdentity = stagedInvocationRequestIdentity;
|
|
6509
7976
|
exports.tenantIsolationChecks = tenantIsolationChecks;
|
|
6510
7977
|
exports.toEnterpriseEventEnvelope = toEnterpriseEventEnvelope;
|
|
6511
7978
|
|