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