@fabricorg/platform-host 8.2.0 → 8.4.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 +39 -0
- package/README.md +136 -4
- package/dist/index.cjs +1196 -23
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +357 -14
- package/dist/index.d.ts +357 -14
- package/dist/index.js +1188 -25
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
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
|
|
@@ -129,7 +141,11 @@ function omitUndefinedOptionalFields(value, keys) {
|
|
|
129
141
|
return copy;
|
|
130
142
|
}
|
|
131
143
|
function validateFinalization(value, action, privateFields, contracts) {
|
|
132
|
-
const normalized = omitUndefinedOptionalFields(value, [
|
|
144
|
+
const normalized = omitUndefinedOptionalFields(value, [
|
|
145
|
+
"events",
|
|
146
|
+
"resultPatch",
|
|
147
|
+
"stagedInvocations"
|
|
148
|
+
]);
|
|
133
149
|
if (normalized !== value && normalized && typeof normalized === "object" && !Array.isArray(normalized)) {
|
|
134
150
|
const record = normalized;
|
|
135
151
|
if (Array.isArray(record.events)) {
|
|
@@ -139,7 +155,7 @@ function validateFinalization(value, action, privateFields, contracts) {
|
|
|
139
155
|
}
|
|
140
156
|
}
|
|
141
157
|
const result = finalizationJson(normalized);
|
|
142
|
-
if (!result || typeof result !== "object" || Array.isArray(result) || Object.keys(result).some((key) => key !== "events" && key !== "resultPatch")) throw new Error("Finalization must return only events and
|
|
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.");
|
|
143
159
|
if (result.resultPatch !== void 0) {
|
|
144
160
|
if (!result.resultPatch || typeof result.resultPatch !== "object" || Array.isArray(result.resultPatch)) throw new Error("Finalization resultPatch must be an object.");
|
|
145
161
|
if (Object.keys(result.resultPatch).some((key) => privateFields.includes(key))) throw new Error("Finalization resultPatch contains a private result key.");
|
|
@@ -1149,6 +1165,120 @@ function createOutboxRequeue(deps) {
|
|
|
1149
1165
|
};
|
|
1150
1166
|
}
|
|
1151
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
|
|
1152
1282
|
//#region src/host.ts
|
|
1153
1283
|
const DEFAULT_EXTRACT_EVENTS = (data) => {
|
|
1154
1284
|
const value = data._events;
|
|
@@ -1173,6 +1303,25 @@ const ADMISSION_FAILED = Object.freeze({ kind: "failed" });
|
|
|
1173
1303
|
const ADMISSION_TIMED_OUT = Object.freeze({ kind: "timed_out" });
|
|
1174
1304
|
/** What an admission refusal's code may look like; anything else is `invalid_decision`. */
|
|
1175
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
|
+
});
|
|
1176
1325
|
function createGovernedActionHost(options) {
|
|
1177
1326
|
const retryableExecutionMaxAttempts = options.retryableExecutionMaxAttempts ?? 5;
|
|
1178
1327
|
if (!Number.isInteger(retryableExecutionMaxAttempts) || retryableExecutionMaxAttempts <= 0) throw new Error("retryableExecutionMaxAttempts must be a positive integer.");
|
|
@@ -1202,6 +1351,9 @@ function createGovernedActionHost(options) {
|
|
|
1202
1351
|
const configuredActionResolver = options.resolveAction ?? options.registry?.resolveAction;
|
|
1203
1352
|
if (!configuredActionResolver) throw new Error("Platform Host requires an explicit module registry or resolveAction function.");
|
|
1204
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.");
|
|
1205
1357
|
const stateMachineResolver = options.registry?.resolveStateMachine ?? (() => void 0);
|
|
1206
1358
|
const eventResultFields = options.eventResultFields ?? ["_events"];
|
|
1207
1359
|
const admissionTimeoutMs = options.admissionTimeoutMs ?? DEFAULT_ADMISSION_TIMEOUT_MS;
|
|
@@ -1210,8 +1362,12 @@ function createGovernedActionHost(options) {
|
|
|
1210
1362
|
return error instanceof HostError ? errorMessage(error) : redactErrorText(options.redactErrorText, context, errorMessage(error));
|
|
1211
1363
|
}
|
|
1212
1364
|
async function submitAction(input) {
|
|
1365
|
+
return submitActionInternal(input);
|
|
1366
|
+
}
|
|
1367
|
+
async function submitActionInternal(input, internal = {}) {
|
|
1213
1368
|
const read = snapshotSubmission(input);
|
|
1214
|
-
|
|
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}`);
|
|
1215
1371
|
const action = actionResolver(read.actionId);
|
|
1216
1372
|
if (!action) throw new Error(`Unknown action: ${read.actionId}`);
|
|
1217
1373
|
const declaresAdmission = action.admitSubmission !== void 0;
|
|
@@ -1219,15 +1375,15 @@ function createGovernedActionHost(options) {
|
|
|
1219
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.`);
|
|
1220
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.`);
|
|
1221
1377
|
const authorizationInput = toAuthorizationInput(action, submission);
|
|
1222
|
-
if (!await options.authorization.checkEntitlement(authorizationInput)) throw new
|
|
1223
|
-
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}`);
|
|
1224
1380
|
if (declaresAdmission && (!submission.parametersArePrivate || !isRecord(submission.parameters))) refuseAdmission(action, submission, "invalid_parameters");
|
|
1225
1381
|
const actionInvocationId = (0, _fabricorg_platform.createFabricId)("act");
|
|
1226
1382
|
if (submission.correlationId && submission.provenance?.correlationId && submission.correlationId !== submission.provenance.correlationId) throw new Error("Invocation provenance correlationId does not match submission correlationId.");
|
|
1227
1383
|
if (submission.causationId && submission.provenance?.causationId && submission.causationId !== submission.provenance.causationId) throw new Error("Invocation provenance causationId does not match submission causationId.");
|
|
1228
1384
|
const correlationId = submission.correlationId ?? submission.provenance?.correlationId ?? (0, _fabricorg_platform.createFabricId)("corr");
|
|
1229
1385
|
const prepare = () => {
|
|
1230
|
-
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;
|
|
1231
1387
|
const parameterDigest = digestParameters(submission.parameters);
|
|
1232
1388
|
if (submission.authorizationBinding) validateAuthorizationBinding(submission, parameterDigest, action.execution?.authorityMoment, now());
|
|
1233
1389
|
return {
|
|
@@ -1248,7 +1404,7 @@ function createGovernedActionHost(options) {
|
|
|
1248
1404
|
(0, _fabricorg_platform.assertGovernanceRuntimeEvidence)(runtimeEvidence);
|
|
1249
1405
|
const admission = declaresAdmission ? await admitSubmission(action, submission) : void 0;
|
|
1250
1406
|
const { durableParameters, parameterDigest } = early ?? prepare();
|
|
1251
|
-
const
|
|
1407
|
+
const invocationInput = {
|
|
1252
1408
|
id: actionInvocationId,
|
|
1253
1409
|
tenantId: submission.tenantId,
|
|
1254
1410
|
spaceId: submission.spaceId,
|
|
@@ -1274,7 +1430,24 @@ function createGovernedActionHost(options) {
|
|
|
1274
1430
|
...submission.executionReason ? { executionReason: submission.executionReason } : {},
|
|
1275
1431
|
...submission.authorizationBindingId ? { authorizationBindingId: submission.authorizationBindingId } : {},
|
|
1276
1432
|
...admission ? { admittedAt: admission.admittedAt } : {}
|
|
1277
|
-
}
|
|
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);
|
|
1278
1451
|
emitPlatformHostTelemetry(options.telemetry, {
|
|
1279
1452
|
kind: "event",
|
|
1280
1453
|
name: "invocation.submitted",
|
|
@@ -1308,6 +1481,7 @@ function createGovernedActionHost(options) {
|
|
|
1308
1481
|
}
|
|
1309
1482
|
}
|
|
1310
1483
|
const durableWorkflowId = `action-invocation-${durableInvocation.id}`;
|
|
1484
|
+
if (internal.staged && durableInvocation.id !== actionInvocationId) return replayExistingStagedInvocation(durableInvocation);
|
|
1311
1485
|
if (durableInvocation.id !== actionInvocationId) {
|
|
1312
1486
|
if (options.dispatcher && durableInvocation.status === "pending") {
|
|
1313
1487
|
const dispatched = await options.dispatcher.dispatch({
|
|
@@ -1356,6 +1530,7 @@ function createGovernedActionHost(options) {
|
|
|
1356
1530
|
...durableInvocation.hitlRiskTier ? { hitlRiskTier: durableInvocation.hitlRiskTier } : {}
|
|
1357
1531
|
}, submission.actionId);
|
|
1358
1532
|
} catch (error) {
|
|
1533
|
+
if (internal.staged) throw new StagedDispatcherHandoffError("Staged child dispatcher handoff failed.");
|
|
1359
1534
|
throw error;
|
|
1360
1535
|
}
|
|
1361
1536
|
return {
|
|
@@ -1364,6 +1539,48 @@ function createGovernedActionHost(options) {
|
|
|
1364
1539
|
};
|
|
1365
1540
|
}
|
|
1366
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
|
+
/**
|
|
1367
1584
|
* Run the action's `admitSubmission` check on a copy of the submission
|
|
1368
1585
|
* snapshot's parameters, with the snapshot's scope and actor, bounded by
|
|
1369
1586
|
* `admissionTimeoutMs`. Returns when it was admitted; anything but
|
|
@@ -1870,6 +2087,13 @@ function createGovernedActionHost(options) {
|
|
|
1870
2087
|
try {
|
|
1871
2088
|
(0, _fabricorg_platform.assertAdapterStepDeadlines)(action);
|
|
1872
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
|
+
}
|
|
1873
2097
|
if (action.finalize) {
|
|
1874
2098
|
const store = asAtomicMutationStore(options.store);
|
|
1875
2099
|
if (!store) throw new Error("Action finalization requires atomic mutation transactions.");
|
|
@@ -2127,6 +2351,7 @@ function createGovernedActionHost(options) {
|
|
|
2127
2351
|
let finalizationInput;
|
|
2128
2352
|
let domainEvents = [];
|
|
2129
2353
|
let pendingTransitionEvent;
|
|
2354
|
+
let handlerStagedInvocations = replayCheckpoint?.stagedInvocations ?? [];
|
|
2130
2355
|
try {
|
|
2131
2356
|
if (replayCheckpoint) {
|
|
2132
2357
|
data = structuredClone(replayCheckpoint.result);
|
|
@@ -2221,6 +2446,24 @@ function createGovernedActionHost(options) {
|
|
|
2221
2446
|
};
|
|
2222
2447
|
if (!handlerResult.success) throw handlerResult.error != null ? new Error(handlerResult.error) : new HostError("Action handler failed");
|
|
2223
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
|
+
}
|
|
2224
2467
|
if (action.resultSchema && !action.finalize) {
|
|
2225
2468
|
const publicResult = withoutPrivateHostFields(handlerData, eventResultFields);
|
|
2226
2469
|
const parsedResult = action.resultSchema.safeParse(publicResult);
|
|
@@ -2271,7 +2514,8 @@ function createGovernedActionHost(options) {
|
|
|
2271
2514
|
handlerEvents: events,
|
|
2272
2515
|
result: preparedFinalizationInput.handlerData,
|
|
2273
2516
|
...transitionEvent ? { stateTransition: transitionEvent } : {},
|
|
2274
|
-
skipped
|
|
2517
|
+
skipped,
|
|
2518
|
+
...handlerStagedInvocations.length > 0 ? { stagedInvocations: handlerStagedInvocations } : {}
|
|
2275
2519
|
});
|
|
2276
2520
|
if (new TextEncoder().encode((0, _fabricorg_platform.canonicalJson)(preparation)).byteLength > 262144) throw new HostError("Finalization checkpoint exceeds 256 KiB.");
|
|
2277
2521
|
if (executionLease) {
|
|
@@ -3070,6 +3314,19 @@ function createGovernedActionHost(options) {
|
|
|
3070
3314
|
if (action.resultSchema && !action.resultSchema.safeParse(result).success) throw new Error("Finalization merged result does not match resultSchema.");
|
|
3071
3315
|
finalPatch.result = result;
|
|
3072
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
|
+
}
|
|
3073
3330
|
for (const [index, event] of domainEvents.entries()) await appendEvent(invocation, event, `domain:${index}`, action.version, transaction);
|
|
3074
3331
|
for (const [index, event] of (finalized?.events ?? []).entries()) await appendEvent(invocation, event, `finalize:${index}`, action.version, transaction);
|
|
3075
3332
|
if (pendingTransitionEvent) await appendEvent(invocation, pendingTransitionEvent, "state:transition", 1, transaction);
|
|
@@ -3104,6 +3361,21 @@ function createGovernedActionHost(options) {
|
|
|
3104
3361
|
await hostOperation(() => persistExecutionInvocation(invocation, finalPatch));
|
|
3105
3362
|
}
|
|
3106
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
|
+
}
|
|
3107
3379
|
if (action.finalize) {
|
|
3108
3380
|
if (error instanceof RecoverableFinalizationError) throw error;
|
|
3109
3381
|
if (!finalizationCallbackFailure || finalizationCallbackFailure.error !== error) throw new RecoverableFinalizationError(caughtError({
|
|
@@ -3169,7 +3441,9 @@ function createGovernedActionHost(options) {
|
|
|
3169
3441
|
const entitled = await options.authorization.checkEntitlement(approvalAuthorizationInput);
|
|
3170
3442
|
const authorized = options.authorization.authorizeApproval ? await options.authorization.authorizeApproval({
|
|
3171
3443
|
...approvalAuthorizationInput,
|
|
3172
|
-
|
|
3444
|
+
actionInvocationId,
|
|
3445
|
+
decision,
|
|
3446
|
+
invocation: structuredClone(invocation)
|
|
3173
3447
|
}) : await options.authorization.authorize(approvalAuthorizationInput);
|
|
3174
3448
|
if (!entitled || !authorized) return {
|
|
3175
3449
|
...actionResult(invocation),
|
|
@@ -3400,8 +3674,282 @@ function createGovernedActionHost(options) {
|
|
|
3400
3674
|
actionResult,
|
|
3401
3675
|
withConsistency
|
|
3402
3676
|
});
|
|
3677
|
+
async function dispatchStagedInvocations(input) {
|
|
3678
|
+
if (!stagedStore) throw new Error("This Platform Host store does not support staged descendant invocations.");
|
|
3679
|
+
const leaseDurationMs = input.leaseDurationMs ?? 3e4;
|
|
3680
|
+
const submissionTimeoutMs = input.submissionTimeoutMs ?? 1e4;
|
|
3681
|
+
const maxAttempts = input.maxAttempts ?? 10;
|
|
3682
|
+
for (const [name, value] of [
|
|
3683
|
+
["leaseDurationMs", leaseDurationMs],
|
|
3684
|
+
["submissionTimeoutMs", submissionTimeoutMs],
|
|
3685
|
+
["maxAttempts", maxAttempts]
|
|
3686
|
+
]) if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer.`);
|
|
3687
|
+
const cancelled = await stagedStore.cancelStagedInvocationsForFailedParents({
|
|
3688
|
+
...input.tenantId ? { tenantId: input.tenantId } : {},
|
|
3689
|
+
...input.spaceId ? { spaceId: input.spaceId } : {},
|
|
3690
|
+
now: now()
|
|
3691
|
+
});
|
|
3692
|
+
const records = await stagedStore.claimStagedInvocations({
|
|
3693
|
+
workerId: input.workerId,
|
|
3694
|
+
leaseDurationMs,
|
|
3695
|
+
limit: input.limit ?? 10,
|
|
3696
|
+
...input.tenantId ? { tenantId: input.tenantId } : {},
|
|
3697
|
+
...input.spaceId ? { spaceId: input.spaceId } : {},
|
|
3698
|
+
now: now()
|
|
3699
|
+
});
|
|
3700
|
+
const result = {
|
|
3701
|
+
claimed: records.length,
|
|
3702
|
+
dispatched: 0,
|
|
3703
|
+
rejected: 0,
|
|
3704
|
+
failed: 0,
|
|
3705
|
+
deadLettered: 0,
|
|
3706
|
+
cancelled
|
|
3707
|
+
};
|
|
3708
|
+
const sweep = {
|
|
3709
|
+
store: stagedStore,
|
|
3710
|
+
input,
|
|
3711
|
+
leaseDurationMs,
|
|
3712
|
+
submissionTimeoutMs,
|
|
3713
|
+
maxAttempts
|
|
3714
|
+
};
|
|
3715
|
+
for (const record of records) {
|
|
3716
|
+
let outcome;
|
|
3717
|
+
try {
|
|
3718
|
+
outcome = await dispatchStagedRow(record, sweep);
|
|
3719
|
+
} catch (error) {
|
|
3720
|
+
reportStagedError(input, error);
|
|
3721
|
+
outcome = "unsettled";
|
|
3722
|
+
}
|
|
3723
|
+
if (outcome === "dispatched") result.dispatched += 1;
|
|
3724
|
+
else if (outcome === "rejected") result.rejected += 1;
|
|
3725
|
+
else {
|
|
3726
|
+
result.failed += 1;
|
|
3727
|
+
if (outcome === "dead_lettered") result.deadLettered += 1;
|
|
3728
|
+
}
|
|
3729
|
+
}
|
|
3730
|
+
return result;
|
|
3731
|
+
}
|
|
3732
|
+
/**
|
|
3733
|
+
* Settle one claimed row through a single sequence:
|
|
3734
|
+
*
|
|
3735
|
+
* 1. Renew the claim's lease, then look the child up by staged origin. An
|
|
3736
|
+
* earlier attempt's creation still in flight holds an older lease token
|
|
3737
|
+
* and fails the creation fence, so this lookup is authoritative for
|
|
3738
|
+
* earlier attempts. A child found here goes to step 4: no
|
|
3739
|
+
* pre-submission checks, no governance.
|
|
3740
|
+
* 2. No child: pre-submission checks and the shared submission pipeline.
|
|
3741
|
+
* 3. Unless this attempt's own create returned the child, rotate the lease
|
|
3742
|
+
* token. Only this attempt's own timed-out submission could still create
|
|
3743
|
+
* the child, it holds the old token, and the store orders the rotation
|
|
3744
|
+
* after any create the fence already admitted. The lookup that follows is
|
|
3745
|
+
* authoritative; a child found there goes to step 4, otherwise the
|
|
3746
|
+
* outcome is classified as rejected, retry or dead letter.
|
|
3747
|
+
* 4. A known child that is pending is handed to the configured dispatcher
|
|
3748
|
+
* again under its stable workflow id; the stage settles with its id.
|
|
3749
|
+
*
|
|
3750
|
+
* Each settlement is one compare-and-set on the current owner and token. A
|
|
3751
|
+
* lookup that fails settles the row for retry, never terminally.
|
|
3752
|
+
*/
|
|
3753
|
+
async function dispatchStagedRow(record, sweep) {
|
|
3754
|
+
const { store, input, leaseDurationMs, maxAttempts } = sweep;
|
|
3755
|
+
const claimLease = {
|
|
3756
|
+
id: record.id,
|
|
3757
|
+
workerId: input.workerId,
|
|
3758
|
+
leaseToken: record.leaseToken
|
|
3759
|
+
};
|
|
3760
|
+
if (!await store.renewStagedInvocationLease({
|
|
3761
|
+
...claimLease,
|
|
3762
|
+
leaseDurationMs,
|
|
3763
|
+
now: now()
|
|
3764
|
+
})) return "unsettled";
|
|
3765
|
+
const prior = await lookUpStagedChild(record, sweep);
|
|
3766
|
+
if (prior.kind === "unreadable") return settleStagedRetry(record, claimLease, sweep, {
|
|
3767
|
+
error: STAGED_ERROR.lookup,
|
|
3768
|
+
terminal: false
|
|
3769
|
+
});
|
|
3770
|
+
if (prior.kind === "found") return settleExistingStagedChild(record, claimLease, prior.child, sweep);
|
|
3771
|
+
const known = {};
|
|
3772
|
+
let failure;
|
|
3773
|
+
let submitted = false;
|
|
3774
|
+
const stopHeartbeat = startStagedHeartbeat(store, claimLease, input, leaseDurationMs);
|
|
3775
|
+
try {
|
|
3776
|
+
await withSubmissionTimeout(submitStagedChild(record, claimLease, (child) => {
|
|
3777
|
+
known.child = child;
|
|
3778
|
+
}), sweep.submissionTimeoutMs);
|
|
3779
|
+
submitted = true;
|
|
3780
|
+
} catch (error) {
|
|
3781
|
+
failure = error;
|
|
3782
|
+
} finally {
|
|
3783
|
+
await stopHeartbeat();
|
|
3784
|
+
}
|
|
3785
|
+
const createdChild = known.child;
|
|
3786
|
+
if (createdChild) {
|
|
3787
|
+
if (submitted || !options.dispatcher) return settleStagedDispatched(claimLease, createdChild.id, sweep);
|
|
3788
|
+
return settleStagedRetry(record, claimLease, sweep, {
|
|
3789
|
+
error: STAGED_ERROR.handoff,
|
|
3790
|
+
childInvocationId: createdChild.id,
|
|
3791
|
+
terminal: record.attemptCount >= maxAttempts
|
|
3792
|
+
});
|
|
3793
|
+
}
|
|
3794
|
+
const rotatedToken = await store.rotateStagedInvocationLease({
|
|
3795
|
+
...claimLease,
|
|
3796
|
+
leaseDurationMs,
|
|
3797
|
+
now: now()
|
|
3798
|
+
});
|
|
3799
|
+
if (rotatedToken === void 0) return "unsettled";
|
|
3800
|
+
const lease = {
|
|
3801
|
+
...claimLease,
|
|
3802
|
+
leaseToken: rotatedToken
|
|
3803
|
+
};
|
|
3804
|
+
const after = await lookUpStagedChild(record, sweep);
|
|
3805
|
+
if (after.kind === "unreadable") return settleStagedRetry(record, lease, sweep, {
|
|
3806
|
+
error: STAGED_ERROR.lookup,
|
|
3807
|
+
terminal: false
|
|
3808
|
+
});
|
|
3809
|
+
if (after.kind === "found") return settleExistingStagedChild(record, lease, after.child, sweep);
|
|
3810
|
+
if (isDefinitiveStagedRefusal(failure)) return await store.markStagedInvocationRejected({
|
|
3811
|
+
...lease,
|
|
3812
|
+
error: STAGED_ERROR.refused,
|
|
3813
|
+
now: now()
|
|
3814
|
+
}) ? "rejected" : "unsettled";
|
|
3815
|
+
return settleStagedRetry(record, lease, sweep, {
|
|
3816
|
+
error: STAGED_ERROR.dispatch,
|
|
3817
|
+
terminal: record.attemptCount >= maxAttempts
|
|
3818
|
+
});
|
|
3819
|
+
}
|
|
3820
|
+
/** Pre-submission checks, then the shared submission pipeline under the claim's lease. */
|
|
3821
|
+
async function submitStagedChild(record, lease, onChild) {
|
|
3822
|
+
const parent = await options.store.getActionInvocation(record.parentInvocationId, record.tenantId, record.spaceId);
|
|
3823
|
+
if (!parent || parent.status !== "completed") throw new Error("Staged parent is not completed.");
|
|
3824
|
+
const child = actionResolver(record.actionId);
|
|
3825
|
+
if (!child) throw new StagedDispatchRejectionError("Staged action is no longer registered.");
|
|
3826
|
+
if (child.execution?.authorityMoment === "capture") throw new StagedDispatchRejectionError("Staged action now requires capture-time authority.");
|
|
3827
|
+
if (!child.schema.safeParse(structuredClone(record.parameters)).success) throw new StagedDispatchRejectionError("Staged parameters no longer match the registered action.");
|
|
3828
|
+
return submitActionInternal({
|
|
3829
|
+
actionId: record.actionId,
|
|
3830
|
+
parameters: structuredClone(record.parameters),
|
|
3831
|
+
tenantId: parent.tenantId,
|
|
3832
|
+
spaceId: parent.spaceId,
|
|
3833
|
+
actorId: parent.actorId,
|
|
3834
|
+
actorType: parent.actorType,
|
|
3835
|
+
correlationId: parent.correlationId,
|
|
3836
|
+
causationId: parent.id,
|
|
3837
|
+
provenance: {
|
|
3838
|
+
source: "worker",
|
|
3839
|
+
correlationId: parent.correlationId,
|
|
3840
|
+
causationId: parent.id
|
|
3841
|
+
},
|
|
3842
|
+
executionReason: "staged_dispatch"
|
|
3843
|
+
}, {
|
|
3844
|
+
staged: {
|
|
3845
|
+
id: record.id,
|
|
3846
|
+
origin: {
|
|
3847
|
+
parentInvocationId: parent.id,
|
|
3848
|
+
stageKey: record.key
|
|
3849
|
+
},
|
|
3850
|
+
workerId: lease.workerId,
|
|
3851
|
+
leaseToken: lease.leaseToken,
|
|
3852
|
+
onChild
|
|
3853
|
+
},
|
|
3854
|
+
parametersAlreadyRedacted: true
|
|
3855
|
+
});
|
|
3856
|
+
}
|
|
3857
|
+
async function lookUpStagedChild(record, sweep) {
|
|
3858
|
+
try {
|
|
3859
|
+
const child = await sweep.store.getActionInvocationByStagedOrigin(record.tenantId, record.spaceId, {
|
|
3860
|
+
parentInvocationId: record.parentInvocationId,
|
|
3861
|
+
stageKey: record.key
|
|
3862
|
+
});
|
|
3863
|
+
return child ? {
|
|
3864
|
+
kind: "found",
|
|
3865
|
+
child
|
|
3866
|
+
} : { kind: "absent" };
|
|
3867
|
+
} catch (error) {
|
|
3868
|
+
reportStagedError(sweep.input, error);
|
|
3869
|
+
return { kind: "unreadable" };
|
|
3870
|
+
}
|
|
3871
|
+
}
|
|
3872
|
+
async function settleExistingStagedChild(record, lease, child, sweep) {
|
|
3873
|
+
if (options.dispatcher && child.status === "pending") {
|
|
3874
|
+
const stopHeartbeat = startStagedHeartbeat(sweep.store, lease, sweep.input, sweep.leaseDurationMs);
|
|
3875
|
+
let handedOff = false;
|
|
3876
|
+
try {
|
|
3877
|
+
await withSubmissionTimeout(handOffExistingStagedChild(child), sweep.submissionTimeoutMs);
|
|
3878
|
+
handedOff = true;
|
|
3879
|
+
} catch {} finally {
|
|
3880
|
+
await stopHeartbeat();
|
|
3881
|
+
}
|
|
3882
|
+
if (!handedOff) return settleStagedRetry(record, lease, sweep, {
|
|
3883
|
+
error: STAGED_ERROR.handoff,
|
|
3884
|
+
childInvocationId: child.id,
|
|
3885
|
+
terminal: record.attemptCount >= sweep.maxAttempts
|
|
3886
|
+
});
|
|
3887
|
+
}
|
|
3888
|
+
return settleStagedDispatched(lease, child.id, sweep);
|
|
3889
|
+
}
|
|
3890
|
+
async function settleStagedDispatched(lease, childInvocationId, sweep) {
|
|
3891
|
+
return await sweep.store.markStagedInvocationDispatched({
|
|
3892
|
+
...lease,
|
|
3893
|
+
childInvocationId,
|
|
3894
|
+
now: now()
|
|
3895
|
+
}) ? "dispatched" : "unsettled";
|
|
3896
|
+
}
|
|
3897
|
+
async function settleStagedRetry(record, lease, sweep, settlement) {
|
|
3898
|
+
if (!await sweep.store.markStagedInvocationFailed({
|
|
3899
|
+
...lease,
|
|
3900
|
+
error: settlement.error,
|
|
3901
|
+
availableAt: new Date(now().getTime() + (sweep.input.retryDelayMs?.(record.attemptCount) ?? 1e3)),
|
|
3902
|
+
deadLetter: settlement.terminal,
|
|
3903
|
+
...settlement.childInvocationId ? { childInvocationId: settlement.childInvocationId } : {},
|
|
3904
|
+
now: now()
|
|
3905
|
+
})) return "unsettled";
|
|
3906
|
+
return settlement.terminal ? "dead_lettered" : "retry";
|
|
3907
|
+
}
|
|
3908
|
+
function isDefinitiveStagedRefusal(error) {
|
|
3909
|
+
return error instanceof SubmissionRefusalError || error instanceof StagedDispatchRejectionError || error instanceof ActionAdmissionError && (error.refusal === "refused" || error.refusal === "invalid_parameters");
|
|
3910
|
+
}
|
|
3911
|
+
function reportStagedError(input, error) {
|
|
3912
|
+
try {
|
|
3913
|
+
input.onError?.(error);
|
|
3914
|
+
} catch {}
|
|
3915
|
+
}
|
|
3916
|
+
/** Renew one lease token while a slow step runs; the returned stop awaits a renewal in flight. */
|
|
3917
|
+
function startStagedHeartbeat(store, lease, input, leaseDurationMs) {
|
|
3918
|
+
const intervalMs = input.leaseRenewalIntervalMs ?? Math.max(1, Math.floor(leaseDurationMs / 3));
|
|
3919
|
+
let stopped = false;
|
|
3920
|
+
let timer;
|
|
3921
|
+
let running;
|
|
3922
|
+
const schedule = () => {
|
|
3923
|
+
if (!stopped) timer = setTimeout(tick, intervalMs);
|
|
3924
|
+
};
|
|
3925
|
+
const tick = () => {
|
|
3926
|
+
running = store.renewStagedInvocationLease({
|
|
3927
|
+
...lease,
|
|
3928
|
+
leaseDurationMs,
|
|
3929
|
+
now: now()
|
|
3930
|
+
}).then(() => void 0).catch(() => void 0).finally(schedule);
|
|
3931
|
+
};
|
|
3932
|
+
schedule();
|
|
3933
|
+
return async () => {
|
|
3934
|
+
stopped = true;
|
|
3935
|
+
if (timer) clearTimeout(timer);
|
|
3936
|
+
await running;
|
|
3937
|
+
};
|
|
3938
|
+
}
|
|
3939
|
+
async function withSubmissionTimeout(submission, timeoutMs) {
|
|
3940
|
+
let timer;
|
|
3941
|
+
try {
|
|
3942
|
+
return await Promise.race([submission, new Promise((_, reject) => {
|
|
3943
|
+
timer = setTimeout(() => reject(new StagedSubmissionTimeoutError("Staged invocation submission timed out.")), timeoutMs);
|
|
3944
|
+
})]);
|
|
3945
|
+
} finally {
|
|
3946
|
+
if (timer) clearTimeout(timer);
|
|
3947
|
+
}
|
|
3948
|
+
}
|
|
3403
3949
|
return {
|
|
3404
3950
|
submitAction,
|
|
3951
|
+
hasStagedInvocationActions: declaresStaging,
|
|
3952
|
+
...stagedStore ? { dispatchStagedInvocations } : {},
|
|
3405
3953
|
executeInvocation,
|
|
3406
3954
|
resumeApprovedInvocation,
|
|
3407
3955
|
recordExecutionAttestation,
|
|
@@ -3731,6 +4279,7 @@ function emitRelayTelemetry(telemetry, name, occurredAt, workerId, attributes) {
|
|
|
3731
4279
|
var MemoryPlatformHostStore = class {
|
|
3732
4280
|
db;
|
|
3733
4281
|
transactionalOutbox;
|
|
4282
|
+
transactionalStagedInvocations;
|
|
3734
4283
|
adapterCommit;
|
|
3735
4284
|
transactionTail = Promise.resolve();
|
|
3736
4285
|
invocations = [];
|
|
@@ -3741,6 +4290,7 @@ var MemoryPlatformHostStore = class {
|
|
|
3741
4290
|
executionAttestations = [];
|
|
3742
4291
|
externalReconciliations = [];
|
|
3743
4292
|
outbox = [];
|
|
4293
|
+
stagedInvocations = [];
|
|
3744
4294
|
/**
|
|
3745
4295
|
* Requeue evidence is append-only audit history, so unlike the ledger
|
|
3746
4296
|
* arrays above, which tests have always inspected and seeded directly, it
|
|
@@ -3751,6 +4301,7 @@ var MemoryPlatformHostStore = class {
|
|
|
3751
4301
|
constructor(db, transactionProvider) {
|
|
3752
4302
|
this.db = db;
|
|
3753
4303
|
this.transactionalOutbox = transactionProvider !== void 0;
|
|
4304
|
+
this.transactionalStagedInvocations = transactionProvider !== void 0;
|
|
3754
4305
|
if (transactionProvider) this.transactionWithEvents = (run) => this.runTransactionWithEvents(run, transactionProvider);
|
|
3755
4306
|
}
|
|
3756
4307
|
async transaction(run) {
|
|
@@ -3776,6 +4327,7 @@ var MemoryPlatformHostStore = class {
|
|
|
3776
4327
|
const conditionalAdapters = [];
|
|
3777
4328
|
const leasedUpdates = [];
|
|
3778
4329
|
const pendingReconciliations = [];
|
|
4330
|
+
const pendingStagedBatches = [];
|
|
3779
4331
|
const appendPending = async (event, metadata) => {
|
|
3780
4332
|
if (this.events.some((candidate) => candidate.id === event.id) || pendingEvents.some((candidate) => candidate.event.id === event.id)) return;
|
|
3781
4333
|
pendingEvents.push({
|
|
@@ -3832,6 +4384,9 @@ var MemoryPlatformHostStore = class {
|
|
|
3832
4384
|
appendExternalReconciliation: async (record) => {
|
|
3833
4385
|
pendingReconciliations.push(record);
|
|
3834
4386
|
},
|
|
4387
|
+
stageInvocations: async (records) => {
|
|
4388
|
+
pendingStagedBatches.push(records.map(cloneStagedInvocationRecord));
|
|
4389
|
+
},
|
|
3835
4390
|
updateActionInvocation: async (id, tenantId, spaceId, patch) => {
|
|
3836
4391
|
pendingUpdates.push({
|
|
3837
4392
|
id,
|
|
@@ -3885,6 +4440,15 @@ var MemoryPlatformHostStore = class {
|
|
|
3885
4440
|
rememberAppend(this.externalReconciliations, record.id);
|
|
3886
4441
|
await this.appendExternalReconciliation(record);
|
|
3887
4442
|
}
|
|
4443
|
+
for (const records of pendingStagedBatches) {
|
|
4444
|
+
const appended = this.applyStagedBatch(records);
|
|
4445
|
+
if (appended.length > 0) undoLedgerWrites.push(() => {
|
|
4446
|
+
for (const id of appended) {
|
|
4447
|
+
const index = this.stagedInvocations.findIndex((record) => record.id === id);
|
|
4448
|
+
if (index !== -1) this.stagedInvocations.splice(index, 1);
|
|
4449
|
+
}
|
|
4450
|
+
});
|
|
4451
|
+
}
|
|
3888
4452
|
for (const [id, patch, scope] of pendingAdapterUpdates) {
|
|
3889
4453
|
const record = await this.getAdapterInvocation(id, scope);
|
|
3890
4454
|
if (!record) throw new Error(`AdapterInvocation not found: ${id}`);
|
|
@@ -3935,9 +4499,50 @@ var MemoryPlatformHostStore = class {
|
|
|
3935
4499
|
this.invocations.push(record);
|
|
3936
4500
|
return record;
|
|
3937
4501
|
}
|
|
4502
|
+
async createActionInvocationForStagedLease(input) {
|
|
4503
|
+
const { invocation, now = /* @__PURE__ */ new Date() } = input;
|
|
4504
|
+
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);
|
|
4505
|
+
if (existing) return {
|
|
4506
|
+
kind: "existing",
|
|
4507
|
+
invocation: existing
|
|
4508
|
+
};
|
|
4509
|
+
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" };
|
|
4510
|
+
const createdAt = /* @__PURE__ */ new Date();
|
|
4511
|
+
const record = {
|
|
4512
|
+
...invocation,
|
|
4513
|
+
attemptCount: 0,
|
|
4514
|
+
leaseToken: 0,
|
|
4515
|
+
createdAt,
|
|
4516
|
+
updatedAt: createdAt
|
|
4517
|
+
};
|
|
4518
|
+
this.invocations.push(record);
|
|
4519
|
+
return {
|
|
4520
|
+
kind: "created",
|
|
4521
|
+
invocation: record
|
|
4522
|
+
};
|
|
4523
|
+
}
|
|
4524
|
+
applyStagedBatch(records) {
|
|
4525
|
+
if (records.length === 0) return [];
|
|
4526
|
+
const first = records[0];
|
|
4527
|
+
const existing = this.stagedInvocations.filter((record) => record.tenantId === first.tenantId && record.spaceId === first.spaceId && record.parentInvocationId === first.parentInvocationId);
|
|
4528
|
+
if (existing.length > 0) {
|
|
4529
|
+
if (!(existing.length === records.length && records.every((record) => {
|
|
4530
|
+
const stored = existing.find((candidate) => candidate.key === record.key);
|
|
4531
|
+
return stored !== void 0 && stored.batchDigest === record.batchDigest && stagedInvocationRequestIdentity(stored) === stagedInvocationRequestIdentity(record);
|
|
4532
|
+
}))) throw new _fabricorg_platform.ReconciliationRequiredError(`Staged invocation batch drift for parent ${first.parentInvocationId}.`);
|
|
4533
|
+
return [];
|
|
4534
|
+
}
|
|
4535
|
+
const keys = records.map((record) => record.key);
|
|
4536
|
+
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}.`);
|
|
4537
|
+
for (const record of records) this.stagedInvocations.push(cloneStagedInvocationRecord(record));
|
|
4538
|
+
return records.map((record) => record.id);
|
|
4539
|
+
}
|
|
3938
4540
|
async getActionInvocation(id, tenantId, spaceId) {
|
|
3939
4541
|
return this.invocations.find((record) => record.id === id && record.tenantId === tenantId && record.spaceId === spaceId);
|
|
3940
4542
|
}
|
|
4543
|
+
async getActionInvocationByStagedOrigin(tenantId, spaceId, origin) {
|
|
4544
|
+
return this.invocations.find((record) => record.tenantId === tenantId && record.spaceId === spaceId && record.stagedOrigin?.parentInvocationId === origin.parentInvocationId && record.stagedOrigin.stageKey === origin.stageKey);
|
|
4545
|
+
}
|
|
3941
4546
|
async updateActionInvocation(id, tenantId, spaceId, patch) {
|
|
3942
4547
|
const record = this.invocations.find((row) => row.id === id && row.tenantId === tenantId && row.spaceId === spaceId);
|
|
3943
4548
|
if (!record) throw new Error(`ActionInvocation not found: ${id}`);
|
|
@@ -4267,6 +4872,96 @@ var MemoryPlatformHostStore = class {
|
|
|
4267
4872
|
record.updatedAt = current;
|
|
4268
4873
|
return true;
|
|
4269
4874
|
}
|
|
4875
|
+
async claimStagedInvocations(input) {
|
|
4876
|
+
while (this.adapterCommit) await this.adapterCommit;
|
|
4877
|
+
const current = input.now ?? /* @__PURE__ */ new Date();
|
|
4878
|
+
const eligible = this.stagedInvocations.filter((record) => {
|
|
4879
|
+
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);
|
|
4880
|
+
}).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)));
|
|
4881
|
+
for (const record of eligible) {
|
|
4882
|
+
record.leaseOwner = input.workerId;
|
|
4883
|
+
record.leaseExpiresAt = new Date(current.getTime() + input.leaseDurationMs);
|
|
4884
|
+
record.leaseToken += 1;
|
|
4885
|
+
record.attemptCount += 1;
|
|
4886
|
+
record.updatedAt = current;
|
|
4887
|
+
}
|
|
4888
|
+
return eligible.map(cloneStagedInvocationRecord);
|
|
4889
|
+
}
|
|
4890
|
+
async markStagedInvocationDispatched(input) {
|
|
4891
|
+
return this.settleStagedInvocation(input, "dispatched", input.childInvocationId, void 0);
|
|
4892
|
+
}
|
|
4893
|
+
async markStagedInvocationRejected(input) {
|
|
4894
|
+
return this.settleStagedInvocation(input, "rejected", void 0, input.error);
|
|
4895
|
+
}
|
|
4896
|
+
async markStagedInvocationFailed(input) {
|
|
4897
|
+
const record = this.leasedStagedInvocation(input.id, input.workerId, input.leaseToken);
|
|
4898
|
+
if (!record) return false;
|
|
4899
|
+
record.status = input.deadLetter ? "dead_lettered" : "pending";
|
|
4900
|
+
record.lastError = input.error;
|
|
4901
|
+
if (input.childInvocationId) record.childInvocationId = input.childInvocationId;
|
|
4902
|
+
record.availableAt = new Date(input.availableAt);
|
|
4903
|
+
record.updatedAt = input.now ?? /* @__PURE__ */ new Date();
|
|
4904
|
+
delete record.leaseOwner;
|
|
4905
|
+
delete record.leaseExpiresAt;
|
|
4906
|
+
return true;
|
|
4907
|
+
}
|
|
4908
|
+
async renewStagedInvocationLease(input) {
|
|
4909
|
+
const current = input.now ?? /* @__PURE__ */ new Date();
|
|
4910
|
+
const record = this.leasedStagedInvocation(input.id, input.workerId, input.leaseToken);
|
|
4911
|
+
if (!record || !record.leaseExpiresAt || record.leaseExpiresAt <= current) return false;
|
|
4912
|
+
record.leaseExpiresAt = new Date(current.getTime() + input.leaseDurationMs);
|
|
4913
|
+
record.updatedAt = current;
|
|
4914
|
+
return true;
|
|
4915
|
+
}
|
|
4916
|
+
/** Await-free, like the fenced create above, so a create runs wholly before or after it. */
|
|
4917
|
+
async rotateStagedInvocationLease(input) {
|
|
4918
|
+
const current = input.now ?? /* @__PURE__ */ new Date();
|
|
4919
|
+
const record = this.leasedStagedInvocation(input.id, input.workerId, input.leaseToken);
|
|
4920
|
+
if (!record || !record.leaseExpiresAt || record.leaseExpiresAt <= current) return void 0;
|
|
4921
|
+
record.leaseToken += 1;
|
|
4922
|
+
record.leaseExpiresAt = new Date(current.getTime() + input.leaseDurationMs);
|
|
4923
|
+
record.updatedAt = current;
|
|
4924
|
+
return record.leaseToken;
|
|
4925
|
+
}
|
|
4926
|
+
async cancelStagedInvocationsForFailedParents(input = {}) {
|
|
4927
|
+
const current = input.now ?? /* @__PURE__ */ new Date();
|
|
4928
|
+
let cancelled = 0;
|
|
4929
|
+
for (const record of this.stagedInvocations) {
|
|
4930
|
+
if (record.status !== "pending" || input.tenantId && record.tenantId !== input.tenantId || input.spaceId && record.spaceId !== input.spaceId) continue;
|
|
4931
|
+
const parent = this.invocations.find((candidate) => candidate.id === record.parentInvocationId && candidate.tenantId === record.tenantId && candidate.spaceId === record.spaceId);
|
|
4932
|
+
if (!parent || ![
|
|
4933
|
+
"failed",
|
|
4934
|
+
"blocked_by_policy",
|
|
4935
|
+
"validation_failed"
|
|
4936
|
+
].includes(parent.status)) continue;
|
|
4937
|
+
record.status = "cancelled";
|
|
4938
|
+
record.lastError = `Parent invocation ended with ${parent.status}.`;
|
|
4939
|
+
record.updatedAt = current;
|
|
4940
|
+
delete record.leaseOwner;
|
|
4941
|
+
delete record.leaseExpiresAt;
|
|
4942
|
+
cancelled += 1;
|
|
4943
|
+
}
|
|
4944
|
+
return cancelled;
|
|
4945
|
+
}
|
|
4946
|
+
async listStagedInvocations(input) {
|
|
4947
|
+
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);
|
|
4948
|
+
}
|
|
4949
|
+
leasedStagedInvocation(id, workerId, leaseToken) {
|
|
4950
|
+
return this.stagedInvocations.find((record) => record.id === id && record.status === "pending" && record.leaseOwner === workerId && record.leaseToken === leaseToken);
|
|
4951
|
+
}
|
|
4952
|
+
/** Mirrors PostgreSQL: a missing child id keeps the recorded one; `lastError` is replaced. */
|
|
4953
|
+
settleStagedInvocation(input, status, childInvocationId, lastError) {
|
|
4954
|
+
const record = this.leasedStagedInvocation(input.id, input.workerId, input.leaseToken);
|
|
4955
|
+
if (!record) return false;
|
|
4956
|
+
record.status = status;
|
|
4957
|
+
if (childInvocationId !== void 0) record.childInvocationId = childInvocationId;
|
|
4958
|
+
if (lastError !== void 0) record.lastError = lastError;
|
|
4959
|
+
else delete record.lastError;
|
|
4960
|
+
record.updatedAt = input.now ?? /* @__PURE__ */ new Date();
|
|
4961
|
+
delete record.leaseOwner;
|
|
4962
|
+
delete record.leaseExpiresAt;
|
|
4963
|
+
return true;
|
|
4964
|
+
}
|
|
4270
4965
|
async listEvents(tenantId, spaceId) {
|
|
4271
4966
|
return this.events.filter((event) => event.tenantId === tenantId && event.spaceId === spaceId).sort((left, right) => left.sequence - right.sequence);
|
|
4272
4967
|
}
|
|
@@ -4360,10 +5055,12 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
4360
5055
|
sql;
|
|
4361
5056
|
transactionWithEvents;
|
|
4362
5057
|
transactionalOutbox;
|
|
5058
|
+
transactionalStagedInvocations;
|
|
4363
5059
|
constructor(db, sql, transactionProvider) {
|
|
4364
5060
|
this.db = db;
|
|
4365
5061
|
this.sql = sql;
|
|
4366
5062
|
this.transactionalOutbox = transactionProvider !== void 0;
|
|
5063
|
+
this.transactionalStagedInvocations = transactionProvider !== void 0;
|
|
4367
5064
|
if (transactionProvider) this.transactionWithEvents = async (run) => transactionProvider.run(async ({ db, sql }) => {
|
|
4368
5065
|
const scoped = new PostgresPlatformHostStore(db, sql);
|
|
4369
5066
|
return run({
|
|
@@ -4389,7 +5086,8 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
4389
5086
|
updateLeasedActionInvocation: (input) => scoped.updateLeasedActionInvocation(input),
|
|
4390
5087
|
updateAdapterInvocation: (id, patch, scope) => scoped.updateAdapterInvocation(id, patch, scope),
|
|
4391
5088
|
writeAdapterInvocation: (input) => scoped.writeAdapterInvocation(input),
|
|
4392
|
-
appendExternalReconciliation: (record) => scoped.appendExternalReconciliation(record)
|
|
5089
|
+
appendExternalReconciliation: (record) => scoped.appendExternalReconciliation(record),
|
|
5090
|
+
stageInvocations: (records) => scoped.stageInvocations(records)
|
|
4393
5091
|
});
|
|
4394
5092
|
});
|
|
4395
5093
|
}
|
|
@@ -4593,6 +5291,38 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
4593
5291
|
version: 10,
|
|
4594
5292
|
name: "finalization_preparation_checkpoint",
|
|
4595
5293
|
sql: `ALTER TABLE fabric_platform.action_invocations ADD COLUMN IF NOT EXISTS finalization_checkpoint jsonb;`
|
|
5294
|
+
},
|
|
5295
|
+
{
|
|
5296
|
+
version: 11,
|
|
5297
|
+
name: "staged_descendant_invocations",
|
|
5298
|
+
sql: `
|
|
5299
|
+
ALTER TABLE fabric_platform.action_invocations
|
|
5300
|
+
ADD COLUMN IF NOT EXISTS staged_parent_invocation_id text,
|
|
5301
|
+
ADD COLUMN IF NOT EXISTS staged_stage_key text;
|
|
5302
|
+
CREATE UNIQUE INDEX IF NOT EXISTS action_invocations_staged_origin_idx
|
|
5303
|
+
ON fabric_platform.action_invocations
|
|
5304
|
+
(tenant_id, space_id, staged_parent_invocation_id, staged_stage_key)
|
|
5305
|
+
WHERE staged_parent_invocation_id IS NOT NULL AND staged_stage_key IS NOT NULL;
|
|
5306
|
+
CREATE TABLE IF NOT EXISTS fabric_platform.staged_invocations (
|
|
5307
|
+
id text PRIMARY KEY,
|
|
5308
|
+
tenant_id text NOT NULL, space_id text NOT NULL,
|
|
5309
|
+
parent_invocation_id text NOT NULL, parent_action_id text NOT NULL,
|
|
5310
|
+
stage_key text NOT NULL, action_id text NOT NULL,
|
|
5311
|
+
expected_action_version integer NOT NULL,
|
|
5312
|
+
parameters jsonb NOT NULL, parameter_digest text NOT NULL, batch_digest text NOT NULL,
|
|
5313
|
+
status text NOT NULL, child_invocation_id text,
|
|
5314
|
+
attempt_count integer NOT NULL DEFAULT 0,
|
|
5315
|
+
available_at timestamptz NOT NULL,
|
|
5316
|
+
lease_owner text, lease_expires_at timestamptz,
|
|
5317
|
+
lease_token bigint NOT NULL DEFAULT 0,
|
|
5318
|
+
last_error text, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
|
|
5319
|
+
UNIQUE (tenant_id, space_id, parent_invocation_id, stage_key)
|
|
5320
|
+
);
|
|
5321
|
+
CREATE INDEX IF NOT EXISTS staged_invocations_claim_idx
|
|
5322
|
+
ON fabric_platform.staged_invocations (status, available_at, lease_expires_at, created_at);
|
|
5323
|
+
CREATE INDEX IF NOT EXISTS staged_invocations_parent_idx
|
|
5324
|
+
ON fabric_platform.staged_invocations (tenant_id, space_id, parent_invocation_id, created_at);
|
|
5325
|
+
`
|
|
4596
5326
|
}
|
|
4597
5327
|
]);
|
|
4598
5328
|
}
|
|
@@ -4608,11 +5338,11 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
4608
5338
|
try {
|
|
4609
5339
|
result = await this.sql.query(`INSERT INTO fabric_platform.action_invocations
|
|
4610
5340
|
(id,tenant_id,space_id,action_id,action_version,actor_id,actor_type,status,
|
|
4611
|
-
|
|
4612
|
-
|
|
4613
|
-
|
|
4614
|
-
|
|
4615
|
-
|
|
5341
|
+
parameters,result,correlation_id,causation_id,idempotency_key,
|
|
5342
|
+
parameter_digest,parameter_digest_algorithm,idempotency_actor_id,
|
|
5343
|
+
idempotency_authorization_binding_id,invocation_provenance,authorization_binding,execution_reason,
|
|
5344
|
+
authorization_binding_id,error,runtime_evidence,${admittedColumn}created_at,updated_at)
|
|
5345
|
+
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)
|
|
4616
5346
|
ON CONFLICT (tenant_id,space_id,action_id,idempotency_key)
|
|
4617
5347
|
WHERE idempotency_key IS NOT NULL DO UPDATE SET id=fabric_platform.action_invocations.id
|
|
4618
5348
|
RETURNING *`, [
|
|
@@ -4648,6 +5378,140 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
4648
5378
|
}
|
|
4649
5379
|
return toActionRecord(result.rows[0]);
|
|
4650
5380
|
}
|
|
5381
|
+
async createActionInvocationForStagedLease(input) {
|
|
5382
|
+
const { invocation } = input;
|
|
5383
|
+
const current = input.now ?? /* @__PURE__ */ new Date();
|
|
5384
|
+
const admitted = invocation.admittedAt !== void 0;
|
|
5385
|
+
const admittedColumn = admitted ? ",admitted_at" : "";
|
|
5386
|
+
const admittedValue = admitted ? ",$30" : "";
|
|
5387
|
+
const values = [
|
|
5388
|
+
invocation.id,
|
|
5389
|
+
invocation.tenantId,
|
|
5390
|
+
invocation.spaceId,
|
|
5391
|
+
invocation.actionId,
|
|
5392
|
+
invocation.actionVersion,
|
|
5393
|
+
invocation.actorId,
|
|
5394
|
+
invocation.actorType,
|
|
5395
|
+
invocation.status,
|
|
5396
|
+
JSON.stringify(invocation.parameters),
|
|
5397
|
+
JSON.stringify(invocation.result),
|
|
5398
|
+
invocation.correlationId,
|
|
5399
|
+
invocation.causationId ?? null,
|
|
5400
|
+
invocation.idempotencyKey ?? null,
|
|
5401
|
+
invocation.parameterDigest ?? null,
|
|
5402
|
+
invocation.parameterDigestAlgorithm ?? null,
|
|
5403
|
+
invocation.idempotencyActorId ?? null,
|
|
5404
|
+
invocation.idempotencyAuthorizationBindingId ?? null,
|
|
5405
|
+
invocation.provenance ? JSON.stringify(invocation.provenance) : null,
|
|
5406
|
+
invocation.authorizationBinding ? JSON.stringify(invocation.authorizationBinding) : null,
|
|
5407
|
+
invocation.executionReason ?? null,
|
|
5408
|
+
invocation.authorizationBindingId ?? null,
|
|
5409
|
+
invocation.error ?? null,
|
|
5410
|
+
invocation.runtimeEvidence ? JSON.stringify(invocation.runtimeEvidence) : null,
|
|
5411
|
+
invocation.stagedOrigin.parentInvocationId,
|
|
5412
|
+
invocation.stagedOrigin.stageKey,
|
|
5413
|
+
input.stageId,
|
|
5414
|
+
input.workerId,
|
|
5415
|
+
input.leaseToken,
|
|
5416
|
+
current,
|
|
5417
|
+
...admitted ? [invocation.admittedAt] : []
|
|
5418
|
+
];
|
|
5419
|
+
let result;
|
|
5420
|
+
try {
|
|
5421
|
+
result = await this.sql.query(`WITH fence AS (
|
|
5422
|
+
SELECT stage.id
|
|
5423
|
+
FROM fabric_platform.staged_invocations AS stage
|
|
5424
|
+
WHERE stage.id=$26 AND stage.tenant_id=$2 AND stage.space_id=$3
|
|
5425
|
+
AND stage.parent_invocation_id=$24 AND stage.stage_key=$25 AND stage.action_id=$4
|
|
5426
|
+
AND stage.status='pending' AND stage.lease_owner=$27 AND stage.lease_token=$28
|
|
5427
|
+
AND stage.lease_expires_at IS NOT NULL AND stage.lease_expires_at > $29
|
|
5428
|
+
FOR SHARE
|
|
5429
|
+
)
|
|
5430
|
+
INSERT INTO fabric_platform.action_invocations
|
|
5431
|
+
(id,tenant_id,space_id,action_id,action_version,actor_id,actor_type,status,
|
|
5432
|
+
parameters,result,correlation_id,causation_id,idempotency_key,parameter_digest,
|
|
5433
|
+
parameter_digest_algorithm,idempotency_actor_id,idempotency_authorization_binding_id,
|
|
5434
|
+
invocation_provenance,authorization_binding,execution_reason,authorization_binding_id,error,
|
|
5435
|
+
runtime_evidence,staged_parent_invocation_id,staged_stage_key${admittedColumn},created_at,updated_at)
|
|
5436
|
+
SELECT $1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10::jsonb,$11,$12,$13,$14,$15,$16,$17,
|
|
5437
|
+
$18::jsonb,$19::jsonb,$20,$21,$22,$23::jsonb,$24,$25${admittedValue},$29,$29
|
|
5438
|
+
FROM fence
|
|
5439
|
+
ON CONFLICT (tenant_id,space_id,staged_parent_invocation_id,staged_stage_key)
|
|
5440
|
+
WHERE staged_parent_invocation_id IS NOT NULL AND staged_stage_key IS NOT NULL
|
|
5441
|
+
DO UPDATE SET id=fabric_platform.action_invocations.id
|
|
5442
|
+
RETURNING *`, values);
|
|
5443
|
+
} catch (error) {
|
|
5444
|
+
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 });
|
|
5445
|
+
throw error;
|
|
5446
|
+
}
|
|
5447
|
+
if (result.rows[0]) {
|
|
5448
|
+
const record = toActionRecord(result.rows[0]);
|
|
5449
|
+
return {
|
|
5450
|
+
kind: record.id === invocation.id ? "created" : "existing",
|
|
5451
|
+
invocation: record
|
|
5452
|
+
};
|
|
5453
|
+
}
|
|
5454
|
+
const existing = await this.getActionInvocationByStagedOrigin(invocation.tenantId, invocation.spaceId, invocation.stagedOrigin);
|
|
5455
|
+
return existing ? {
|
|
5456
|
+
kind: "existing",
|
|
5457
|
+
invocation: existing
|
|
5458
|
+
} : { kind: "lease_lost" };
|
|
5459
|
+
}
|
|
5460
|
+
async stageInvocations(records) {
|
|
5461
|
+
if (records.length === 0) return;
|
|
5462
|
+
const first = records[0];
|
|
5463
|
+
if (!(await this.sql.query(`SELECT id FROM fabric_platform.action_invocations
|
|
5464
|
+
WHERE id=$1 AND tenant_id=$2 AND space_id=$3 FOR UPDATE`, [
|
|
5465
|
+
first.parentInvocationId,
|
|
5466
|
+
first.tenantId,
|
|
5467
|
+
first.spaceId
|
|
5468
|
+
])).rows[0]) throw new Error(`ActionInvocation not found: ${first.parentInvocationId}`);
|
|
5469
|
+
const current = await this.sql.query(`SELECT * FROM fabric_platform.staged_invocations
|
|
5470
|
+
WHERE tenant_id=$1 AND space_id=$2 AND parent_invocation_id=$3
|
|
5471
|
+
ORDER BY stage_key`, [
|
|
5472
|
+
first.tenantId,
|
|
5473
|
+
first.spaceId,
|
|
5474
|
+
first.parentInvocationId
|
|
5475
|
+
]);
|
|
5476
|
+
if (current.rows.length > 0) {
|
|
5477
|
+
const stored = current.rows.map(toStagedInvocationRecord);
|
|
5478
|
+
if (!(stored.length === records.length && records.every((record) => {
|
|
5479
|
+
const candidate = stored.find((item) => item.key === record.key);
|
|
5480
|
+
return candidate !== void 0 && candidate.batchDigest === record.batchDigest && stagedInvocationRequestIdentity(candidate) === stagedInvocationRequestIdentity(record);
|
|
5481
|
+
}))) throw new _fabricorg_platform.ReconciliationRequiredError(`Staged invocation batch drift for parent ${first.parentInvocationId}.`);
|
|
5482
|
+
return;
|
|
5483
|
+
}
|
|
5484
|
+
for (const record of records) {
|
|
5485
|
+
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}.`);
|
|
5486
|
+
await this.sql.query(`INSERT INTO fabric_platform.staged_invocations
|
|
5487
|
+
(id,tenant_id,space_id,parent_invocation_id,parent_action_id,stage_key,action_id,
|
|
5488
|
+
expected_action_version,parameters,parameter_digest,batch_digest,status,child_invocation_id,
|
|
5489
|
+
attempt_count,available_at,lease_owner,lease_expires_at,lease_token,last_error,created_at,updated_at)
|
|
5490
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)`, [
|
|
5491
|
+
record.id,
|
|
5492
|
+
record.tenantId,
|
|
5493
|
+
record.spaceId,
|
|
5494
|
+
record.parentInvocationId,
|
|
5495
|
+
record.parentActionId,
|
|
5496
|
+
record.key,
|
|
5497
|
+
record.actionId,
|
|
5498
|
+
record.expectedActionVersion,
|
|
5499
|
+
JSON.stringify(record.parameters),
|
|
5500
|
+
record.parameterDigest,
|
|
5501
|
+
record.batchDigest,
|
|
5502
|
+
record.status,
|
|
5503
|
+
record.childInvocationId ?? null,
|
|
5504
|
+
record.attemptCount,
|
|
5505
|
+
record.availableAt,
|
|
5506
|
+
record.leaseOwner ?? null,
|
|
5507
|
+
record.leaseExpiresAt ?? null,
|
|
5508
|
+
record.leaseToken,
|
|
5509
|
+
record.lastError ?? null,
|
|
5510
|
+
record.createdAt,
|
|
5511
|
+
record.updatedAt
|
|
5512
|
+
]);
|
|
5513
|
+
}
|
|
5514
|
+
}
|
|
4651
5515
|
async getActionInvocation(id, tenantId, spaceId) {
|
|
4652
5516
|
const result = await this.sql.query(`SELECT * FROM fabric_platform.action_invocations
|
|
4653
5517
|
WHERE id=$1 AND tenant_id=$2 AND space_id=$3`, [
|
|
@@ -4657,6 +5521,17 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
4657
5521
|
]);
|
|
4658
5522
|
return result.rows[0] ? toActionRecord(result.rows[0]) : void 0;
|
|
4659
5523
|
}
|
|
5524
|
+
async getActionInvocationByStagedOrigin(tenantId, spaceId, origin) {
|
|
5525
|
+
const result = await this.sql.query(`SELECT * FROM fabric_platform.action_invocations
|
|
5526
|
+
WHERE tenant_id=$1 AND space_id=$2
|
|
5527
|
+
AND staged_parent_invocation_id=$3 AND staged_stage_key=$4`, [
|
|
5528
|
+
tenantId,
|
|
5529
|
+
spaceId,
|
|
5530
|
+
origin.parentInvocationId,
|
|
5531
|
+
origin.stageKey
|
|
5532
|
+
]);
|
|
5533
|
+
return result.rows[0] ? toActionRecord(result.rows[0]) : void 0;
|
|
5534
|
+
}
|
|
4660
5535
|
async updateActionInvocation(id, tenantId, spaceId, patch) {
|
|
4661
5536
|
await this.sql.query(`UPDATE fabric_platform.action_invocations SET
|
|
4662
5537
|
status=COALESCE($4,status), result=COALESCE($5::jsonb,result),
|
|
@@ -5187,6 +6062,139 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
5187
6062
|
input.outboxId ?? null
|
|
5188
6063
|
])).rows.map((row) => toOutboxRequeueRecord(row, ""));
|
|
5189
6064
|
}
|
|
6065
|
+
async claimStagedInvocations(input) {
|
|
6066
|
+
const current = input.now ?? /* @__PURE__ */ new Date();
|
|
6067
|
+
const leaseExpiresAt = new Date(current.getTime() + input.leaseDurationMs);
|
|
6068
|
+
const limit = Math.max(1, Math.min(input.limit ?? 100, 1e3));
|
|
6069
|
+
return (await this.sql.query(`WITH claimable AS (
|
|
6070
|
+
SELECT stage.id
|
|
6071
|
+
FROM fabric_platform.staged_invocations AS stage
|
|
6072
|
+
JOIN fabric_platform.action_invocations AS parent
|
|
6073
|
+
ON parent.id=stage.parent_invocation_id
|
|
6074
|
+
AND parent.tenant_id=stage.tenant_id AND parent.space_id=stage.space_id
|
|
6075
|
+
WHERE stage.status='pending' AND parent.status='completed'
|
|
6076
|
+
AND stage.available_at <= $1
|
|
6077
|
+
AND (stage.lease_expires_at IS NULL OR stage.lease_expires_at <= $1)
|
|
6078
|
+
AND ($2::text IS NULL OR stage.tenant_id=$2)
|
|
6079
|
+
AND ($3::text IS NULL OR stage.space_id=$3)
|
|
6080
|
+
ORDER BY stage.created_at,stage.id
|
|
6081
|
+
FOR UPDATE OF stage SKIP LOCKED LIMIT $4
|
|
6082
|
+
)
|
|
6083
|
+
UPDATE fabric_platform.staged_invocations AS stage
|
|
6084
|
+
SET lease_owner=$5, lease_expires_at=$6, lease_token=stage.lease_token+1,
|
|
6085
|
+
attempt_count=stage.attempt_count+1, updated_at=$1
|
|
6086
|
+
FROM claimable WHERE stage.id=claimable.id
|
|
6087
|
+
RETURNING stage.*`, [
|
|
6088
|
+
current,
|
|
6089
|
+
input.tenantId ?? null,
|
|
6090
|
+
input.spaceId ?? null,
|
|
6091
|
+
limit,
|
|
6092
|
+
input.workerId,
|
|
6093
|
+
leaseExpiresAt
|
|
6094
|
+
])).rows.map(toStagedInvocationRecord);
|
|
6095
|
+
}
|
|
6096
|
+
async markStagedInvocationDispatched(input) {
|
|
6097
|
+
return this.settleStagedInvocation(input.id, input.workerId, input.leaseToken, "dispatched", input.now ?? /* @__PURE__ */ new Date(), input.childInvocationId, null);
|
|
6098
|
+
}
|
|
6099
|
+
async markStagedInvocationRejected(input) {
|
|
6100
|
+
return this.settleStagedInvocation(input.id, input.workerId, input.leaseToken, "rejected", input.now ?? /* @__PURE__ */ new Date(), null, input.error);
|
|
6101
|
+
}
|
|
6102
|
+
async markStagedInvocationFailed(input) {
|
|
6103
|
+
return (await this.sql.query(`UPDATE fabric_platform.staged_invocations
|
|
6104
|
+
SET status=$4, last_error=$5, available_at=$6,
|
|
6105
|
+
child_invocation_id=COALESCE($7,child_invocation_id),
|
|
6106
|
+
lease_owner=NULL, lease_expires_at=NULL, updated_at=$8
|
|
6107
|
+
WHERE id=$1 AND status='pending' AND lease_owner=$2 AND lease_token=$3 RETURNING id`, [
|
|
6108
|
+
input.id,
|
|
6109
|
+
input.workerId,
|
|
6110
|
+
input.leaseToken,
|
|
6111
|
+
input.deadLetter ? "dead_lettered" : "pending",
|
|
6112
|
+
input.error,
|
|
6113
|
+
input.availableAt,
|
|
6114
|
+
input.childInvocationId ?? null,
|
|
6115
|
+
input.now ?? /* @__PURE__ */ new Date()
|
|
6116
|
+
])).rows.length === 1;
|
|
6117
|
+
}
|
|
6118
|
+
async renewStagedInvocationLease(input) {
|
|
6119
|
+
const current = input.now ?? /* @__PURE__ */ new Date();
|
|
6120
|
+
return (await this.sql.query(`UPDATE fabric_platform.staged_invocations
|
|
6121
|
+
SET lease_expires_at=$4, updated_at=$5
|
|
6122
|
+
WHERE id=$1 AND status='pending' AND lease_owner=$2 AND lease_token=$3
|
|
6123
|
+
AND lease_expires_at IS NOT NULL AND lease_expires_at > $5 RETURNING id`, [
|
|
6124
|
+
input.id,
|
|
6125
|
+
input.workerId,
|
|
6126
|
+
input.leaseToken,
|
|
6127
|
+
new Date(current.getTime() + input.leaseDurationMs),
|
|
6128
|
+
current
|
|
6129
|
+
])).rows.length === 1;
|
|
6130
|
+
}
|
|
6131
|
+
/**
|
|
6132
|
+
* The UPDATE needs a row lock that conflicts with the `FOR SHARE` lock the
|
|
6133
|
+
* fenced child insert takes on this row, so it waits for an in-flight
|
|
6134
|
+
* insert to commit. A fenced insert under the old token that reaches the
|
|
6135
|
+
* row while this rotation is uncommitted waits for it, rechecks the new row
|
|
6136
|
+
* version and inserts nothing (live tests "orders a token rotation after an
|
|
6137
|
+
* in-flight fenced create ..." and "refuses a fenced create under the old
|
|
6138
|
+
* token once a token rotation is ordered before it").
|
|
6139
|
+
*/
|
|
6140
|
+
async rotateStagedInvocationLease(input) {
|
|
6141
|
+
const current = input.now ?? /* @__PURE__ */ new Date();
|
|
6142
|
+
const result = await this.sql.query(`UPDATE fabric_platform.staged_invocations
|
|
6143
|
+
SET lease_token=lease_token+1, lease_expires_at=$4, updated_at=$5
|
|
6144
|
+
WHERE id=$1 AND status='pending' AND lease_owner=$2 AND lease_token=$3
|
|
6145
|
+
AND lease_expires_at IS NOT NULL AND lease_expires_at > $5
|
|
6146
|
+
RETURNING lease_token`, [
|
|
6147
|
+
input.id,
|
|
6148
|
+
input.workerId,
|
|
6149
|
+
input.leaseToken,
|
|
6150
|
+
new Date(current.getTime() + input.leaseDurationMs),
|
|
6151
|
+
current
|
|
6152
|
+
]);
|
|
6153
|
+
return result.rows[0] ? Number(result.rows[0].lease_token) : void 0;
|
|
6154
|
+
}
|
|
6155
|
+
async cancelStagedInvocationsForFailedParents(input = {}) {
|
|
6156
|
+
return (await this.sql.query(`UPDATE fabric_platform.staged_invocations AS stage
|
|
6157
|
+
SET status='cancelled', last_error='Parent invocation ended with ' || parent.status || '.',
|
|
6158
|
+
lease_owner=NULL, lease_expires_at=NULL, updated_at=$3
|
|
6159
|
+
FROM fabric_platform.action_invocations AS parent
|
|
6160
|
+
WHERE parent.id=stage.parent_invocation_id
|
|
6161
|
+
AND parent.tenant_id=stage.tenant_id AND parent.space_id=stage.space_id
|
|
6162
|
+
AND parent.status IN ('failed','blocked_by_policy','validation_failed')
|
|
6163
|
+
AND stage.status='pending'
|
|
6164
|
+
AND ($1::text IS NULL OR stage.tenant_id=$1)
|
|
6165
|
+
AND ($2::text IS NULL OR stage.space_id=$2)
|
|
6166
|
+
RETURNING stage.id`, [
|
|
6167
|
+
input.tenantId ?? null,
|
|
6168
|
+
input.spaceId ?? null,
|
|
6169
|
+
input.now ?? /* @__PURE__ */ new Date()
|
|
6170
|
+
])).rows.length;
|
|
6171
|
+
}
|
|
6172
|
+
async listStagedInvocations(input) {
|
|
6173
|
+
return (await this.sql.query(`SELECT * FROM fabric_platform.staged_invocations
|
|
6174
|
+
WHERE tenant_id=$1 AND space_id=$2
|
|
6175
|
+
AND ($3::text IS NULL OR parent_invocation_id=$3)
|
|
6176
|
+
AND ($4::text[] IS NULL OR status=ANY($4::text[]))
|
|
6177
|
+
ORDER BY created_at,id`, [
|
|
6178
|
+
input.tenantId,
|
|
6179
|
+
input.spaceId,
|
|
6180
|
+
input.parentInvocationId ?? null,
|
|
6181
|
+
input.statuses?.length ? [...input.statuses] : null
|
|
6182
|
+
])).rows.map(toStagedInvocationRecord);
|
|
6183
|
+
}
|
|
6184
|
+
async settleStagedInvocation(id, workerId, leaseToken, status, now, childInvocationId, lastError) {
|
|
6185
|
+
return (await this.sql.query(`UPDATE fabric_platform.staged_invocations
|
|
6186
|
+
SET status=$4, child_invocation_id=COALESCE($5,child_invocation_id), last_error=$6,
|
|
6187
|
+
lease_owner=NULL, lease_expires_at=NULL, updated_at=$7
|
|
6188
|
+
WHERE id=$1 AND status='pending' AND lease_owner=$2 AND lease_token=$3 RETURNING id`, [
|
|
6189
|
+
id,
|
|
6190
|
+
workerId,
|
|
6191
|
+
leaseToken,
|
|
6192
|
+
status,
|
|
6193
|
+
childInvocationId,
|
|
6194
|
+
lastError,
|
|
6195
|
+
now
|
|
6196
|
+
])).rows.length === 1;
|
|
6197
|
+
}
|
|
5190
6198
|
async nextEventSequence(tenantId, spaceId) {
|
|
5191
6199
|
const result = await this.sql.query(`INSERT INTO fabric_platform.event_sequences (tenant_id,space_id,next_sequence)
|
|
5192
6200
|
VALUES ($1,$2,1) ON CONFLICT (tenant_id,space_id) DO UPDATE
|
|
@@ -5441,6 +6449,10 @@ function toActionRecord(row) {
|
|
|
5441
6449
|
...row.parameter_digest_algorithm ? { parameterDigestAlgorithm: String(row.parameter_digest_algorithm) } : {},
|
|
5442
6450
|
...row.idempotency_actor_id ? { idempotencyActorId: String(row.idempotency_actor_id) } : {},
|
|
5443
6451
|
...row.idempotency_authorization_binding_id ? { idempotencyAuthorizationBindingId: String(row.idempotency_authorization_binding_id) } : {},
|
|
6452
|
+
...row.staged_parent_invocation_id && row.staged_stage_key ? { stagedOrigin: {
|
|
6453
|
+
parentInvocationId: String(row.staged_parent_invocation_id),
|
|
6454
|
+
stageKey: String(row.staged_stage_key)
|
|
6455
|
+
} } : {},
|
|
5444
6456
|
...row.invocation_provenance ? { provenance: row.invocation_provenance } : {},
|
|
5445
6457
|
...row.authorization_binding ? { authorizationBinding: row.authorization_binding } : {},
|
|
5446
6458
|
...row.execution_reason ? { executionReason: String(row.execution_reason) } : {},
|
|
@@ -5470,6 +6482,31 @@ function toActionRecord(row) {
|
|
|
5470
6482
|
updatedAt: new Date(row.updated_at)
|
|
5471
6483
|
};
|
|
5472
6484
|
}
|
|
6485
|
+
function toStagedInvocationRecord(row) {
|
|
6486
|
+
return {
|
|
6487
|
+
id: String(row.id),
|
|
6488
|
+
tenantId: String(row.tenant_id),
|
|
6489
|
+
spaceId: String(row.space_id),
|
|
6490
|
+
parentInvocationId: String(row.parent_invocation_id),
|
|
6491
|
+
parentActionId: String(row.parent_action_id),
|
|
6492
|
+
key: String(row.stage_key),
|
|
6493
|
+
actionId: String(row.action_id),
|
|
6494
|
+
expectedActionVersion: Number(row.expected_action_version),
|
|
6495
|
+
parameters: row.parameters,
|
|
6496
|
+
parameterDigest: String(row.parameter_digest),
|
|
6497
|
+
batchDigest: String(row.batch_digest),
|
|
6498
|
+
status: String(row.status),
|
|
6499
|
+
...row.child_invocation_id ? { childInvocationId: String(row.child_invocation_id) } : {},
|
|
6500
|
+
attemptCount: Number(row.attempt_count),
|
|
6501
|
+
availableAt: new Date(row.available_at),
|
|
6502
|
+
...row.lease_owner ? { leaseOwner: String(row.lease_owner) } : {},
|
|
6503
|
+
...row.lease_expires_at ? { leaseExpiresAt: new Date(row.lease_expires_at) } : {},
|
|
6504
|
+
leaseToken: Number(row.lease_token ?? 0),
|
|
6505
|
+
...row.last_error ? { lastError: String(row.last_error) } : {},
|
|
6506
|
+
createdAt: new Date(row.created_at),
|
|
6507
|
+
updatedAt: new Date(row.updated_at)
|
|
6508
|
+
};
|
|
6509
|
+
}
|
|
5473
6510
|
function toObligationRecord(row) {
|
|
5474
6511
|
return {
|
|
5475
6512
|
id: String(row.id),
|
|
@@ -5609,6 +6646,23 @@ function createStoreBackedActionDispatcher() {
|
|
|
5609
6646
|
async function runPlatformActionWorkerCycle(options) {
|
|
5610
6647
|
const leaseDurationMs = options.leaseDurationMs ?? DEFAULT_LEASE_DURATION_MS;
|
|
5611
6648
|
emitWorkerTelemetry(options.telemetry, "worker.cycle.started", options.workerId);
|
|
6649
|
+
let staged;
|
|
6650
|
+
if ((options.dispatchStagedInvocations === true || options.dispatchStagedInvocations !== false && options.host.hasStagedInvocationActions === true) && typeof options.host.dispatchStagedInvocations === "function") try {
|
|
6651
|
+
staged = await options.host.dispatchStagedInvocations({
|
|
6652
|
+
workerId: options.workerId,
|
|
6653
|
+
...options.stagedInvocationBatchSize !== void 0 ? { limit: options.stagedInvocationBatchSize } : {},
|
|
6654
|
+
...options.stagedInvocationLeaseDurationMs !== void 0 ? { leaseDurationMs: options.stagedInvocationLeaseDurationMs } : {},
|
|
6655
|
+
...options.stagedInvocationLeaseRenewalIntervalMs !== void 0 ? { leaseRenewalIntervalMs: options.stagedInvocationLeaseRenewalIntervalMs } : {},
|
|
6656
|
+
...options.stagedInvocationSubmissionTimeoutMs !== void 0 ? { submissionTimeoutMs: options.stagedInvocationSubmissionTimeoutMs } : {},
|
|
6657
|
+
...options.stagedInvocationMaxAttempts !== void 0 ? { maxAttempts: options.stagedInvocationMaxAttempts } : {},
|
|
6658
|
+
...options.stagedInvocationRetryDelayMs ? { retryDelayMs: options.stagedInvocationRetryDelayMs } : {},
|
|
6659
|
+
...options.tenantId ? { tenantId: options.tenantId } : {},
|
|
6660
|
+
...options.spaceId ? { spaceId: options.spaceId } : {},
|
|
6661
|
+
...options.onError ? { onError: (error) => options.onError?.(error) } : {}
|
|
6662
|
+
});
|
|
6663
|
+
} catch (error) {
|
|
6664
|
+
options.onError?.(error);
|
|
6665
|
+
}
|
|
5612
6666
|
if (options.sweepOverdueCompletions !== false && typeof options.host.reconcileOverdueCompletions === "function") try {
|
|
5613
6667
|
await options.host.reconcileOverdueCompletions({
|
|
5614
6668
|
...options.tenantId ? { tenantId: options.tenantId } : {},
|
|
@@ -5666,9 +6720,20 @@ async function runPlatformActionWorkerCycle(options) {
|
|
|
5666
6720
|
completed,
|
|
5667
6721
|
failed,
|
|
5668
6722
|
...waitingForApproval > 0 ? { waitingForApproval } : {},
|
|
5669
|
-
...deferred > 0 ? { deferred } : {}
|
|
6723
|
+
...deferred > 0 ? { deferred } : {},
|
|
6724
|
+
...staged && Object.values(staged).some((value) => value > 0) ? { staged } : {}
|
|
5670
6725
|
};
|
|
5671
|
-
emitWorkerTelemetry(options.telemetry, "worker.cycle.completed", options.workerId,
|
|
6726
|
+
emitWorkerTelemetry(options.telemetry, "worker.cycle.completed", options.workerId, {
|
|
6727
|
+
claimed: result.claimed,
|
|
6728
|
+
completed: result.completed,
|
|
6729
|
+
failed: result.failed,
|
|
6730
|
+
...result.waitingForApproval !== void 0 ? { waitingForApproval: result.waitingForApproval } : {},
|
|
6731
|
+
...result.deferred !== void 0 ? { deferred: result.deferred } : {},
|
|
6732
|
+
...result.staged ? {
|
|
6733
|
+
stagedClaimed: result.staged.claimed,
|
|
6734
|
+
stagedDispatched: result.staged.dispatched
|
|
6735
|
+
} : {}
|
|
6736
|
+
});
|
|
5672
6737
|
return result;
|
|
5673
6738
|
}
|
|
5674
6739
|
function startLeaseHeartbeat(options, invocation, leaseDurationMs) {
|
|
@@ -5926,15 +6991,16 @@ async function adapterProbe(subject) {
|
|
|
5926
6991
|
};
|
|
5927
6992
|
}
|
|
5928
6993
|
/**
|
|
5929
|
-
* The suite
|
|
6994
|
+
* The suite a deployment of the host must pass before it claims isolation.
|
|
5930
6995
|
*
|
|
5931
|
-
*
|
|
6996
|
+
* Nine checks: an invocation cannot be read, executed, or completed under
|
|
5932
6997
|
* another tenant's scope; an idempotency key is a tenant's own, so the same
|
|
5933
6998
|
* key in two tenants is two invocations; a tenant's event stream carries no
|
|
5934
6999
|
* other tenant's events; a worker scoped to one tenant does not claim another's
|
|
5935
|
-
* work;
|
|
5936
|
-
*
|
|
5937
|
-
* the
|
|
7000
|
+
* work; staged listing and claiming retain tenant and space scope; an adapter
|
|
7001
|
+
* checkpoint cannot be read or written under another tenant, space, or owning
|
|
7002
|
+
* invocation; and, when the subject lends a read, a viewer in the other tenant
|
|
7003
|
+
* sees nothing of ours.
|
|
5938
7004
|
*/
|
|
5939
7005
|
function tenantIsolationChecks() {
|
|
5940
7006
|
return [
|
|
@@ -5978,6 +7044,103 @@ function tenantIsolationChecks() {
|
|
|
5978
7044
|
return ["one idempotency key: one invocation within a tenant, two across tenants"];
|
|
5979
7045
|
}
|
|
5980
7046
|
},
|
|
7047
|
+
{
|
|
7048
|
+
id: "fabric.tenancy.staged-invocation-scoped.v1",
|
|
7049
|
+
async run(subject) {
|
|
7050
|
+
await subject.reset?.();
|
|
7051
|
+
const store = subject.store;
|
|
7052
|
+
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"];
|
|
7053
|
+
const suffix = globalThis.crypto.randomUUID();
|
|
7054
|
+
const probeTenant = `iso-stage-tenant-${suffix}`;
|
|
7055
|
+
const otherProbeTenant = `iso-stage-tenant-other-${suffix}`;
|
|
7056
|
+
const probeSpace = `iso-stage-space-${suffix}`;
|
|
7057
|
+
const otherProbeSpace = `iso-stage-space-other-${suffix}`;
|
|
7058
|
+
const parents = [
|
|
7059
|
+
{
|
|
7060
|
+
id: `iso_stage_parent_ours_${suffix}`,
|
|
7061
|
+
tenantId: probeTenant,
|
|
7062
|
+
spaceId: probeSpace,
|
|
7063
|
+
actorId: subject.ours.actorId,
|
|
7064
|
+
actorType: subject.ours.actorType
|
|
7065
|
+
},
|
|
7066
|
+
{
|
|
7067
|
+
id: `iso_stage_parent_theirs_${suffix}`,
|
|
7068
|
+
tenantId: otherProbeTenant,
|
|
7069
|
+
spaceId: probeSpace,
|
|
7070
|
+
actorId: subject.theirs.actorId,
|
|
7071
|
+
actorType: subject.theirs.actorType
|
|
7072
|
+
},
|
|
7073
|
+
{
|
|
7074
|
+
id: `iso_stage_parent_space_${suffix}`,
|
|
7075
|
+
tenantId: probeTenant,
|
|
7076
|
+
spaceId: otherProbeSpace,
|
|
7077
|
+
actorId: subject.ours.actorId,
|
|
7078
|
+
actorType: subject.ours.actorType
|
|
7079
|
+
}
|
|
7080
|
+
];
|
|
7081
|
+
for (const parent of parents) await store.createActionInvocation({
|
|
7082
|
+
...parent,
|
|
7083
|
+
actionId: subject.actionId,
|
|
7084
|
+
actionVersion: 1,
|
|
7085
|
+
status: "completed",
|
|
7086
|
+
parameters: {},
|
|
7087
|
+
result: {},
|
|
7088
|
+
correlationId: suffix
|
|
7089
|
+
});
|
|
7090
|
+
const now = /* @__PURE__ */ new Date();
|
|
7091
|
+
const records = parents.map((parent, index) => ({
|
|
7092
|
+
id: `iso_stage_${index}_${suffix}`,
|
|
7093
|
+
tenantId: parent.tenantId,
|
|
7094
|
+
spaceId: parent.spaceId,
|
|
7095
|
+
parentInvocationId: parent.id,
|
|
7096
|
+
parentActionId: subject.actionId,
|
|
7097
|
+
key: "child",
|
|
7098
|
+
actionId: subject.actionId,
|
|
7099
|
+
expectedActionVersion: 1,
|
|
7100
|
+
parameters: {},
|
|
7101
|
+
parameterDigest: `digest-${index}`,
|
|
7102
|
+
batchDigest: `batch-${index}`,
|
|
7103
|
+
status: "pending",
|
|
7104
|
+
attemptCount: 0,
|
|
7105
|
+
availableAt: now,
|
|
7106
|
+
leaseToken: 0,
|
|
7107
|
+
createdAt: now,
|
|
7108
|
+
updatedAt: now
|
|
7109
|
+
}));
|
|
7110
|
+
try {
|
|
7111
|
+
await store.transactionWithEvents(async (transaction) => {
|
|
7112
|
+
if (!transaction.stageInvocations) fail$1("transactionalStagedInvocations did not supply stageInvocations");
|
|
7113
|
+
for (const record of records) await transaction.stageInvocations([record]);
|
|
7114
|
+
});
|
|
7115
|
+
const ours = await store.listStagedInvocations({
|
|
7116
|
+
tenantId: probeTenant,
|
|
7117
|
+
spaceId: probeSpace
|
|
7118
|
+
});
|
|
7119
|
+
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");
|
|
7120
|
+
if (ours.some((record) => record.id === records[1].id || record.id === records[2].id)) fail$1("staged invocation listing crossed tenant or space");
|
|
7121
|
+
if ((await store.listStagedInvocations({
|
|
7122
|
+
tenantId: otherProbeTenant,
|
|
7123
|
+
spaceId: probeSpace,
|
|
7124
|
+
parentInvocationId: parents[0].id
|
|
7125
|
+
})).length !== 0) fail$1("a staged invocation was listable under another tenant's scope");
|
|
7126
|
+
const claimed = await store.claimStagedInvocations({
|
|
7127
|
+
workerId: `iso-stage-worker-${suffix}`,
|
|
7128
|
+
tenantId: probeTenant,
|
|
7129
|
+
spaceId: probeSpace,
|
|
7130
|
+
leaseDurationMs: 6e4,
|
|
7131
|
+
now
|
|
7132
|
+
});
|
|
7133
|
+
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");
|
|
7134
|
+
if (claimed.some((record) => record.tenantId !== probeTenant || record.spaceId !== probeSpace)) fail$1("staged invocation claim crossed tenant or space");
|
|
7135
|
+
return ["staged listing and claiming retained both tenant and space scope"];
|
|
7136
|
+
} finally {
|
|
7137
|
+
for (const parent of parents) await store.updateActionInvocation(parent.id, parent.tenantId, parent.spaceId, {
|
|
7138
|
+
status: "failed",
|
|
7139
|
+
error: "tenant isolation probe; retired"
|
|
7140
|
+
});
|
|
7141
|
+
}
|
|
7142
|
+
}
|
|
7143
|
+
},
|
|
5981
7144
|
{
|
|
5982
7145
|
id: "fabric.tenancy.events-scoped.v1",
|
|
5983
7146
|
async run(subject) {
|
|
@@ -6775,13 +7938,20 @@ async function runEventAppendChecks(subject) {
|
|
|
6775
7938
|
exports.ActionAdmissionError = ActionAdmissionError;
|
|
6776
7939
|
exports.ExternalCompletionError = ExternalCompletionError;
|
|
6777
7940
|
exports.IdempotencyConflictError = IdempotencyConflictError;
|
|
7941
|
+
exports.MAX_STAGED_INVOCATIONS = MAX_STAGED_INVOCATIONS;
|
|
7942
|
+
exports.MAX_STAGED_INVOCATION_BATCH_BYTES = MAX_STAGED_INVOCATION_BATCH_BYTES;
|
|
7943
|
+
exports.MAX_STAGED_INVOCATION_BYTES = MAX_STAGED_INVOCATION_BYTES;
|
|
7944
|
+
exports.MAX_STAGED_INVOCATION_KEY_LENGTH = MAX_STAGED_INVOCATION_KEY_LENGTH;
|
|
6778
7945
|
exports.MemoryPlatformHostStore = MemoryPlatformHostStore;
|
|
6779
7946
|
exports.PARAMETER_DIGEST_ALGORITHM = PARAMETER_DIGEST_ALGORITHM;
|
|
6780
7947
|
exports.PLATFORM_HOST_CONTRACT_VERSION = PLATFORM_HOST_CONTRACT_VERSION;
|
|
6781
7948
|
exports.PLATFORM_HOST_HEALTH_CONTRACT_VERSION = PLATFORM_HOST_HEALTH_CONTRACT_VERSION;
|
|
6782
7949
|
exports.PLATFORM_HOST_METRIC_NAMES = PLATFORM_HOST_METRIC_NAMES;
|
|
6783
7950
|
exports.PostgresPlatformHostStore = PostgresPlatformHostStore;
|
|
7951
|
+
exports.STAGED_INVOCATION_KEY_PATTERN = STAGED_INVOCATION_KEY_PATTERN;
|
|
7952
|
+
exports.SubmissionRefusalError = SubmissionRefusalError;
|
|
6784
7953
|
exports.applyPostgresMigrations = applyPostgresMigrations;
|
|
7954
|
+
exports.asStagedInvocationStore = asStagedInvocationStore;
|
|
6785
7955
|
Object.defineProperty(exports, "canonicalJson", {
|
|
6786
7956
|
enumerable: true,
|
|
6787
7957
|
get: function() {
|
|
@@ -6789,6 +7959,7 @@ Object.defineProperty(exports, "canonicalJson", {
|
|
|
6789
7959
|
}
|
|
6790
7960
|
});
|
|
6791
7961
|
exports.cloneOutboxRecord = cloneOutboxRecord;
|
|
7962
|
+
exports.cloneStagedInvocationRecord = cloneStagedInvocationRecord;
|
|
6792
7963
|
exports.createDurableSagaParentLifecycle = createDurableSagaParentLifecycle;
|
|
6793
7964
|
exports.createGovernedActionHost = createGovernedActionHost;
|
|
6794
7965
|
exports.createStoreBackedActionDispatcher = createStoreBackedActionDispatcher;
|
|
@@ -6797,11 +7968,13 @@ exports.emitPlatformHostTelemetry = emitPlatformHostTelemetry;
|
|
|
6797
7968
|
exports.eventAppendChecks = eventAppendChecks;
|
|
6798
7969
|
exports.getPlatformHostHealthSnapshot = getPlatformHostHealthSnapshot;
|
|
6799
7970
|
exports.outboxActorDisclosure = outboxActorDisclosure;
|
|
7971
|
+
exports.prepareStagedInvocationBatch = prepareStagedInvocationBatch;
|
|
6800
7972
|
exports.runEventAppendChecks = runEventAppendChecks;
|
|
6801
7973
|
exports.runOutboxRelayCycle = runOutboxRelayCycle;
|
|
6802
7974
|
exports.runPlatformActionWorker = runPlatformActionWorker;
|
|
6803
7975
|
exports.runPlatformActionWorkerCycle = runPlatformActionWorkerCycle;
|
|
6804
7976
|
exports.runTenantIsolationChecks = runTenantIsolationChecks;
|
|
7977
|
+
exports.stagedInvocationRequestIdentity = stagedInvocationRequestIdentity;
|
|
6805
7978
|
exports.tenantIsolationChecks = tenantIsolationChecks;
|
|
6806
7979
|
exports.toEnterpriseEventEnvelope = toEnterpriseEventEnvelope;
|
|
6807
7980
|
|