@fabricorg/platform-host 8.3.0 → 8.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +119 -4
- package/dist/index.cjs +581 -158
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +138 -8
- package/dist/index.d.ts +138 -8
- package/dist/index.js +578 -159
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -1,8 +1,102 @@
|
|
|
1
|
+
import { types } from "util";
|
|
1
2
|
import { AdapterCircuitBreakerRegistry, AdapterRegistry, FABRIC_GOVERNANCE_CONTRACT_VERSION, ReconciliationRequiredError, aggregatePolicyOutcomes, assertActionFinalization, assertAdapterCircuitBreakerConfig, assertAdapterOutcomeConsistency, assertAdapterStepDeadlines, assertGovernanceRuntimeEvidence, assertMutationGovernanceContext, canonicalJson, createFabricId, evaluatePolicyDefinitions, evaluateStateMachineTransition, executeWithAdapterRetry, isAdapterCircuitOpenError, isReconciliationRequiredError, isRetryableExecutionError } from "@fabricorg/platform";
|
|
2
3
|
import { performance } from "perf_hooks";
|
|
3
4
|
import { assertAssemblyRuntimeCompatible } from "@fabricorg/assembly";
|
|
4
5
|
import { createHash } from "crypto";
|
|
5
|
-
|
|
6
|
+
//#region src/action-finalization.ts
|
|
7
|
+
/** Remove private preparation and restore its compatible protocol on settlement. */
|
|
8
|
+
function finalizationSettlementPatch(record, patch) {
|
|
9
|
+
const checkpoint = Object.hasOwn(patch, "finalizationCheckpoint") ? patch.finalizationCheckpoint : record.finalizationCheckpoint;
|
|
10
|
+
if (!checkpoint || !patch.status || ![
|
|
11
|
+
"completed",
|
|
12
|
+
"failed",
|
|
13
|
+
"blocked_by_policy",
|
|
14
|
+
"validation_failed"
|
|
15
|
+
].includes(patch.status) || !Object.hasOwn(checkpoint, "adapterPreparation") && checkpoint.protocol !== "atomic-after-adapters-v2") return patch;
|
|
16
|
+
const { adapterPreparation: _preparation, ...evidence } = checkpoint;
|
|
17
|
+
return {
|
|
18
|
+
...patch,
|
|
19
|
+
finalizationCheckpoint: {
|
|
20
|
+
...evidence,
|
|
21
|
+
protocol: "atomic-after-adapters-v1"
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** Strict JSON evidence: reject lossy serialization rather than silently dropping values. */
|
|
26
|
+
function finalizationJson(value) {
|
|
27
|
+
const ancestors = /* @__PURE__ */ new Set();
|
|
28
|
+
function check(item) {
|
|
29
|
+
if (item === null || typeof item === "string" || typeof item === "boolean") return;
|
|
30
|
+
if (typeof item === "number" && Number.isFinite(item)) return;
|
|
31
|
+
if (typeof item !== "object" || !item || ancestors.has(item)) throw new Error("Finalization requires JSON-compatible data.");
|
|
32
|
+
if (!Array.isArray(item) && Object.getPrototypeOf(item) !== Object.prototype && Object.getPrototypeOf(item) !== null) throw new Error("Finalization requires plain JSON objects.");
|
|
33
|
+
if (Object.getOwnPropertySymbols(item).length > 0) throw new Error("Finalization requires JSON string keys.");
|
|
34
|
+
if (Array.isArray(item)) {
|
|
35
|
+
const keys = Object.keys(item);
|
|
36
|
+
if (keys.length !== item.length || keys.some((key, index) => key !== String(index))) throw new Error("Finalization requires dense JSON arrays.");
|
|
37
|
+
}
|
|
38
|
+
ancestors.add(item);
|
|
39
|
+
for (const child of Object.values(item)) check(child);
|
|
40
|
+
ancestors.delete(item);
|
|
41
|
+
}
|
|
42
|
+
check(value);
|
|
43
|
+
return JSON.parse(JSON.stringify(value));
|
|
44
|
+
}
|
|
45
|
+
/** Match the persisted JSON representation before validating finalization execution input. */
|
|
46
|
+
function normalizeFinalizationJson(value) {
|
|
47
|
+
return finalizationJson(JSON.parse(JSON.stringify(value)));
|
|
48
|
+
}
|
|
49
|
+
/** Omit only public optional fields; payloads and patch values retain strict JSON validation. */
|
|
50
|
+
function omitUndefinedOptionalFields(value, keys) {
|
|
51
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return value;
|
|
52
|
+
const copy = { ...value };
|
|
53
|
+
for (const key of keys) if (Object.hasOwn(copy, key) && copy[key] === void 0) delete copy[key];
|
|
54
|
+
return copy;
|
|
55
|
+
}
|
|
56
|
+
function validateFinalization(value, action, privateFields, contracts) {
|
|
57
|
+
const normalized = omitUndefinedOptionalFields(value, [
|
|
58
|
+
"events",
|
|
59
|
+
"resultPatch",
|
|
60
|
+
"stagedInvocations"
|
|
61
|
+
]);
|
|
62
|
+
if (normalized !== value && normalized && typeof normalized === "object" && !Array.isArray(normalized)) {
|
|
63
|
+
const record = normalized;
|
|
64
|
+
if (Array.isArray(record.events)) {
|
|
65
|
+
const events = Object.assign(record.events.slice(), record.events);
|
|
66
|
+
for (let index = 0; index < events.length; index++) if (Object.hasOwn(events, index)) events[index] = omitUndefinedOptionalFields(events[index], ["eventSchemaVersion"]);
|
|
67
|
+
record.events = events;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const result = finalizationJson(normalized);
|
|
71
|
+
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.");
|
|
72
|
+
if (result.resultPatch !== void 0) {
|
|
73
|
+
if (!result.resultPatch || typeof result.resultPatch !== "object" || Array.isArray(result.resultPatch)) throw new Error("Finalization resultPatch must be an object.");
|
|
74
|
+
if (Object.keys(result.resultPatch).some((key) => privateFields.includes(key))) throw new Error("Finalization resultPatch contains a private result key.");
|
|
75
|
+
}
|
|
76
|
+
if (result.events !== void 0) {
|
|
77
|
+
if (!Array.isArray(result.events)) throw new Error("Finalization events must be an array.");
|
|
78
|
+
for (const event of result.events) {
|
|
79
|
+
if (!event || typeof event !== "object" || Array.isArray(event) || Object.keys(event).some((key) => ![
|
|
80
|
+
"eventType",
|
|
81
|
+
"subjectType",
|
|
82
|
+
"subjectId",
|
|
83
|
+
"payload",
|
|
84
|
+
"eventSchemaVersion"
|
|
85
|
+
].includes(key))) throw new Error("Invalid finalization event fields.");
|
|
86
|
+
for (const key of [
|
|
87
|
+
"eventType",
|
|
88
|
+
"subjectType",
|
|
89
|
+
"subjectId"
|
|
90
|
+
]) if (typeof event[key] !== "string" || !event[key].trim()) throw new Error("Finalization event identifiers must be non-empty.");
|
|
91
|
+
if (!Object.hasOwn(event, "payload")) throw new Error("Finalization event requires a JSON payload.");
|
|
92
|
+
if (event.eventSchemaVersion !== void 0 && (!Number.isInteger(event.eventSchemaVersion) || event.eventSchemaVersion <= 0)) throw new Error("Finalization eventSchemaVersion must be a positive integer.");
|
|
93
|
+
if (!action.emitsEvents.includes(event.eventType)) throw new Error("Finalization emitted undeclared event type.");
|
|
94
|
+
if (contracts && (!contracts.hasEventType(event.eventType) || !contracts.hasSubjectType(event.subjectType))) throw new Error("Finalization emitted an unregistered event or subject type.");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
//#endregion
|
|
6
100
|
//#region src/types.ts
|
|
7
101
|
/** Durable Host lifecycle generation, independent from the npm package version. */
|
|
8
102
|
const PLATFORM_HOST_CONTRACT_VERSION = 2;
|
|
@@ -68,6 +162,139 @@ var ExternalCompletionError = class extends Error {
|
|
|
68
162
|
return this.refusal === "still_executing";
|
|
69
163
|
}
|
|
70
164
|
};
|
|
165
|
+
/** Maximum serialized UTF-8 evidence size. */
|
|
166
|
+
const MAX_APPROVAL_EVIDENCE_BYTES = 16384;
|
|
167
|
+
const APPROVAL_REQUESTED_EVENT = "ApprovalRequested";
|
|
168
|
+
const APPROVAL_DECIDED_EVENT = "ApprovalDecided";
|
|
169
|
+
const APPROVAL_EXPIRED_EVENT = "ApprovalExpired";
|
|
170
|
+
//#endregion
|
|
171
|
+
//#region src/approval-lifecycle.ts
|
|
172
|
+
function validApprovalDate(value) {
|
|
173
|
+
return value instanceof Date && Number.isFinite(value.getTime());
|
|
174
|
+
}
|
|
175
|
+
/** Snapshot before awaiting application authorization; reject lossy JSON conversions. */
|
|
176
|
+
function snapshotApprovalDecision(decision) {
|
|
177
|
+
const visit = (value, ancestors, depth) => {
|
|
178
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return;
|
|
179
|
+
if (typeof value === "number" && Number.isFinite(value)) return;
|
|
180
|
+
if (typeof value !== "object" || types.isProxy(value)) throw new Error("Approval evidence must be plain JSON data.");
|
|
181
|
+
if (ancestors.includes(value)) throw new Error("Approval evidence must not contain cycles.");
|
|
182
|
+
if (depth > 64) throw new Error("Approval evidence exceeds maximum depth 64.");
|
|
183
|
+
const array = Array.isArray(value);
|
|
184
|
+
if (!array && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) throw new Error("Approval evidence must contain only plain objects and JSON values.");
|
|
185
|
+
const keys = Reflect.ownKeys(value);
|
|
186
|
+
if (array && keys.length !== value.length + 1) throw new Error("Approval evidence arrays must be dense JSON arrays.");
|
|
187
|
+
for (const key of keys) {
|
|
188
|
+
if (array && key === "length") continue;
|
|
189
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
190
|
+
if (typeof key !== "string" || !descriptor.enumerable || !Object.hasOwn(descriptor, "value") || array && !/^(0|[1-9][0-9]*)$/.test(key)) throw new Error("Approval evidence must contain enumerable JSON data properties only.");
|
|
191
|
+
visit(descriptor.value, [...ancestors, value], depth + 1);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
if (types.isProxy(decision)) throw new Error("Approval decision must be plain data.");
|
|
195
|
+
for (const descriptor of Object.values(Object.getOwnPropertyDescriptors(decision))) if (!Object.hasOwn(descriptor, "value")) throw new Error("Approval decision must be plain data.");
|
|
196
|
+
if (decision.evidence !== void 0) {
|
|
197
|
+
if (!decision.evidence || typeof decision.evidence !== "object" || Array.isArray(decision.evidence)) throw new Error("Approval evidence must be a JSON object.");
|
|
198
|
+
visit(decision.evidence, [], 0);
|
|
199
|
+
if (Buffer.byteLength(JSON.stringify(decision.evidence), "utf8") > 16384) throw new Error(`Approval evidence exceeds ${MAX_APPROVAL_EVIDENCE_BYTES} bytes.`);
|
|
200
|
+
}
|
|
201
|
+
if (decision.decidedAt !== void 0 && !validApprovalDate(decision.decidedAt)) throw new Error("decidedAt must be a valid Date.");
|
|
202
|
+
return structuredClone(decision);
|
|
203
|
+
}
|
|
204
|
+
function approvalSweepLimit(input) {
|
|
205
|
+
if (!validApprovalDate(input.now)) throw new Error("Approval sweep now must be a valid Date.");
|
|
206
|
+
const limit = input.limit ?? 100;
|
|
207
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 1e3) throw new Error("Approval sweep limit must be an integer from 1 to 1000.");
|
|
208
|
+
return limit;
|
|
209
|
+
}
|
|
210
|
+
function approvalExpiredPatch(record, now) {
|
|
211
|
+
return finalizationSettlementPatch(record, {
|
|
212
|
+
status: "failed",
|
|
213
|
+
approvalDecision: void 0,
|
|
214
|
+
approvalExpiry: {
|
|
215
|
+
expiresAt: new Date(record.approvalExpiresAt),
|
|
216
|
+
expiredAt: new Date(now)
|
|
217
|
+
},
|
|
218
|
+
error: "Approval expired before a decision was applied."
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
/** Both stores serialize this read, event append, and update within their mutation transaction. */
|
|
222
|
+
async function applyApprovalTransition(transaction, input, kind) {
|
|
223
|
+
if (!transaction.getActionInvocationForUpdate) throw new Error("Approval transitions require locking invocation reads.");
|
|
224
|
+
const record = await transaction.getActionInvocationForUpdate(input.actionInvocationId, input.tenantId, input.spaceId);
|
|
225
|
+
if (!record) return { applied: false };
|
|
226
|
+
if (kind === "decided" && transaction.beginApprovalDecision) {
|
|
227
|
+
const { lifecycleEvent, ...decisionInput } = input;
|
|
228
|
+
const result = await transaction.beginApprovalDecision(decisionInput);
|
|
229
|
+
if (result.applied && result.invocation) await appendLifecycleEvent(transaction, lifecycleEvent, result.invocation, result.invocation.approvalExpiry ? "expired" : "decided");
|
|
230
|
+
return result;
|
|
231
|
+
}
|
|
232
|
+
let patch;
|
|
233
|
+
if (kind === "requested") {
|
|
234
|
+
if (!["pending", "running"].includes(record.status) || record.approvalDecision !== void 0 || record.leaseOwner !== void 0 && (record.leaseOwner !== input.leaseOwner || record.leaseToken !== input.leaseToken)) return {
|
|
235
|
+
applied: false,
|
|
236
|
+
invocation: record
|
|
237
|
+
};
|
|
238
|
+
patch = {
|
|
239
|
+
status: "waiting_for_approval",
|
|
240
|
+
...Object.hasOwn(input, "approvalExpiresAt") ? { approvalExpiresAt: input.approvalExpiresAt } : {}
|
|
241
|
+
};
|
|
242
|
+
} else {
|
|
243
|
+
if (record.status !== "waiting_for_approval") return {
|
|
244
|
+
applied: false,
|
|
245
|
+
invocation: record
|
|
246
|
+
};
|
|
247
|
+
const due = record.approvalExpiresAt !== void 0 && record.approvalExpiresAt.getTime() <= input.now.getTime();
|
|
248
|
+
if (kind === "expired" && !due) return {
|
|
249
|
+
applied: false,
|
|
250
|
+
invocation: record
|
|
251
|
+
};
|
|
252
|
+
if (due) {
|
|
253
|
+
kind = "expired";
|
|
254
|
+
patch = approvalExpiredPatch(record, input.now);
|
|
255
|
+
} else {
|
|
256
|
+
const decisionInput = input;
|
|
257
|
+
patch = decisionInput.decision.approved ? {
|
|
258
|
+
status: "running",
|
|
259
|
+
error: void 0,
|
|
260
|
+
approvalDecision: structuredClone(decisionInput.decision),
|
|
261
|
+
leaseOwner: decisionInput.leaseOwner,
|
|
262
|
+
leaseToken: (record.leaseToken ?? 0) + 1,
|
|
263
|
+
leaseExpiresAt: new Date(input.now.getTime() + decisionInput.leaseDurationMs),
|
|
264
|
+
attemptCount: Math.max(record.attemptCount, 1)
|
|
265
|
+
} : finalizationSettlementPatch(record, {
|
|
266
|
+
status: "failed",
|
|
267
|
+
approvalDecision: structuredClone(decisionInput.decision),
|
|
268
|
+
error: `Approval rejected: ${decisionInput.decision.reason ?? "no reason provided"}`
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const transitioned = {
|
|
273
|
+
...record,
|
|
274
|
+
...patch,
|
|
275
|
+
updatedAt: new Date(input.now)
|
|
276
|
+
};
|
|
277
|
+
if (Object.hasOwn(patch, "approvalDecision") && patch.approvalDecision === void 0) delete transitioned.approvalDecision;
|
|
278
|
+
if (transitioned.status !== "running") {
|
|
279
|
+
delete transitioned.leaseOwner;
|
|
280
|
+
delete transitioned.leaseExpiresAt;
|
|
281
|
+
}
|
|
282
|
+
await appendLifecycleEvent(transaction, input.lifecycleEvent, transitioned, kind);
|
|
283
|
+
await transaction.updateActionInvocation(record.id, record.tenantId, record.spaceId, patch);
|
|
284
|
+
return {
|
|
285
|
+
applied: true,
|
|
286
|
+
invocation: transitioned
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
async function appendLifecycleEvent(transaction, factory, invocation, kind) {
|
|
290
|
+
if (!factory) return;
|
|
291
|
+
if (!transaction.appendEventWithOutbox) throw new Error("Approval events require atomic outbox writes.");
|
|
292
|
+
const { event, metadata } = factory(invocation, kind);
|
|
293
|
+
await transaction.appendEventWithOutbox({
|
|
294
|
+
...event,
|
|
295
|
+
sequence: await transaction.nextEventSequence(invocation.tenantId, invocation.spaceId)
|
|
296
|
+
}, metadata);
|
|
297
|
+
}
|
|
71
298
|
//#endregion
|
|
72
299
|
//#region src/command-identity.ts
|
|
73
300
|
const PARAMETER_DIGEST_ALGORITHM = "fabric-canonical-json-sha256-v1";
|
|
@@ -107,82 +334,6 @@ function normalizeProvenance(input, options) {
|
|
|
107
334
|
};
|
|
108
335
|
}
|
|
109
336
|
//#endregion
|
|
110
|
-
//#region src/action-finalization.ts
|
|
111
|
-
/** Strict JSON evidence: reject lossy serialization rather than silently dropping values. */
|
|
112
|
-
function finalizationJson(value) {
|
|
113
|
-
const ancestors = /* @__PURE__ */ new Set();
|
|
114
|
-
function check(item) {
|
|
115
|
-
if (item === null || typeof item === "string" || typeof item === "boolean") return;
|
|
116
|
-
if (typeof item === "number" && Number.isFinite(item)) return;
|
|
117
|
-
if (typeof item !== "object" || !item || ancestors.has(item)) throw new Error("Finalization requires JSON-compatible data.");
|
|
118
|
-
if (!Array.isArray(item) && Object.getPrototypeOf(item) !== Object.prototype && Object.getPrototypeOf(item) !== null) throw new Error("Finalization requires plain JSON objects.");
|
|
119
|
-
if (Object.getOwnPropertySymbols(item).length > 0) throw new Error("Finalization requires JSON string keys.");
|
|
120
|
-
if (Array.isArray(item)) {
|
|
121
|
-
const keys = Object.keys(item);
|
|
122
|
-
if (keys.length !== item.length || keys.some((key, index) => key !== String(index))) throw new Error("Finalization requires dense JSON arrays.");
|
|
123
|
-
}
|
|
124
|
-
ancestors.add(item);
|
|
125
|
-
for (const child of Object.values(item)) check(child);
|
|
126
|
-
ancestors.delete(item);
|
|
127
|
-
}
|
|
128
|
-
check(value);
|
|
129
|
-
return JSON.parse(JSON.stringify(value));
|
|
130
|
-
}
|
|
131
|
-
/** Match the persisted JSON representation before validating finalization execution input. */
|
|
132
|
-
function normalizeFinalizationJson(value) {
|
|
133
|
-
return finalizationJson(JSON.parse(JSON.stringify(value)));
|
|
134
|
-
}
|
|
135
|
-
/** Omit only public optional fields; payloads and patch values retain strict JSON validation. */
|
|
136
|
-
function omitUndefinedOptionalFields(value, keys) {
|
|
137
|
-
if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return value;
|
|
138
|
-
const copy = { ...value };
|
|
139
|
-
for (const key of keys) if (Object.hasOwn(copy, key) && copy[key] === void 0) delete copy[key];
|
|
140
|
-
return copy;
|
|
141
|
-
}
|
|
142
|
-
function validateFinalization(value, action, privateFields, contracts) {
|
|
143
|
-
const normalized = omitUndefinedOptionalFields(value, [
|
|
144
|
-
"events",
|
|
145
|
-
"resultPatch",
|
|
146
|
-
"stagedInvocations"
|
|
147
|
-
]);
|
|
148
|
-
if (normalized !== value && normalized && typeof normalized === "object" && !Array.isArray(normalized)) {
|
|
149
|
-
const record = normalized;
|
|
150
|
-
if (Array.isArray(record.events)) {
|
|
151
|
-
const events = Object.assign(record.events.slice(), record.events);
|
|
152
|
-
for (let index = 0; index < events.length; index++) if (Object.hasOwn(events, index)) events[index] = omitUndefinedOptionalFields(events[index], ["eventSchemaVersion"]);
|
|
153
|
-
record.events = events;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
const result = finalizationJson(normalized);
|
|
157
|
-
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.");
|
|
158
|
-
if (result.resultPatch !== void 0) {
|
|
159
|
-
if (!result.resultPatch || typeof result.resultPatch !== "object" || Array.isArray(result.resultPatch)) throw new Error("Finalization resultPatch must be an object.");
|
|
160
|
-
if (Object.keys(result.resultPatch).some((key) => privateFields.includes(key))) throw new Error("Finalization resultPatch contains a private result key.");
|
|
161
|
-
}
|
|
162
|
-
if (result.events !== void 0) {
|
|
163
|
-
if (!Array.isArray(result.events)) throw new Error("Finalization events must be an array.");
|
|
164
|
-
for (const event of result.events) {
|
|
165
|
-
if (!event || typeof event !== "object" || Array.isArray(event) || Object.keys(event).some((key) => ![
|
|
166
|
-
"eventType",
|
|
167
|
-
"subjectType",
|
|
168
|
-
"subjectId",
|
|
169
|
-
"payload",
|
|
170
|
-
"eventSchemaVersion"
|
|
171
|
-
].includes(key))) throw new Error("Invalid finalization event fields.");
|
|
172
|
-
for (const key of [
|
|
173
|
-
"eventType",
|
|
174
|
-
"subjectType",
|
|
175
|
-
"subjectId"
|
|
176
|
-
]) if (typeof event[key] !== "string" || !event[key].trim()) throw new Error("Finalization event identifiers must be non-empty.");
|
|
177
|
-
if (!Object.hasOwn(event, "payload")) throw new Error("Finalization event requires a JSON payload.");
|
|
178
|
-
if (event.eventSchemaVersion !== void 0 && (!Number.isInteger(event.eventSchemaVersion) || event.eventSchemaVersion <= 0)) throw new Error("Finalization eventSchemaVersion must be a positive integer.");
|
|
179
|
-
if (!action.emitsEvents.includes(event.eventType)) throw new Error("Finalization emitted undeclared event type.");
|
|
180
|
-
if (contracts && (!contracts.hasEventType(event.eventType) || !contracts.hasSubjectType(event.subjectType))) throw new Error("Finalization emitted an unregistered event or subject type.");
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
return result;
|
|
184
|
-
}
|
|
185
|
-
//#endregion
|
|
186
337
|
//#region src/observability.ts
|
|
187
338
|
/** Version of the vendor-neutral health/readiness contract. */
|
|
188
339
|
const PLATFORM_HOST_HEALTH_CONTRACT_VERSION = 1;
|
|
@@ -589,7 +740,7 @@ function createExternalCompletionLifecycle(deps) {
|
|
|
589
740
|
externalCompletion,
|
|
590
741
|
pendingCompletion: void 0
|
|
591
742
|
};
|
|
592
|
-
await writer.update(patch);
|
|
743
|
+
await writer.update(finalizationSettlementPatch(invocation, patch));
|
|
593
744
|
if (settles) emitInvocationStatusTelemetry(invocation, "completed", deps.telemetry, now(), invocation.status);
|
|
594
745
|
return withConsistency(actionResult({
|
|
595
746
|
...invocation,
|
|
@@ -626,7 +777,7 @@ function createExternalCompletionLifecycle(deps) {
|
|
|
626
777
|
externalCompletion,
|
|
627
778
|
pendingCompletion: void 0
|
|
628
779
|
};
|
|
629
|
-
await writer.update(patch);
|
|
780
|
+
await writer.update(finalizationSettlementPatch(invocation, patch));
|
|
630
781
|
if (settles) emitInvocationStatusTelemetry(invocation, "failed", deps.telemetry, now(), invocation.status);
|
|
631
782
|
return withConsistency(actionResult({
|
|
632
783
|
...invocation,
|
|
@@ -1322,6 +1473,11 @@ const STAGED_ERROR = Object.freeze({
|
|
|
1322
1473
|
lookup: "Staged child lookup failed."
|
|
1323
1474
|
});
|
|
1324
1475
|
function createGovernedActionHost(options) {
|
|
1476
|
+
if (options.approvalDefaultTtlMs !== void 0 && (!Number.isSafeInteger(options.approvalDefaultTtlMs) || options.approvalDefaultTtlMs <= 0)) throw new Error("approvalDefaultTtlMs must be a positive safe integer in milliseconds.");
|
|
1477
|
+
if (options.approvalLifecycleEvents) {
|
|
1478
|
+
const approvalStore = asApprovalStore(options.store);
|
|
1479
|
+
if (!approvalStore || approvalStore.transactionalApprovalLifecycle !== true || typeof approvalStore.parkApproval !== "function" || typeof approvalStore.expireDueApprovals !== "function" || asOutboxStore(options.store)?.transactionalOutbox !== true || !asAtomicMutationStore(options.store)) throw new Error("Approval lifecycle events require transactionalOutbox, transactionWithEvents, and atomic approval lifecycle capabilities.");
|
|
1480
|
+
}
|
|
1325
1481
|
const retryableExecutionMaxAttempts = options.retryableExecutionMaxAttempts ?? 5;
|
|
1326
1482
|
if (!Number.isInteger(retryableExecutionMaxAttempts) || retryableExecutionMaxAttempts <= 0) throw new Error("retryableExecutionMaxAttempts must be a positive integer.");
|
|
1327
1483
|
const recoveryMaxAttempts = options.recoveryMaxAttempts ?? Math.max(5, retryableExecutionMaxAttempts);
|
|
@@ -1697,6 +1853,7 @@ function createGovernedActionHost(options) {
|
|
|
1697
1853
|
};
|
|
1698
1854
|
}
|
|
1699
1855
|
async function persistExecutionInvocation(record, patch) {
|
|
1856
|
+
patch = finalizationSettlementPatch(record, patch);
|
|
1700
1857
|
const externalCompletionDigest = record.externalCompletion?.digest;
|
|
1701
1858
|
if (await persistInvocation(record, patch, onPreservedReconciliation, executionLease)) rememberVerdict(patch, externalCompletionDigest);
|
|
1702
1859
|
}
|
|
@@ -2109,7 +2266,7 @@ function createGovernedActionHost(options) {
|
|
|
2109
2266
|
}
|
|
2110
2267
|
const replayCheckpoint = action.finalize && (resumingRunningInvocation || invocation.attemptCount > 1) ? invocation.finalizationCheckpoint : void 0;
|
|
2111
2268
|
if (replayCheckpoint) {
|
|
2112
|
-
if (replayCheckpoint.protocol
|
|
2269
|
+
if (!["atomic-after-adapters-v1", "atomic-after-adapters-v2"].includes(replayCheckpoint.protocol) || replayCheckpoint.protocol === "atomic-after-adapters-v2" !== Object.hasOwn(replayCheckpoint, "adapterPreparation") || replayCheckpoint.actionVersion !== action.version || replayCheckpoint.steps.length !== (action.adapterSteps ?? []).length || replayCheckpoint.steps.some((step, index) => step.adapterType !== action.adapterSteps[index]?.adapterType || step.operation !== action.adapterSteps[index]?.operation) || invocation.pendingCompletion) return interruptedNonIdempotent("Finalization checkpoint protocol, action version, step identity or handoff is incompatible with replay.");
|
|
2113
2270
|
}
|
|
2114
2271
|
if (action.finalize && !replayCheckpoint && (resumingRunningInvocation || invocation.attemptCount > 1) && hasDurableProviderEffect) return interruptedNonIdempotent("Finalizing action recovery requires a preparation checkpoint; reconciliation is required.");
|
|
2115
2272
|
if (action.admitSubmission !== void 0 && !isAdmissionTime(invocation.admittedAt)) return hostOperation(() => failExecution(invocation, "failed", `Invocation of ${action.actionId} was not admitted at submission; an action that declares admitSubmission runs only on invocations its check admitted.`));
|
|
@@ -2180,6 +2337,7 @@ function createGovernedActionHost(options) {
|
|
|
2180
2337
|
const hitlParameters = isRecord(parsed.data) ? parsed.data : invocation.parameters;
|
|
2181
2338
|
const approvalStore = asApprovalStore(options.store);
|
|
2182
2339
|
if (!approvalStore) return hostOperation(() => failExecution(invocation, "failed", "The configured HITL evaluator requires a store with approval persistence support."));
|
|
2340
|
+
let evaluatedApprovalExpiresAt;
|
|
2183
2341
|
if (!invocation.hitlRoute) {
|
|
2184
2342
|
const decision = await options.hitlEvaluator({
|
|
2185
2343
|
actionId: action.actionId,
|
|
@@ -2193,6 +2351,8 @@ function createGovernedActionHost(options) {
|
|
|
2193
2351
|
...stringValue(hitlParameters.agentSessionId) ? { agentSessionId: stringValue(hitlParameters.agentSessionId) } : {},
|
|
2194
2352
|
...stringValue(hitlParameters.agentRunId) ? { agentRunId: stringValue(hitlParameters.agentRunId) } : {}
|
|
2195
2353
|
});
|
|
2354
|
+
if (decision.approvalExpiresAt !== void 0 && !validApprovalDate(decision.approvalExpiresAt)) return hostOperation(() => failExecution(invocation, "failed", "approvalExpiresAt must be a valid Date."));
|
|
2355
|
+
evaluatedApprovalExpiresAt = decision.approvalExpiresAt === void 0 ? void 0 : new Date(decision.approvalExpiresAt);
|
|
2196
2356
|
invocation = { ...await hostOperation(() => approvalStore.recordHitlDecision(actionInvocationId, tenantId, spaceId, {
|
|
2197
2357
|
...decision,
|
|
2198
2358
|
...options.hitlPolicyVersion ? { policyVersion: options.hitlPolicyVersion } : {},
|
|
@@ -2201,11 +2361,33 @@ function createGovernedActionHost(options) {
|
|
|
2201
2361
|
}
|
|
2202
2362
|
if (invocation.hitlRoute === "rejected") return hostOperation(() => failExecution(invocation, "failed", `HITL rejected: ${invocation.hitlReason ?? "no reason provided"}`));
|
|
2203
2363
|
if ((invocation.hitlRoute === "needs-approval" || invocation.hitlRoute === "escalate") && !invocation.approvalDecision?.approved) {
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2364
|
+
const parkedAt = now();
|
|
2365
|
+
const approvalExpiresAt = invocation.approvalExpiresAt ?? evaluatedApprovalExpiresAt ?? (options.approvalDefaultTtlMs !== void 0 ? new Date(parkedAt.getTime() + options.approvalDefaultTtlMs) : void 0);
|
|
2366
|
+
if (evaluatedApprovalExpiresAt !== void 0 && invocation.approvalExpiresAt === void 0) return hostOperation(() => failExecution(invocation, "failed", "The configured HITL evaluator requires a store with approval deadline persistence support."));
|
|
2367
|
+
if (approvalExpiresAt && !validApprovalDate(approvalExpiresAt)) return hostOperation(() => failExecution(invocation, "failed", "Approval deadline exceeds the supported Date range."));
|
|
2368
|
+
if (approvalExpiresAt && (!approvalStore.expireDueApprovals || !approvalStore.parkApproval)) return hostOperation(() => failExecution(invocation, "failed", "Approval deadlines require a store with approval expiry support."));
|
|
2369
|
+
if (approvalStore.parkApproval) {
|
|
2370
|
+
let transition;
|
|
2371
|
+
try {
|
|
2372
|
+
transition = await approvalStore.parkApproval({
|
|
2373
|
+
actionInvocationId,
|
|
2374
|
+
tenantId,
|
|
2375
|
+
spaceId,
|
|
2376
|
+
now: parkedAt,
|
|
2377
|
+
...approvalExpiresAt ? { approvalExpiresAt } : {},
|
|
2378
|
+
...executionLease,
|
|
2379
|
+
...options.approvalLifecycleEvents ? { lifecycleEvent: approvalLifecycleEvent } : {}
|
|
2380
|
+
});
|
|
2381
|
+
} catch (error) {
|
|
2382
|
+
throw new RecoverableFinalizationError(caughtError({
|
|
2383
|
+
actionId: invocation.actionId,
|
|
2384
|
+
source: "execution"
|
|
2385
|
+
}, error));
|
|
2386
|
+
}
|
|
2387
|
+
invocation = transition.invocation ?? await options.store.getActionInvocation(actionInvocationId, tenantId, spaceId) ?? invocation;
|
|
2388
|
+
if (transition.applied) emitInvocationStatusTelemetry(invocation, "waiting_for_approval", options.telemetry, parkedAt);
|
|
2389
|
+
} else await hostOperation(() => persistExecutionInvocation(invocation, { status: "waiting_for_approval" }));
|
|
2390
|
+
return actionResult(invocation);
|
|
2209
2391
|
}
|
|
2210
2392
|
}
|
|
2211
2393
|
const authorizationInput = toAuthorizationInput(action, invocation);
|
|
@@ -2438,13 +2620,23 @@ function createGovernedActionHost(options) {
|
|
|
2438
2620
|
...isAdmissionTime(invocation.admittedAt) ? { admittedAt: invocation.admittedAt } : {},
|
|
2439
2621
|
db,
|
|
2440
2622
|
services: options.services,
|
|
2441
|
-
...action.finalize ? {
|
|
2623
|
+
...action.finalize ? {
|
|
2624
|
+
finalizationProtocol: "atomic-after-adapters-v1",
|
|
2625
|
+
adapterPreparationProtocol: "v1"
|
|
2626
|
+
} : {}
|
|
2442
2627
|
}, parsed.data) : {
|
|
2443
2628
|
success: true,
|
|
2444
2629
|
data: {}
|
|
2445
2630
|
};
|
|
2446
2631
|
if (!handlerResult.success) throw handlerResult.error != null ? new Error(handlerResult.error) : new HostError("Action handler failed");
|
|
2447
2632
|
const handlerData = handlerResult.data ?? {};
|
|
2633
|
+
let adapterPreparation;
|
|
2634
|
+
if (handlerResult.adapterPreparation !== void 0) {
|
|
2635
|
+
if (!action.finalize) throw new HostError("adapterPreparation requires a finalizing action.");
|
|
2636
|
+
const value = handlerResult.adapterPreparation;
|
|
2637
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new HostError("adapterPreparation must be a plain JSON object.");
|
|
2638
|
+
adapterPreparation = finalizationJson(value);
|
|
2639
|
+
}
|
|
2448
2640
|
const rawStagedInvocations = handlerResult.stagedInvocations ?? [];
|
|
2449
2641
|
const stagedRecords = prepareStagedInvocationBatch({
|
|
2450
2642
|
parent: invocation,
|
|
@@ -2498,12 +2690,12 @@ function createGovernedActionHost(options) {
|
|
|
2498
2690
|
if (preparedFinalizationInput) {
|
|
2499
2691
|
const skipped = [];
|
|
2500
2692
|
for (const [index, step] of (action.adapterSteps ?? []).entries()) {
|
|
2501
|
-
const input = step.getInput(structuredClone(preparedFinalizationInput.params), structuredClone(preparedFinalizationInput.handlerData));
|
|
2693
|
+
const input = step.getInput(structuredClone(preparedFinalizationInput.params), structuredClone(preparedFinalizationInput.handlerData), structuredClone(adapterPreparation));
|
|
2502
2694
|
preparedInputs.set(index, input);
|
|
2503
2695
|
if (!input) skipped.push(index);
|
|
2504
2696
|
}
|
|
2505
2697
|
preparation = normalizeFinalizationJson({
|
|
2506
|
-
protocol: "atomic-after-adapters-v1",
|
|
2698
|
+
protocol: adapterPreparation !== void 0 ? "atomic-after-adapters-v2" : "atomic-after-adapters-v1",
|
|
2507
2699
|
actionVersion: action.version,
|
|
2508
2700
|
steps: (action.adapterSteps ?? []).map(({ adapterType, operation }) => ({
|
|
2509
2701
|
adapterType,
|
|
@@ -2512,6 +2704,7 @@ function createGovernedActionHost(options) {
|
|
|
2512
2704
|
...preparedFinalizationInput,
|
|
2513
2705
|
handlerEvents: events,
|
|
2514
2706
|
result: preparedFinalizationInput.handlerData,
|
|
2707
|
+
...adapterPreparation !== void 0 ? { adapterPreparation } : {},
|
|
2515
2708
|
...transitionEvent ? { stateTransition: transitionEvent } : {},
|
|
2516
2709
|
skipped,
|
|
2517
2710
|
...handlerStagedInvocations.length > 0 ? { stagedInvocations: handlerStagedInvocations } : {}
|
|
@@ -2661,7 +2854,7 @@ function createGovernedActionHost(options) {
|
|
|
2661
2854
|
}, error));
|
|
2662
2855
|
}
|
|
2663
2856
|
if (previousAdapterInvocation?.status === "ambiguous") return await settleUnknownOutcome(stepIndex, recordedAdapterReconciliation(previousAdapterInvocation), previousAdapterInvocation.reconciliation?.adapterEventSubject);
|
|
2664
|
-
const input = action.finalize ? preparation.skipped.includes(stepIndex) ? void 0 : replayCheckpoint ? step.getInput(structuredClone(replayCheckpoint.params), structuredClone(replayCheckpoint.handlerData)) : preparedInputs.get(stepIndex) : step.getInput(parsed.data, data);
|
|
2857
|
+
const input = action.finalize ? preparation.skipped.includes(stepIndex) ? void 0 : replayCheckpoint ? step.getInput(structuredClone(replayCheckpoint.params), structuredClone(replayCheckpoint.handlerData), structuredClone(replayCheckpoint.adapterPreparation)) : preparedInputs.get(stepIndex) : step.getInput(parsed.data, data);
|
|
2665
2858
|
if (!input) {
|
|
2666
2859
|
if (action.finalize) finalizationSteps.push({
|
|
2667
2860
|
stepIndex,
|
|
@@ -3253,14 +3446,14 @@ function createGovernedActionHost(options) {
|
|
|
3253
3446
|
...pendingHandoff,
|
|
3254
3447
|
parkedAt: now()
|
|
3255
3448
|
} : void 0;
|
|
3256
|
-
const finalPatch = parked ? {
|
|
3449
|
+
const finalPatch = finalizationSettlementPatch(invocation, parked ? {
|
|
3257
3450
|
status: "running",
|
|
3258
3451
|
result,
|
|
3259
3452
|
pendingCompletion: parked
|
|
3260
3453
|
} : {
|
|
3261
3454
|
status: "completed",
|
|
3262
3455
|
result
|
|
3263
|
-
};
|
|
3456
|
+
});
|
|
3264
3457
|
let verdictWritten = true;
|
|
3265
3458
|
let finalizationCallbackFailure;
|
|
3266
3459
|
try {
|
|
@@ -3421,6 +3614,7 @@ function createGovernedActionHost(options) {
|
|
|
3421
3614
|
}
|
|
3422
3615
|
}
|
|
3423
3616
|
async function resumeApprovedInvocation(actionInvocationId, tenantId, spaceId, decision) {
|
|
3617
|
+
decision = snapshotApprovalDecision(decision);
|
|
3424
3618
|
if (!decision.approverId.trim()) throw new Error("approverId is required");
|
|
3425
3619
|
const invocation = await options.store.getActionInvocation(actionInvocationId, tenantId, spaceId);
|
|
3426
3620
|
if (!invocation) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
|
|
@@ -3430,7 +3624,13 @@ function createGovernedActionHost(options) {
|
|
|
3430
3624
|
error: `Invocation is not waiting for approval (status: ${invocation.status})`
|
|
3431
3625
|
};
|
|
3432
3626
|
const action = actionResolver(invocation.actionId);
|
|
3433
|
-
if (!action)
|
|
3627
|
+
if (!action) {
|
|
3628
|
+
const error = `Unknown action: ${invocation.actionId}`;
|
|
3629
|
+
return invocation.approvalExpiresAt === void 0 ? fail(invocation, "failed", error) : {
|
|
3630
|
+
...actionResult(invocation),
|
|
3631
|
+
error
|
|
3632
|
+
};
|
|
3633
|
+
}
|
|
3434
3634
|
const approvalAuthorizationInput = toAuthorizationInput(action, {
|
|
3435
3635
|
tenantId,
|
|
3436
3636
|
spaceId,
|
|
@@ -3440,7 +3640,9 @@ function createGovernedActionHost(options) {
|
|
|
3440
3640
|
const entitled = await options.authorization.checkEntitlement(approvalAuthorizationInput);
|
|
3441
3641
|
const authorized = options.authorization.authorizeApproval ? await options.authorization.authorizeApproval({
|
|
3442
3642
|
...approvalAuthorizationInput,
|
|
3443
|
-
|
|
3643
|
+
actionInvocationId,
|
|
3644
|
+
decision: structuredClone(decision),
|
|
3645
|
+
invocation: structuredClone(invocation)
|
|
3444
3646
|
}) : await options.authorization.authorize(approvalAuthorizationInput);
|
|
3445
3647
|
if (!entitled || !authorized) return {
|
|
3446
3648
|
...actionResult(invocation),
|
|
@@ -3464,14 +3666,15 @@ function createGovernedActionHost(options) {
|
|
|
3464
3666
|
},
|
|
3465
3667
|
leaseOwner,
|
|
3466
3668
|
leaseDurationMs: options.approvalResumeLeaseDurationMs ?? 3e5,
|
|
3467
|
-
now: transitionStartedAt
|
|
3669
|
+
now: transitionStartedAt,
|
|
3670
|
+
...options.approvalLifecycleEvents ? { lifecycleEvent: approvalLifecycleEvent } : {}
|
|
3468
3671
|
});
|
|
3469
3672
|
const transitioned = transition.invocation ?? await options.store.getActionInvocation(actionInvocationId, tenantId, spaceId);
|
|
3470
3673
|
if (!transition.applied || !transitioned) {
|
|
3471
3674
|
if (!transitioned) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
|
|
3472
3675
|
return withConsistency(actionResult(transitioned), transitioned.actionId);
|
|
3473
3676
|
}
|
|
3474
|
-
if (!decision.approved) {
|
|
3677
|
+
if (transitioned.approvalExpiry || !decision.approved) {
|
|
3475
3678
|
emitInvocationStatusTelemetry(transitioned, "failed", options.telemetry, now(), "waiting_for_approval");
|
|
3476
3679
|
return withConsistency(actionResult(transitioned), transitioned.actionId);
|
|
3477
3680
|
}
|
|
@@ -3523,9 +3726,73 @@ function createGovernedActionHost(options) {
|
|
|
3523
3726
|
consistency
|
|
3524
3727
|
} : result;
|
|
3525
3728
|
}
|
|
3526
|
-
|
|
3527
|
-
const
|
|
3528
|
-
const
|
|
3729
|
+
function eventOutboxMetadata(invocation, envelope) {
|
|
3730
|
+
const traceContext = options.outbox?.traceContext?.(envelope);
|
|
3731
|
+
const payloadClassification = options.outbox?.classifyPayload(envelope) ?? "internal";
|
|
3732
|
+
return {
|
|
3733
|
+
producerModuleVersion: options.outbox?.producerModuleVersion(invocation.actionId, invocation.actionVersion) ?? String(invocation.actionVersion),
|
|
3734
|
+
payloadClassification,
|
|
3735
|
+
includeProvenance: options.outbox?.includeProvenance?.(envelope, payloadClassification) ?? payloadClassification !== "restricted",
|
|
3736
|
+
includeActor: options.outbox?.includeActor?.(envelope, payloadClassification) === true,
|
|
3737
|
+
...traceContext ? { traceContext } : {}
|
|
3738
|
+
};
|
|
3739
|
+
}
|
|
3740
|
+
const approvalLifecycleEvent = (invocation, kind) => {
|
|
3741
|
+
let eventType;
|
|
3742
|
+
let payload;
|
|
3743
|
+
if (kind === "requested") {
|
|
3744
|
+
eventType = APPROVAL_REQUESTED_EVENT;
|
|
3745
|
+
payload = {
|
|
3746
|
+
route: invocation.hitlRoute,
|
|
3747
|
+
riskTier: invocation.hitlRiskTier,
|
|
3748
|
+
reason: invocation.hitlReason ?? "",
|
|
3749
|
+
...invocation.approvalExpiresAt ? { expiresAt: invocation.approvalExpiresAt.toISOString() } : {}
|
|
3750
|
+
};
|
|
3751
|
+
} else if (kind === "decided") {
|
|
3752
|
+
eventType = APPROVAL_DECIDED_EVENT;
|
|
3753
|
+
const decision = invocation.approvalDecision;
|
|
3754
|
+
payload = {
|
|
3755
|
+
approved: decision.approved,
|
|
3756
|
+
approverId: decision.approverId,
|
|
3757
|
+
approverType: decision.approverType,
|
|
3758
|
+
...decision.reason !== void 0 ? { reason: decision.reason } : {},
|
|
3759
|
+
decidedAt: decision.decidedAt.toISOString(),
|
|
3760
|
+
evidencePresent: decision.evidence !== void 0
|
|
3761
|
+
};
|
|
3762
|
+
} else {
|
|
3763
|
+
eventType = APPROVAL_EXPIRED_EVENT;
|
|
3764
|
+
payload = {
|
|
3765
|
+
expiresAt: invocation.approvalExpiry.expiresAt.toISOString(),
|
|
3766
|
+
expiredAt: invocation.approvalExpiry.expiredAt.toISOString()
|
|
3767
|
+
};
|
|
3768
|
+
}
|
|
3769
|
+
const event = buildEventEnvelope(invocation, {
|
|
3770
|
+
eventType,
|
|
3771
|
+
subjectType: "ActionInvocation",
|
|
3772
|
+
subjectId: invocation.id,
|
|
3773
|
+
payload
|
|
3774
|
+
}, `approval:${kind}`, 0, kind === "expired" ? invocation.approvalExpiry.expiredAt : invocation.updatedAt);
|
|
3775
|
+
return {
|
|
3776
|
+
event,
|
|
3777
|
+
metadata: eventOutboxMetadata(invocation, event)
|
|
3778
|
+
};
|
|
3779
|
+
};
|
|
3780
|
+
async function expireDueApprovals(input = {}) {
|
|
3781
|
+
const store = asApprovalStore(options.store);
|
|
3782
|
+
if (!store?.expireDueApprovals) {
|
|
3783
|
+
if (options.approvalDefaultTtlMs !== void 0 || options.approvalLifecycleEvents) throw new HostError("Approval expiry requires a store with approval expiry support.");
|
|
3784
|
+
return { expired: [] };
|
|
3785
|
+
}
|
|
3786
|
+
const expired = await store.expireDueApprovals({
|
|
3787
|
+
...input,
|
|
3788
|
+
now: input.now ?? now(),
|
|
3789
|
+
...options.approvalLifecycleEvents ? { lifecycleEvent: approvalLifecycleEvent } : {}
|
|
3790
|
+
});
|
|
3791
|
+
for (const invocation of expired) emitInvocationStatusTelemetry(invocation, "failed", options.telemetry, invocation.approvalExpiry.expiredAt, "waiting_for_approval");
|
|
3792
|
+
return { expired: expired.map((invocation) => invocation.id) };
|
|
3793
|
+
}
|
|
3794
|
+
function buildEventEnvelope(invocation, event, deduplicationKey, sequence, timestamp, defaultEventSchemaVersion = 1) {
|
|
3795
|
+
return {
|
|
3529
3796
|
id: lifecycleId("evt", invocation.id, deduplicationKey),
|
|
3530
3797
|
tenantId: invocation.tenantId,
|
|
3531
3798
|
spaceId: invocation.spaceId,
|
|
@@ -3537,7 +3804,7 @@ function createGovernedActionHost(options) {
|
|
|
3537
3804
|
actorType: invocation.actorType,
|
|
3538
3805
|
actionInvocationId: invocation.id,
|
|
3539
3806
|
payload: event.payload,
|
|
3540
|
-
sequence
|
|
3807
|
+
sequence,
|
|
3541
3808
|
occurredAt: timestamp,
|
|
3542
3809
|
recordedAt: timestamp,
|
|
3543
3810
|
correlationId: invocation.correlationId,
|
|
@@ -3548,6 +3815,10 @@ function createGovernedActionHost(options) {
|
|
|
3548
3815
|
} } : {},
|
|
3549
3816
|
...declaredConsistency(invocation.actionId) === "provisional-until-reconciled" ? { consistency: "provisional-until-reconciled" } : {}
|
|
3550
3817
|
};
|
|
3818
|
+
}
|
|
3819
|
+
async function appendEvent(invocation, event, deduplicationKey, defaultEventSchemaVersion = 1, transaction) {
|
|
3820
|
+
const timestamp = now();
|
|
3821
|
+
const envelope = buildEventEnvelope(invocation, event, deduplicationKey, await (transaction ?? options.store).nextEventSequence(invocation.tenantId, invocation.spaceId), timestamp, defaultEventSchemaVersion);
|
|
3551
3822
|
if (options.outbox) {
|
|
3552
3823
|
const hostLifecycleEvent = (/* @__PURE__ */ new Set([
|
|
3553
3824
|
"AdapterInvocationStarted",
|
|
@@ -3563,15 +3834,7 @@ function createGovernedActionHost(options) {
|
|
|
3563
3834
|
await (transaction ?? options.store).appendEvent(envelope);
|
|
3564
3835
|
return;
|
|
3565
3836
|
}
|
|
3566
|
-
const
|
|
3567
|
-
const payloadClassification = options.outbox.classifyPayload(envelope);
|
|
3568
|
-
const metadata = {
|
|
3569
|
-
producerModuleVersion: options.outbox.producerModuleVersion(invocation.actionId, invocation.actionVersion),
|
|
3570
|
-
payloadClassification,
|
|
3571
|
-
includeProvenance: options.outbox.includeProvenance?.(envelope, payloadClassification) ?? payloadClassification !== "restricted",
|
|
3572
|
-
includeActor: options.outbox.includeActor?.(envelope, payloadClassification) === true,
|
|
3573
|
-
...traceContext ? { traceContext } : {}
|
|
3574
|
-
};
|
|
3837
|
+
const metadata = eventOutboxMetadata(invocation, envelope);
|
|
3575
3838
|
if (transaction) {
|
|
3576
3839
|
if (!transaction.appendEventWithOutbox) throw new HostError("Outbox egress requires an atomic transaction with appendEventWithOutbox.");
|
|
3577
3840
|
await transaction.appendEventWithOutbox(envelope, metadata);
|
|
@@ -3634,6 +3897,7 @@ function createGovernedActionHost(options) {
|
|
|
3634
3897
|
return !evidenceOnly;
|
|
3635
3898
|
}
|
|
3636
3899
|
async function persistInvocation(invocation, patch, onPreservedReconciliation, lease) {
|
|
3900
|
+
patch = finalizationSettlementPatch(invocation, patch);
|
|
3637
3901
|
const previousStatus = invocation.status;
|
|
3638
3902
|
const identity = lease ?? (invocation.leaseOwner && invocation.leaseToken !== void 0 ? {
|
|
3639
3903
|
leaseOwner: invocation.leaseOwner,
|
|
@@ -3949,6 +4213,7 @@ function createGovernedActionHost(options) {
|
|
|
3949
4213
|
...stagedStore ? { dispatchStagedInvocations } : {},
|
|
3950
4214
|
executeInvocation,
|
|
3951
4215
|
resumeApprovedInvocation,
|
|
4216
|
+
expireDueApprovals,
|
|
3952
4217
|
recordExecutionAttestation,
|
|
3953
4218
|
recordExternalReconciliation,
|
|
3954
4219
|
completeExternalInvocation: externalCompletion.completeExternalInvocation,
|
|
@@ -4276,6 +4541,8 @@ function emitRelayTelemetry(telemetry, name, occurredAt, workerId, attributes) {
|
|
|
4276
4541
|
var MemoryPlatformHostStore = class {
|
|
4277
4542
|
db;
|
|
4278
4543
|
transactionalOutbox;
|
|
4544
|
+
transactionalApprovalLifecycle;
|
|
4545
|
+
approvalTransactionProvider;
|
|
4279
4546
|
transactionalStagedInvocations;
|
|
4280
4547
|
adapterCommit;
|
|
4281
4548
|
transactionTail = Promise.resolve();
|
|
@@ -4298,6 +4565,11 @@ var MemoryPlatformHostStore = class {
|
|
|
4298
4565
|
constructor(db, transactionProvider) {
|
|
4299
4566
|
this.db = db;
|
|
4300
4567
|
this.transactionalOutbox = transactionProvider !== void 0;
|
|
4568
|
+
this.transactionalApprovalLifecycle = transactionProvider !== void 0;
|
|
4569
|
+
this.approvalTransactionProvider = transactionProvider ?? {
|
|
4570
|
+
snapshot: (db) => db,
|
|
4571
|
+
restore: () => void 0
|
|
4572
|
+
};
|
|
4301
4573
|
this.transactionalStagedInvocations = transactionProvider !== void 0;
|
|
4302
4574
|
if (transactionProvider) this.transactionWithEvents = (run) => this.runTransactionWithEvents(run, transactionProvider);
|
|
4303
4575
|
}
|
|
@@ -4544,6 +4816,15 @@ var MemoryPlatformHostStore = class {
|
|
|
4544
4816
|
const record = this.invocations.find((row) => row.id === id && row.tenantId === tenantId && row.spaceId === spaceId);
|
|
4545
4817
|
if (!record) throw new Error(`ActionInvocation not found: ${id}`);
|
|
4546
4818
|
Object.assign(record, patch, { updatedAt: /* @__PURE__ */ new Date() });
|
|
4819
|
+
for (const key of [
|
|
4820
|
+
"approvalExpiresAt",
|
|
4821
|
+
"approvalExpiry",
|
|
4822
|
+
"approvalDecision"
|
|
4823
|
+
]) {
|
|
4824
|
+
if (!Object.hasOwn(patch, key)) continue;
|
|
4825
|
+
if (patch[key] === void 0) delete record[key];
|
|
4826
|
+
else Object.assign(record, { [key]: structuredClone(patch[key]) });
|
|
4827
|
+
}
|
|
4547
4828
|
if (Object.hasOwn(patch, "finalizationCheckpoint")) {
|
|
4548
4829
|
if (patch.finalizationCheckpoint) record.finalizationCheckpoint = structuredClone(patch.finalizationCheckpoint);
|
|
4549
4830
|
else delete record.finalizationCheckpoint;
|
|
@@ -4568,6 +4849,8 @@ var MemoryPlatformHostStore = class {
|
|
|
4568
4849
|
async recordHitlDecision(actionInvocationId, tenantId, spaceId, decision) {
|
|
4569
4850
|
const record = await this.getActionInvocation(actionInvocationId, tenantId, spaceId);
|
|
4570
4851
|
if (!record) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
|
|
4852
|
+
if (record.hitlRoute || !["pending", "running"].includes(record.status)) return record;
|
|
4853
|
+
if (decision.approvalExpiresAt) record.approvalExpiresAt = new Date(decision.approvalExpiresAt);
|
|
4571
4854
|
record.hitlRoute = decision.route;
|
|
4572
4855
|
record.hitlRiskTier = decision.riskTier;
|
|
4573
4856
|
record.hitlReason = decision.reason;
|
|
@@ -4575,34 +4858,34 @@ var MemoryPlatformHostStore = class {
|
|
|
4575
4858
|
record.updatedAt = decision.evaluatedAt;
|
|
4576
4859
|
return record;
|
|
4577
4860
|
}
|
|
4861
|
+
runApprovalTransaction(run) {
|
|
4862
|
+
return this.transactionWithEvents ? this.transactionWithEvents(run) : this.runTransactionWithEvents(run, this.approvalTransactionProvider);
|
|
4863
|
+
}
|
|
4864
|
+
async parkApproval(input) {
|
|
4865
|
+
if (input.lifecycleEvent && !this.transactionalApprovalLifecycle) throw new Error("Approval events require transactional approval support.");
|
|
4866
|
+
return this.runApprovalTransaction((tx) => applyApprovalTransition(tx, input, "requested"));
|
|
4867
|
+
}
|
|
4578
4868
|
async beginApprovalDecision(input) {
|
|
4579
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
|
|
4590
|
-
|
|
4591
|
-
|
|
4592
|
-
|
|
4593
|
-
|
|
4594
|
-
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
4599
|
-
record.leaseExpiresAt = new Date(input.now.getTime() + input.leaseDurationMs);
|
|
4600
|
-
record.leaseToken = (record.leaseToken ?? 0) + 1;
|
|
4601
|
-
record.attemptCount = Math.max(record.attemptCount, 1);
|
|
4602
|
-
return {
|
|
4603
|
-
applied: true,
|
|
4604
|
-
invocation: record
|
|
4605
|
-
};
|
|
4869
|
+
if (input.lifecycleEvent && !this.transactionalApprovalLifecycle) throw new Error("Approval events require transactional approval support.");
|
|
4870
|
+
return this.runApprovalTransaction((tx) => applyApprovalTransition(tx, input, "decided"));
|
|
4871
|
+
}
|
|
4872
|
+
async expireDueApprovals(input) {
|
|
4873
|
+
const limit = approvalSweepLimit(input);
|
|
4874
|
+
if (input.lifecycleEvent && !this.transactionalApprovalLifecycle) throw new Error("Approval events require transactional approval support.");
|
|
4875
|
+
const candidates = this.invocations.filter((row) => row.status === "waiting_for_approval" && row.approvalExpiresAt && row.approvalExpiresAt <= input.now && (input.tenantId === void 0 || row.tenantId === input.tenantId) && (input.spaceId === void 0 || row.spaceId === input.spaceId)).sort((a, b) => a.approvalExpiresAt.getTime() - b.approvalExpiresAt.getTime() || a.id.localeCompare(b.id)).slice(0, limit);
|
|
4876
|
+
return this.runApprovalTransaction(async (tx) => {
|
|
4877
|
+
const expired = [];
|
|
4878
|
+
for (const row of candidates) {
|
|
4879
|
+
const transition = await applyApprovalTransition(tx, {
|
|
4880
|
+
...input,
|
|
4881
|
+
actionInvocationId: row.id,
|
|
4882
|
+
tenantId: row.tenantId,
|
|
4883
|
+
spaceId: row.spaceId
|
|
4884
|
+
}, "expired");
|
|
4885
|
+
if (transition.applied) expired.push(transition.invocation);
|
|
4886
|
+
}
|
|
4887
|
+
return expired;
|
|
4888
|
+
});
|
|
4606
4889
|
}
|
|
4607
4890
|
async appendPolicyEvaluation(record) {
|
|
4608
4891
|
if (this.policyEvaluations.some((candidate) => candidate.id === record.id)) return;
|
|
@@ -5052,16 +5335,19 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
5052
5335
|
sql;
|
|
5053
5336
|
transactionWithEvents;
|
|
5054
5337
|
transactionalOutbox;
|
|
5338
|
+
transactionalApprovalLifecycle;
|
|
5055
5339
|
transactionalStagedInvocations;
|
|
5056
5340
|
constructor(db, sql, transactionProvider) {
|
|
5057
5341
|
this.db = db;
|
|
5058
5342
|
this.sql = sql;
|
|
5059
5343
|
this.transactionalOutbox = transactionProvider !== void 0;
|
|
5344
|
+
this.transactionalApprovalLifecycle = transactionProvider !== void 0;
|
|
5060
5345
|
this.transactionalStagedInvocations = transactionProvider !== void 0;
|
|
5061
5346
|
if (transactionProvider) this.transactionWithEvents = async (run) => transactionProvider.run(async ({ db, sql }) => {
|
|
5062
5347
|
const scoped = new PostgresPlatformHostStore(db, sql);
|
|
5063
5348
|
return run({
|
|
5064
5349
|
db,
|
|
5350
|
+
beginApprovalDecision: (input) => scoped.beginApprovalDecision(input),
|
|
5065
5351
|
getActionInvocationForUpdate: async (id, tenantId, spaceId) => {
|
|
5066
5352
|
const result = await sql.query(`SELECT * FROM fabric_platform.action_invocations
|
|
5067
5353
|
WHERE id=$1 AND tenant_id=$2 AND space_id=$3
|
|
@@ -5320,6 +5606,18 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
5320
5606
|
CREATE INDEX IF NOT EXISTS staged_invocations_parent_idx
|
|
5321
5607
|
ON fabric_platform.staged_invocations (tenant_id, space_id, parent_invocation_id, created_at);
|
|
5322
5608
|
`
|
|
5609
|
+
},
|
|
5610
|
+
{
|
|
5611
|
+
version: 12,
|
|
5612
|
+
name: "approval_lifecycle_expiry",
|
|
5613
|
+
sql: `
|
|
5614
|
+
ALTER TABLE fabric_platform.action_invocations
|
|
5615
|
+
ADD COLUMN IF NOT EXISTS approval_expires_at timestamptz,
|
|
5616
|
+
ADD COLUMN IF NOT EXISTS approval_expiry jsonb;
|
|
5617
|
+
CREATE INDEX IF NOT EXISTS action_invocations_approval_expiry_idx
|
|
5618
|
+
ON fabric_platform.action_invocations (approval_expires_at, id)
|
|
5619
|
+
WHERE status='waiting_for_approval' AND approval_expires_at IS NOT NULL;
|
|
5620
|
+
`
|
|
5323
5621
|
}
|
|
5324
5622
|
]);
|
|
5325
5623
|
}
|
|
@@ -5530,7 +5828,9 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
5530
5828
|
return result.rows[0] ? toActionRecord(result.rows[0]) : void 0;
|
|
5531
5829
|
}
|
|
5532
5830
|
async updateActionInvocation(id, tenantId, spaceId, patch) {
|
|
5831
|
+
const approvalFields = approvalPatchSql(patch, 18 + (Object.hasOwn(patch, "deferredBeforeEffectAttempt") ? 2 : 0) + (Object.hasOwn(patch, "finalizationCheckpoint") ? 1 : 0));
|
|
5533
5832
|
await this.sql.query(`UPDATE fabric_platform.action_invocations SET
|
|
5833
|
+
${approvalFields.sql}
|
|
5534
5834
|
status=COALESCE($4,status), result=COALESCE($5::jsonb,result),
|
|
5535
5835
|
error=CASE WHEN $6::boolean THEN $7 ELSE error END,
|
|
5536
5836
|
authorization_reconciliation=CASE WHEN $8::boolean THEN $9::jsonb ELSE authorization_reconciliation END,
|
|
@@ -5541,10 +5841,10 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
5541
5841
|
${Object.hasOwn(patch, "finalizationCheckpoint") ? `finalization_checkpoint=$${18 + (Object.hasOwn(patch, "deferredBeforeEffectAttempt") ? 2 : 0)}::jsonb,` : ""}
|
|
5542
5842
|
${Object.hasOwn(patch, "deferredBeforeEffectAttempt") ? "deferred_before_effect_attempt=CASE WHEN $18::boolean THEN $19::integer ELSE deferred_before_effect_attempt END," : ""}
|
|
5543
5843
|
pending_state_transition=CASE WHEN $16::boolean THEN $17::jsonb ELSE pending_state_transition END,
|
|
5544
|
-
lease_owner=CASE WHEN ($13::jsonb IS NOT NULL AND $4='running') OR $4 IN ('waiting_for_approval','completed','failed','blocked_by_policy','reconciliation_required','validation_failed')
|
|
5545
|
-
THEN NULL ELSE lease_owner END
|
|
5546
|
-
lease_expires_at=CASE WHEN ($13::jsonb IS NOT NULL AND $4='running') OR $4 IN ('waiting_for_approval','completed','failed','blocked_by_policy','reconciliation_required','validation_failed')
|
|
5547
|
-
THEN NULL ELSE lease_expires_at END
|
|
5844
|
+
${Object.hasOwn(patch, "leaseOwner") ? "" : `lease_owner=CASE WHEN ($13::jsonb IS NOT NULL AND $4='running') OR $4 IN ('waiting_for_approval','completed','failed','blocked_by_policy','reconciliation_required','validation_failed')
|
|
5845
|
+
THEN NULL ELSE lease_owner END,`}
|
|
5846
|
+
${Object.hasOwn(patch, "leaseExpiresAt") ? "" : `lease_expires_at=CASE WHEN ($13::jsonb IS NOT NULL AND $4='running') OR $4 IN ('waiting_for_approval','completed','failed','blocked_by_policy','reconciliation_required','validation_failed')
|
|
5847
|
+
THEN NULL ELSE lease_expires_at END,`}
|
|
5548
5848
|
updated_at=now()
|
|
5549
5849
|
WHERE id=$1 AND tenant_id=$2 AND space_id=$3`, [
|
|
5550
5850
|
id,
|
|
@@ -5565,14 +5865,15 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
5565
5865
|
Object.hasOwn(patch, "pendingStateTransition"),
|
|
5566
5866
|
patch.pendingStateTransition ? JSON.stringify(patch.pendingStateTransition) : null,
|
|
5567
5867
|
...Object.hasOwn(patch, "deferredBeforeEffectAttempt") ? [true, patch.deferredBeforeEffectAttempt ?? null] : [],
|
|
5568
|
-
...Object.hasOwn(patch, "finalizationCheckpoint") ? [patch.finalizationCheckpoint ? JSON.stringify(patch.finalizationCheckpoint) : null] : []
|
|
5868
|
+
...Object.hasOwn(patch, "finalizationCheckpoint") ? [patch.finalizationCheckpoint ? JSON.stringify(patch.finalizationCheckpoint) : null] : [],
|
|
5869
|
+
...approvalFields.values
|
|
5569
5870
|
]);
|
|
5570
5871
|
}
|
|
5571
5872
|
async recordHitlDecision(actionInvocationId, tenantId, spaceId, decision) {
|
|
5572
5873
|
const row = (await this.sql.query(`UPDATE fabric_platform.action_invocations SET
|
|
5573
5874
|
hitl_route=$4, hitl_risk_tier=$5, hitl_reason=$6,
|
|
5574
|
-
hitl_policy_version=$7, updated_at=$8
|
|
5575
|
-
WHERE id=$1 AND tenant_id=$2 AND space_id=$3
|
|
5875
|
+
hitl_policy_version=$7, updated_at=$8, approval_expires_at=$9
|
|
5876
|
+
WHERE id=$1 AND tenant_id=$2 AND space_id=$3 AND hitl_route IS NULL AND status IN ('pending','running')
|
|
5576
5877
|
RETURNING *`, [
|
|
5577
5878
|
actionInvocationId,
|
|
5578
5879
|
tenantId,
|
|
@@ -5581,20 +5882,93 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
5581
5882
|
decision.riskTier,
|
|
5582
5883
|
decision.reason,
|
|
5583
5884
|
decision.policyVersion ?? null,
|
|
5584
|
-
decision.evaluatedAt
|
|
5885
|
+
decision.evaluatedAt,
|
|
5886
|
+
decision.approvalExpiresAt ?? null
|
|
5585
5887
|
])).rows[0];
|
|
5586
|
-
if (
|
|
5587
|
-
|
|
5888
|
+
if (row) return toActionRecord(row);
|
|
5889
|
+
const existing = await this.getActionInvocation(actionInvocationId, tenantId, spaceId);
|
|
5890
|
+
if (!existing) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
|
|
5891
|
+
return existing;
|
|
5892
|
+
}
|
|
5893
|
+
async parkApproval(input) {
|
|
5894
|
+
if (this.transactionWithEvents) return this.transactionWithEvents((tx) => applyApprovalTransition(tx, input, "requested"));
|
|
5895
|
+
if (input.lifecycleEvent) throw new Error("Approval events require transactional approval support.");
|
|
5896
|
+
const result = await this.sql.query(`UPDATE fabric_platform.action_invocations SET status='waiting_for_approval',
|
|
5897
|
+
approval_expires_at=COALESCE($4::timestamptz,approval_expires_at), lease_owner=NULL, lease_expires_at=NULL, updated_at=$5
|
|
5898
|
+
WHERE id=$1 AND tenant_id=$2 AND space_id=$3 AND status IN ('pending','running') AND approval_decision IS NULL
|
|
5899
|
+
AND (lease_owner IS NULL OR (lease_owner=$6 AND lease_token=$7)) RETURNING *`, [
|
|
5900
|
+
input.actionInvocationId,
|
|
5901
|
+
input.tenantId,
|
|
5902
|
+
input.spaceId,
|
|
5903
|
+
input.approvalExpiresAt ?? null,
|
|
5904
|
+
input.now,
|
|
5905
|
+
input.leaseOwner ?? null,
|
|
5906
|
+
input.leaseToken ?? 0
|
|
5907
|
+
]);
|
|
5908
|
+
return result.rows[0] ? {
|
|
5909
|
+
applied: true,
|
|
5910
|
+
invocation: toActionRecord(result.rows[0])
|
|
5911
|
+
} : { applied: false };
|
|
5912
|
+
}
|
|
5913
|
+
async expireDueApprovals(input) {
|
|
5914
|
+
const limit = approvalSweepLimit(input);
|
|
5915
|
+
if (input.lifecycleEvent && !this.transactionWithEvents) throw new Error("Approval events require transactional approval support.");
|
|
5916
|
+
if (!this.transactionWithEvents) return (await this.sql.query(`WITH due AS (
|
|
5917
|
+
SELECT id FROM fabric_platform.action_invocations WHERE status='waiting_for_approval' AND approval_expires_at <= $1
|
|
5918
|
+
AND ($2::text IS NULL OR tenant_id=$2) AND ($3::text IS NULL OR space_id=$3)
|
|
5919
|
+
ORDER BY approval_expires_at,id LIMIT $4 FOR UPDATE SKIP LOCKED
|
|
5920
|
+
) UPDATE fabric_platform.action_invocations AS invocation SET status='failed', approval_decision=NULL,
|
|
5921
|
+
approval_expiry=jsonb_build_object('expiresAt',approval_expires_at,'expiredAt',$1::timestamptz),
|
|
5922
|
+
error='Approval expired before a decision was applied.', lease_owner=NULL, lease_expires_at=NULL,
|
|
5923
|
+
finalization_checkpoint=CASE WHEN finalization_checkpoint ? 'adapterPreparation' OR finalization_checkpoint->>'protocol'='atomic-after-adapters-v2'
|
|
5924
|
+
THEN (finalization_checkpoint - 'adapterPreparation') || '{"protocol":"atomic-after-adapters-v1"}'::jsonb ELSE finalization_checkpoint END,
|
|
5925
|
+
updated_at=$1 FROM due WHERE invocation.id=due.id RETURNING invocation.*`, [
|
|
5926
|
+
input.now,
|
|
5927
|
+
input.tenantId ?? null,
|
|
5928
|
+
input.spaceId ?? null,
|
|
5929
|
+
limit
|
|
5930
|
+
])).rows.map(toActionRecord);
|
|
5931
|
+
const candidates = await this.sql.query(`SELECT * FROM fabric_platform.action_invocations WHERE status='waiting_for_approval' AND approval_expires_at <= $1
|
|
5932
|
+
AND ($2::text IS NULL OR tenant_id=$2) AND ($3::text IS NULL OR space_id=$3)
|
|
5933
|
+
ORDER BY approval_expires_at,id LIMIT $4`, [
|
|
5934
|
+
input.now,
|
|
5935
|
+
input.tenantId ?? null,
|
|
5936
|
+
input.spaceId ?? null,
|
|
5937
|
+
limit
|
|
5938
|
+
]);
|
|
5939
|
+
return this.transactionWithEvents(async (tx) => {
|
|
5940
|
+
if (!tx.getActionInvocationForUpdate) throw new Error("Approval expiry requires locking invocation reads.");
|
|
5941
|
+
for (const row of candidates.rows) await tx.getActionInvocationForUpdate(String(row.id), String(row.tenant_id), String(row.space_id));
|
|
5942
|
+
const expired = [];
|
|
5943
|
+
for (const row of candidates.rows) {
|
|
5944
|
+
const record = toActionRecord(row);
|
|
5945
|
+
const transition = await applyApprovalTransition(tx, {
|
|
5946
|
+
...input,
|
|
5947
|
+
actionInvocationId: record.id,
|
|
5948
|
+
tenantId: record.tenantId,
|
|
5949
|
+
spaceId: record.spaceId
|
|
5950
|
+
}, "expired");
|
|
5951
|
+
if (transition.applied) expired.push(transition.invocation);
|
|
5952
|
+
}
|
|
5953
|
+
return expired;
|
|
5954
|
+
});
|
|
5588
5955
|
}
|
|
5589
5956
|
async beginApprovalDecision(input) {
|
|
5957
|
+
if (this.transactionWithEvents) return this.transactionWithEvents((tx) => applyApprovalTransition(tx, input, "decided"));
|
|
5958
|
+
if (input.lifecycleEvent) throw new Error("Approval events require transactional approval support.");
|
|
5590
5959
|
const result = await this.sql.query(`UPDATE fabric_platform.action_invocations SET
|
|
5591
|
-
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
5960
|
+
approval_expiry=CASE WHEN approval_expires_at <= $9 THEN jsonb_build_object('expiresAt',approval_expires_at,'expiredAt',$9::timestamptz) ELSE approval_expiry END,
|
|
5961
|
+
approval_decision=CASE WHEN approval_expires_at <= $9 THEN NULL ELSE $4::jsonb END,
|
|
5962
|
+
status=CASE WHEN $5::boolean AND (approval_expires_at IS NULL OR approval_expires_at > $9) THEN 'running' ELSE 'failed' END,
|
|
5963
|
+
finalization_checkpoint=CASE WHEN $5::boolean AND (approval_expires_at IS NULL OR approval_expires_at > $9) THEN finalization_checkpoint
|
|
5964
|
+
WHEN finalization_checkpoint ? 'adapterPreparation' OR finalization_checkpoint->>'protocol'='atomic-after-adapters-v2'
|
|
5965
|
+
THEN (finalization_checkpoint - 'adapterPreparation') || '{"protocol":"atomic-after-adapters-v1"}'::jsonb
|
|
5966
|
+
ELSE finalization_checkpoint END,
|
|
5967
|
+
error=CASE WHEN $5::boolean AND (approval_expires_at IS NULL OR approval_expires_at > $9) THEN NULL WHEN approval_expires_at <= $9 THEN 'Approval expired before a decision was applied.' ELSE $6 END,
|
|
5968
|
+
lease_owner=CASE WHEN $5::boolean AND (approval_expires_at IS NULL OR approval_expires_at > $9) THEN $7 ELSE NULL END,
|
|
5969
|
+
lease_expires_at=CASE WHEN $5::boolean AND (approval_expires_at IS NULL OR approval_expires_at > $9) THEN $8::timestamptz ELSE NULL END,
|
|
5970
|
+
lease_token=CASE WHEN $5::boolean AND (approval_expires_at IS NULL OR approval_expires_at > $9) THEN lease_token+1 ELSE lease_token END,
|
|
5971
|
+
attempt_count=CASE WHEN $5::boolean AND (approval_expires_at IS NULL OR approval_expires_at > $9) THEN GREATEST(attempt_count,1) ELSE attempt_count END,
|
|
5598
5972
|
updated_at=$9
|
|
5599
5973
|
WHERE id=$1 AND tenant_id=$2 AND space_id=$3 AND status='waiting_for_approval'
|
|
5600
5974
|
RETURNING *`, [
|
|
@@ -6320,7 +6694,9 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
6320
6694
|
}
|
|
6321
6695
|
async updateLeasedActionInvocation(input) {
|
|
6322
6696
|
const patch = input.patch;
|
|
6697
|
+
const approvalFields = approvalPatchSql(patch, 20 + (Object.hasOwn(patch, "deferredBeforeEffectAttempt") ? 2 : 0) + (Object.hasOwn(patch, "finalizationCheckpoint") ? 1 : 0));
|
|
6323
6698
|
return (await this.sql.query(`UPDATE fabric_platform.action_invocations SET
|
|
6699
|
+
${approvalFields.sql}
|
|
6324
6700
|
status=COALESCE($6,status), result=COALESCE($7::jsonb,result),
|
|
6325
6701
|
error=CASE WHEN $8::boolean THEN $9 ELSE error END,
|
|
6326
6702
|
authorization_reconciliation=CASE WHEN $10::boolean THEN $11::jsonb ELSE authorization_reconciliation END,
|
|
@@ -6331,10 +6707,10 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
6331
6707
|
${Object.hasOwn(patch, "finalizationCheckpoint") ? `finalization_checkpoint=$${20 + (Object.hasOwn(patch, "deferredBeforeEffectAttempt") ? 2 : 0)}::jsonb,` : ""}
|
|
6332
6708
|
${Object.hasOwn(patch, "deferredBeforeEffectAttempt") ? "deferred_before_effect_attempt=CASE WHEN $20::boolean THEN $21::integer ELSE deferred_before_effect_attempt END," : ""}
|
|
6333
6709
|
pending_state_transition=CASE WHEN $18::boolean THEN $19::jsonb ELSE pending_state_transition END,
|
|
6334
|
-
lease_owner=CASE WHEN ($15::jsonb IS NOT NULL AND $6='running') OR $6 IN ('waiting_for_approval','completed','failed','blocked_by_policy','reconciliation_required','validation_failed')
|
|
6335
|
-
THEN NULL ELSE lease_owner END
|
|
6336
|
-
lease_expires_at=CASE WHEN ($15::jsonb IS NOT NULL AND $6='running') OR $6 IN ('waiting_for_approval','completed','failed','blocked_by_policy','reconciliation_required','validation_failed')
|
|
6337
|
-
THEN NULL ELSE lease_expires_at END
|
|
6710
|
+
${Object.hasOwn(patch, "leaseOwner") ? "" : `lease_owner=CASE WHEN ($15::jsonb IS NOT NULL AND $6='running') OR $6 IN ('waiting_for_approval','completed','failed','blocked_by_policy','reconciliation_required','validation_failed')
|
|
6711
|
+
THEN NULL ELSE lease_owner END,`}
|
|
6712
|
+
${Object.hasOwn(patch, "leaseExpiresAt") ? "" : `lease_expires_at=CASE WHEN ($15::jsonb IS NOT NULL AND $6='running') OR $6 IN ('waiting_for_approval','completed','failed','blocked_by_policy','reconciliation_required','validation_failed')
|
|
6713
|
+
THEN NULL ELSE lease_expires_at END,`}
|
|
6338
6714
|
updated_at=now()
|
|
6339
6715
|
WHERE id=$1 AND tenant_id=$2 AND space_id=$3
|
|
6340
6716
|
AND status='running' AND lease_owner=$4 AND lease_token=$5
|
|
@@ -6359,7 +6735,8 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
6359
6735
|
Object.hasOwn(patch, "pendingStateTransition"),
|
|
6360
6736
|
patch.pendingStateTransition ? JSON.stringify(patch.pendingStateTransition) : null,
|
|
6361
6737
|
...Object.hasOwn(patch, "deferredBeforeEffectAttempt") ? [true, patch.deferredBeforeEffectAttempt ?? null] : [],
|
|
6362
|
-
...Object.hasOwn(patch, "finalizationCheckpoint") ? [patch.finalizationCheckpoint ? JSON.stringify(patch.finalizationCheckpoint) : null] : []
|
|
6738
|
+
...Object.hasOwn(patch, "finalizationCheckpoint") ? [patch.finalizationCheckpoint ? JSON.stringify(patch.finalizationCheckpoint) : null] : [],
|
|
6739
|
+
...approvalFields.values
|
|
6363
6740
|
])).rows.length === 1;
|
|
6364
6741
|
}
|
|
6365
6742
|
async renewActionInvocationLease(input) {
|
|
@@ -6469,6 +6846,11 @@ function toActionRecord(row) {
|
|
|
6469
6846
|
...row.hitl_risk_tier ? { hitlRiskTier: String(row.hitl_risk_tier) } : {},
|
|
6470
6847
|
...row.hitl_reason ? { hitlReason: String(row.hitl_reason) } : {},
|
|
6471
6848
|
...row.hitl_policy_version ? { hitlPolicyVersion: String(row.hitl_policy_version) } : {},
|
|
6849
|
+
...row.approval_expires_at ? { approvalExpiresAt: new Date(row.approval_expires_at) } : {},
|
|
6850
|
+
...row.approval_expiry ? { approvalExpiry: {
|
|
6851
|
+
expiresAt: new Date(row.approval_expiry.expiresAt),
|
|
6852
|
+
expiredAt: new Date(row.approval_expiry.expiredAt)
|
|
6853
|
+
} } : {},
|
|
6472
6854
|
...row.approval_decision ? { approvalDecision: toApprovalDecision(row.approval_decision) } : {},
|
|
6473
6855
|
...row.mutation_footprint ? { mutationFootprint: row.mutation_footprint } : {},
|
|
6474
6856
|
...row.execution_principal ? { executionPrincipal: row.execution_principal } : {},
|
|
@@ -6534,6 +6916,7 @@ function toAttestationRecord(row) {
|
|
|
6534
6916
|
function toApprovalDecision(value) {
|
|
6535
6917
|
const decision = value;
|
|
6536
6918
|
return {
|
|
6919
|
+
...decision.evidence !== void 0 ? { evidence: structuredClone(decision.evidence) } : {},
|
|
6537
6920
|
approved: Boolean(decision.approved),
|
|
6538
6921
|
approverId: String(decision.approverId),
|
|
6539
6922
|
approverType: String(decision.approverType),
|
|
@@ -6627,6 +7010,30 @@ function toOutboxRequeueRecord(row, prefix) {
|
|
|
6627
7010
|
priorLeaseToken: Number(field("prior_lease_token"))
|
|
6628
7011
|
};
|
|
6629
7012
|
}
|
|
7013
|
+
/** Additional approval-only patch bindings; omitted fields do not touch older columns. */
|
|
7014
|
+
function approvalPatchSql(patch, start) {
|
|
7015
|
+
const columns = {
|
|
7016
|
+
approvalExpiresAt: "approval_expires_at",
|
|
7017
|
+
approvalExpiry: "approval_expiry",
|
|
7018
|
+
approvalDecision: "approval_decision",
|
|
7019
|
+
leaseOwner: "lease_owner",
|
|
7020
|
+
leaseExpiresAt: "lease_expires_at",
|
|
7021
|
+
leaseToken: "lease_token",
|
|
7022
|
+
attemptCount: "attempt_count"
|
|
7023
|
+
};
|
|
7024
|
+
const values = [];
|
|
7025
|
+
const assignments = [];
|
|
7026
|
+
for (const key of Object.keys(columns)) {
|
|
7027
|
+
if (!Object.hasOwn(patch, key)) continue;
|
|
7028
|
+
const json = key === "approvalExpiry" || key === "approvalDecision";
|
|
7029
|
+
values.push(patch[key] === void 0 ? null : json ? JSON.stringify(patch[key]) : patch[key]);
|
|
7030
|
+
assignments.push(`${columns[key]}=$${start + values.length - 1}${json ? "::jsonb" : ""},`);
|
|
7031
|
+
}
|
|
7032
|
+
return {
|
|
7033
|
+
sql: assignments.join("\n"),
|
|
7034
|
+
values
|
|
7035
|
+
};
|
|
7036
|
+
}
|
|
6630
7037
|
//#endregion
|
|
6631
7038
|
//#region src/worker.ts
|
|
6632
7039
|
const DEFAULT_BATCH_SIZE = 10;
|
|
@@ -6668,6 +7075,16 @@ async function runPlatformActionWorkerCycle(options) {
|
|
|
6668
7075
|
} catch (error) {
|
|
6669
7076
|
options.onError?.(error);
|
|
6670
7077
|
}
|
|
7078
|
+
let expiredApprovals = 0;
|
|
7079
|
+
if (typeof options.host.expireDueApprovals === "function") try {
|
|
7080
|
+
expiredApprovals = (await options.host.expireDueApprovals({
|
|
7081
|
+
...options.tenantId !== void 0 ? { tenantId: options.tenantId } : {},
|
|
7082
|
+
...options.spaceId !== void 0 ? { spaceId: options.spaceId } : {},
|
|
7083
|
+
limit: options.batchSize ?? DEFAULT_BATCH_SIZE
|
|
7084
|
+
})).expired.length;
|
|
7085
|
+
} catch (error) {
|
|
7086
|
+
options.onError?.(error);
|
|
7087
|
+
}
|
|
6671
7088
|
let claimed;
|
|
6672
7089
|
try {
|
|
6673
7090
|
claimed = await options.store.claimActionInvocations({
|
|
@@ -6716,6 +7133,7 @@ async function runPlatformActionWorkerCycle(options) {
|
|
|
6716
7133
|
claimed: claimed.length,
|
|
6717
7134
|
completed,
|
|
6718
7135
|
failed,
|
|
7136
|
+
...expiredApprovals > 0 ? { expiredApprovals } : {},
|
|
6719
7137
|
...waitingForApproval > 0 ? { waitingForApproval } : {},
|
|
6720
7138
|
...deferred > 0 ? { deferred } : {},
|
|
6721
7139
|
...staged && Object.values(staged).some((value) => value > 0) ? { staged } : {}
|
|
@@ -6724,6 +7142,7 @@ async function runPlatformActionWorkerCycle(options) {
|
|
|
6724
7142
|
claimed: result.claimed,
|
|
6725
7143
|
completed: result.completed,
|
|
6726
7144
|
failed: result.failed,
|
|
7145
|
+
...result.expiredApprovals !== void 0 ? { expiredApprovals: result.expiredApprovals } : {},
|
|
6727
7146
|
...result.waitingForApproval !== void 0 ? { waitingForApproval: result.waitingForApproval } : {},
|
|
6728
7147
|
...result.deferred !== void 0 ? { deferred: result.deferred } : {},
|
|
6729
7148
|
...result.staged ? {
|
|
@@ -6853,7 +7272,7 @@ function createDurableSagaParentLifecycle(options) {
|
|
|
6853
7272
|
correlationId: input.parentInvocationId,
|
|
6854
7273
|
causationId: lifecycleId
|
|
6855
7274
|
});
|
|
6856
|
-
if (patch) await transaction.updateActionInvocation(input.parentInvocationId, input.tenantId, input.spaceId, patch);
|
|
7275
|
+
if (patch) await transaction.updateActionInvocation(input.parentInvocationId, input.tenantId, input.spaceId, finalizationSettlementPatch(parent, patch));
|
|
6857
7276
|
});
|
|
6858
7277
|
}
|
|
6859
7278
|
return {
|
|
@@ -7932,6 +8351,6 @@ async function runEventAppendChecks(subject) {
|
|
|
7932
8351
|
};
|
|
7933
8352
|
}
|
|
7934
8353
|
//#endregion
|
|
7935
|
-
export { ActionAdmissionError, ExternalCompletionError, IdempotencyConflictError, MAX_STAGED_INVOCATIONS, MAX_STAGED_INVOCATION_BATCH_BYTES, MAX_STAGED_INVOCATION_BYTES, MAX_STAGED_INVOCATION_KEY_LENGTH, MemoryPlatformHostStore, PARAMETER_DIGEST_ALGORITHM, PLATFORM_HOST_CONTRACT_VERSION, PLATFORM_HOST_HEALTH_CONTRACT_VERSION, PLATFORM_HOST_METRIC_NAMES, PostgresPlatformHostStore, STAGED_INVOCATION_KEY_PATTERN, SubmissionRefusalError, applyPostgresMigrations, asStagedInvocationStore, canonicalJson, cloneOutboxRecord, cloneStagedInvocationRecord, createDurableSagaParentLifecycle, createGovernedActionHost, createStoreBackedActionDispatcher, digestParameters, emitPlatformHostTelemetry, eventAppendChecks, getPlatformHostHealthSnapshot, outboxActorDisclosure, prepareStagedInvocationBatch, runEventAppendChecks, runOutboxRelayCycle, runPlatformActionWorker, runPlatformActionWorkerCycle, runTenantIsolationChecks, stagedInvocationRequestIdentity, tenantIsolationChecks, toEnterpriseEventEnvelope };
|
|
8354
|
+
export { APPROVAL_DECIDED_EVENT, APPROVAL_EXPIRED_EVENT, APPROVAL_REQUESTED_EVENT, ActionAdmissionError, ExternalCompletionError, IdempotencyConflictError, MAX_APPROVAL_EVIDENCE_BYTES, MAX_STAGED_INVOCATIONS, MAX_STAGED_INVOCATION_BATCH_BYTES, MAX_STAGED_INVOCATION_BYTES, MAX_STAGED_INVOCATION_KEY_LENGTH, MemoryPlatformHostStore, PARAMETER_DIGEST_ALGORITHM, PLATFORM_HOST_CONTRACT_VERSION, PLATFORM_HOST_HEALTH_CONTRACT_VERSION, PLATFORM_HOST_METRIC_NAMES, PostgresPlatformHostStore, STAGED_INVOCATION_KEY_PATTERN, SubmissionRefusalError, applyPostgresMigrations, asStagedInvocationStore, canonicalJson, cloneOutboxRecord, cloneStagedInvocationRecord, createDurableSagaParentLifecycle, createGovernedActionHost, createStoreBackedActionDispatcher, digestParameters, emitPlatformHostTelemetry, eventAppendChecks, getPlatformHostHealthSnapshot, outboxActorDisclosure, prepareStagedInvocationBatch, runEventAppendChecks, runOutboxRelayCycle, runPlatformActionWorker, runPlatformActionWorkerCycle, runTenantIsolationChecks, stagedInvocationRequestIdentity, tenantIsolationChecks, toEnterpriseEventEnvelope };
|
|
7936
8355
|
|
|
7937
8356
|
//# sourceMappingURL=index.js.map
|