@objectstack/plugin-approvals 17.0.0-rc.1 → 17.0.0-rc.2
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 +424 -0
- package/dist/index.d.mts +1769 -1034
- package/dist/index.d.ts +1769 -1034
- package/dist/index.js +476 -86
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +476 -86
- package/dist/index.mjs.map +1 -1
- package/package.json +11 -11
package/dist/index.js
CHANGED
|
@@ -257,6 +257,10 @@ var init_en_objects_generated = __esm({
|
|
|
257
257
|
comment: {
|
|
258
258
|
label: "Comment"
|
|
259
259
|
},
|
|
260
|
+
via_override: {
|
|
261
|
+
label: "Via Admin Override",
|
|
262
|
+
help: "True when the actor was admitted to this action only by the privileged-override path (#3424) \u2014 they held no slot in the request\u2019s pending-approver slate."
|
|
263
|
+
},
|
|
260
264
|
reassign_from: {
|
|
261
265
|
label: "Reassigned From",
|
|
262
266
|
help: "User whose pending-approver slot was handed over (reassign actions only)"
|
|
@@ -574,6 +578,10 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
574
578
|
comment: {
|
|
575
579
|
label: "\u8BC4\u8BBA"
|
|
576
580
|
},
|
|
581
|
+
via_override: {
|
|
582
|
+
label: "\u7BA1\u7406\u5458\u8D8A\u6743\u64CD\u4F5C",
|
|
583
|
+
help: "\u4E3A\u771F\u8868\u793A\u8BE5\u64CD\u4F5C\u8005\u53EA\u662F\u51ED\u7279\u6743\u8D8A\u6743\u8DEF\u5F84\uFF08#3424\uFF09\u88AB\u653E\u884C\u2014\u2014\u4ED6\u4EEC\u5E76\u4E0D\u5728\u8BE5\u8BF7\u6C42\u7684\u5F85\u5BA1\u6279\u4EBA\u540D\u5355\u4E2D\u3002"
|
|
584
|
+
},
|
|
577
585
|
reassign_from: {
|
|
578
586
|
label: "\u8F6C\u51FA\u4EBA",
|
|
579
587
|
help: "\u88AB\u79FB\u4EA4\u5F85\u5BA1\u6279\u69FD\u4F4D\u7684\u7528\u6237\uFF08\u4EC5\u8F6C\u7B7E\u64CD\u4F5C\uFF09"
|
|
@@ -891,6 +899,10 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
891
899
|
comment: {
|
|
892
900
|
label: "\u30B3\u30E1\u30F3\u30C8"
|
|
893
901
|
},
|
|
902
|
+
via_override: {
|
|
903
|
+
label: "\u7BA1\u7406\u8005\u30AA\u30FC\u30D0\u30FC\u30E9\u30A4\u30C9\u7D4C\u7531",
|
|
904
|
+
help: "true \u306E\u5834\u5408\u3001\u5B9F\u884C\u8005\u306F\u7279\u6A29\u30AA\u30FC\u30D0\u30FC\u30E9\u30A4\u30C9\u7D4C\u8DEF\uFF08#3424\uFF09\u306B\u3088\u3063\u3066\u306E\u307F\u8A31\u53EF\u3055\u308C\u305F\u3053\u3068\u3092\u793A\u3057\u307E\u3059 \u2014 \u5F53\u8A72\u30EA\u30AF\u30A8\u30B9\u30C8\u306E\u627F\u8A8D\u5F85\u3061\u30EA\u30B9\u30C8\u306B\u306F\u542B\u307E\u308C\u3066\u3044\u307E\u305B\u3093\u3002"
|
|
905
|
+
},
|
|
894
906
|
reassign_from: {
|
|
895
907
|
label: "\u5F15\u304D\u7D99\u304E\u5143",
|
|
896
908
|
help: "\u627F\u8A8D\u5F85\u3061\u30B9\u30ED\u30C3\u30C8\u3092\u5F15\u304D\u6E21\u3057\u305F\u30E6\u30FC\u30B6\u30FC\uFF08\u5F15\u304D\u7D99\u304E\u64CD\u4F5C\u306E\u307F\uFF09"
|
|
@@ -1208,6 +1220,10 @@ var init_es_ES_objects_generated = __esm({
|
|
|
1208
1220
|
comment: {
|
|
1209
1221
|
label: "Comentario"
|
|
1210
1222
|
},
|
|
1223
|
+
via_override: {
|
|
1224
|
+
label: "Mediante anulaci\xF3n de administrador",
|
|
1225
|
+
help: "Verdadero cuando el actor fue admitido en esta acci\xF3n \xFAnicamente por la v\xEDa de anulaci\xF3n privilegiada (#3424): no ocupaba ning\xFAn puesto en la lista de aprobadores pendientes de la solicitud."
|
|
1226
|
+
},
|
|
1211
1227
|
reassign_from: {
|
|
1212
1228
|
label: "Reasignado de",
|
|
1213
1229
|
help: "Usuario cuyo turno de aprobaci\xF3n pendiente fue traspasado (solo acciones de reasignaci\xF3n)"
|
|
@@ -1714,7 +1730,7 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
|
|
|
1714
1730
|
nameField: "id",
|
|
1715
1731
|
// [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
|
|
1716
1732
|
titleFormat: "{action} \xB7 {step_name}",
|
|
1717
|
-
highlightFields: ["request_id", "step_name", "action", "actor_id", "created_at"],
|
|
1733
|
+
highlightFields: ["request_id", "step_name", "action", "actor_id", "via_override", "created_at"],
|
|
1718
1734
|
// ADR-0104 D3 wave 2. `attachments` is a media field, so the files it holds
|
|
1719
1735
|
// are OWNED by this row — and the storage service would otherwise authorize
|
|
1720
1736
|
// their download by testing whether the caller can READ this row. It cannot:
|
|
@@ -1729,7 +1745,7 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
|
|
|
1729
1745
|
name: "recent",
|
|
1730
1746
|
label: "Recent",
|
|
1731
1747
|
data: { provider: "object", object: "sys_approval_action" },
|
|
1732
|
-
columns: ["created_at", "request_id", "step_name", "action", "actor_id", "comment"],
|
|
1748
|
+
columns: ["created_at", "request_id", "step_name", "action", "actor_id", "via_override", "comment"],
|
|
1733
1749
|
sort: [{ field: "created_at", order: "desc" }],
|
|
1734
1750
|
pagination: { pageSize: 50 },
|
|
1735
1751
|
emptyState: { title: "No approval actions yet", message: "Actions are logged automatically when approvals progress." }
|
|
@@ -1749,7 +1765,7 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
|
|
|
1749
1765
|
name: "all_actions",
|
|
1750
1766
|
label: "All",
|
|
1751
1767
|
data: { provider: "object", object: "sys_approval_action" },
|
|
1752
|
-
columns: ["created_at", "request_id", "step_name", "action", "actor_id", "comment"],
|
|
1768
|
+
columns: ["created_at", "request_id", "step_name", "action", "actor_id", "via_override", "comment"],
|
|
1753
1769
|
sort: [{ field: "created_at", order: "desc" }],
|
|
1754
1770
|
pagination: { pageSize: 100 }
|
|
1755
1771
|
}
|
|
@@ -1797,6 +1813,29 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
|
|
|
1797
1813
|
group: "Action"
|
|
1798
1814
|
}),
|
|
1799
1815
|
comment: import_data2.Field.textarea({ label: "Comment", required: false, group: "Action" }),
|
|
1816
|
+
// #4466 — the one bit of "who really decided this" that was still dropped.
|
|
1817
|
+
// A privileged admin may act on a request whose staffed approver slate they
|
|
1818
|
+
// hold no slot in (the #3424 override path); before this column, that
|
|
1819
|
+
// decision was byte-for-byte identical to the designated approver's own
|
|
1820
|
+
// approval. A reader of the timeline saw `approve` by the admin and could
|
|
1821
|
+
// not tell whether the admin WAS an approver or OVERRODE the ones who were,
|
|
1822
|
+
// and the bypassed approver's later `409 INVALID_STATE` was the only trace
|
|
1823
|
+
// — existing only if they happened to try.
|
|
1824
|
+
//
|
|
1825
|
+
// The platform KNOWS at decision time: it took the `isOverrideActor` branch
|
|
1826
|
+
// to admit the call at all. This is dropped information, not unavailable
|
|
1827
|
+
// information.
|
|
1828
|
+
//
|
|
1829
|
+
// Set on exactly the decisions that were admitted BY that branch — an admin
|
|
1830
|
+
// who is also a genuine slot holder is approving normally and is recorded
|
|
1831
|
+
// as such. Nullable and additive: rows written before this column exists
|
|
1832
|
+
// carry `null`, which reads as "not recorded", never as "not an override".
|
|
1833
|
+
via_override: import_data2.Field.boolean({
|
|
1834
|
+
label: "Via Admin Override",
|
|
1835
|
+
required: false,
|
|
1836
|
+
group: "Action",
|
|
1837
|
+
description: "True when the actor was admitted to this action only by the privileged-override path (#3424) \u2014 they held no slot in the request\u2019s pending-approver slate."
|
|
1838
|
+
}),
|
|
1800
1839
|
// Structured hand-off parties for `action: 'reassign'` (#4365). Before
|
|
1801
1840
|
// these existed the pair lived only inside a default free-text comment
|
|
1802
1841
|
// ("<from_id> → <to_id>"), which no client could parse or render readably.
|
|
@@ -1904,12 +1943,11 @@ var SysApprovalDelegation = import_data4.ObjectSchema.create({
|
|
|
1904
1943
|
pluralLabel: "Approval Delegations",
|
|
1905
1944
|
icon: "user-clock",
|
|
1906
1945
|
isSystem: true,
|
|
1907
|
-
|
|
1908
|
-
//
|
|
1909
|
-
//
|
|
1910
|
-
//
|
|
1911
|
-
|
|
1912
|
-
userActions: { create: true, edit: true, delete: true },
|
|
1946
|
+
// [ADR-0103, #3355] Admin/user-writable DATA on a platform-defined schema: a
|
|
1947
|
+
// user authors their own out-of-office delegation. The bucket default is full
|
|
1948
|
+
// CRUD (matching the full-CRUD apiMethods below), so no `userActions` block is
|
|
1949
|
+
// needed — RLS/permission sets are the authz.
|
|
1950
|
+
managedBy: "system-data",
|
|
1913
1951
|
description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
|
|
1914
1952
|
titleFormat: "{delegator_id} \u2192 {delegate_id}",
|
|
1915
1953
|
highlightFields: ["delegator_id", "delegate_id", "valid_from", "valid_until"],
|
|
@@ -2134,6 +2172,7 @@ var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
|
|
|
2134
2172
|
"cancelled",
|
|
2135
2173
|
"timed_out"
|
|
2136
2174
|
]);
|
|
2175
|
+
var STRANDABLE_REQUEST_STATUSES = ["approved", "rejected", "returned"];
|
|
2137
2176
|
var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
|
|
2138
2177
|
var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
|
|
2139
2178
|
function actingUserId(context) {
|
|
@@ -2273,6 +2312,11 @@ function rowFromAction(row) {
|
|
|
2273
2312
|
// Structured reassign hand-off parties (#4365).
|
|
2274
2313
|
reassign_from: row.reassign_from ?? void 0,
|
|
2275
2314
|
reassign_to: row.reassign_to ?? void 0,
|
|
2315
|
+
// #4466 — surfaced so a timeline can SAY "overridden the approver slate"
|
|
2316
|
+
// rather than render an override identically to an ordinary approval.
|
|
2317
|
+
// `null` (a row written before the column existed) stays `undefined`:
|
|
2318
|
+
// "not recorded" is not the same claim as "not an override".
|
|
2319
|
+
via_override: row.via_override == null ? void 0 : row.via_override === true,
|
|
2276
2320
|
// Decision attachments (#3266): rich descriptors carrying the display name +
|
|
2277
2321
|
// download URL, so consumers label/open them without reading `sys_file`.
|
|
2278
2322
|
attachments: attachments.length ? attachments : void 0,
|
|
@@ -3204,6 +3248,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3204
3248
|
if (!isSlotHolder && !isOverride) {
|
|
3205
3249
|
throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
|
|
3206
3250
|
}
|
|
3251
|
+
const viaOverride = isOverride && !isSlotHolder;
|
|
3207
3252
|
const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
|
|
3208
3253
|
const org = raw.organization_id ?? null;
|
|
3209
3254
|
const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
|
|
@@ -3241,6 +3286,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3241
3286
|
);
|
|
3242
3287
|
}
|
|
3243
3288
|
}
|
|
3289
|
+
await this.assertRunResumable(runId, requestId);
|
|
3244
3290
|
await this.engine.insert("sys_approval_action", {
|
|
3245
3291
|
id: uid("aact"),
|
|
3246
3292
|
request_id: requestId,
|
|
@@ -3250,6 +3296,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3250
3296
|
action: input.decision,
|
|
3251
3297
|
actor_id: actorId,
|
|
3252
3298
|
comment: input.comment ?? null,
|
|
3299
|
+
// #4466: the override is recorded on the DECISION, not inferred later.
|
|
3300
|
+
// Written as an explicit `false` for an ordinary decision so a reader can
|
|
3301
|
+
// tell "checked, and it was not an override" from a legacy row's `null`.
|
|
3302
|
+
via_override: viaOverride,
|
|
3253
3303
|
attachments: input.attachments?.length ? input.attachments : null,
|
|
3254
3304
|
created_at: now
|
|
3255
3305
|
}, { context: SYSTEM_CTX2 });
|
|
@@ -3336,22 +3386,131 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3336
3386
|
* Callers still guard on `typeof this.automation?.resume === 'function'`
|
|
3337
3387
|
* (approvals runs fine with no automation attached) and keep their own
|
|
3338
3388
|
* try/catch, because what a failed resume means differs per path.
|
|
3389
|
+
*
|
|
3390
|
+
* Throws when the engine REPORTS failure, not only when it throws one. The
|
|
3391
|
+
* engine answers a lost run with `{ success: false, code: 'RUN_NOT_FOUND' }`
|
|
3392
|
+
* — a plain return value that every caller here used to discard, which is
|
|
3393
|
+
* how an approval could be recorded, reported as resumed, and leave its flow
|
|
3394
|
+
* stranded forever (#4420). The thrown error carries {@link resumeCodeOf}'s
|
|
3395
|
+
* `resumeCode` so callers can tell a benign duplicate from a dead run.
|
|
3339
3396
|
*/
|
|
3340
3397
|
async serviceResume(runId, signal) {
|
|
3341
|
-
await this.automation.resume(runId, { ...signal, [import_contracts3.RESUME_AUTHORITY_SERVICE]: true });
|
|
3398
|
+
const result = await this.automation.resume(runId, { ...signal, [import_contracts3.RESUME_AUTHORITY_SERVICE]: true });
|
|
3399
|
+
const reported = result;
|
|
3400
|
+
if (reported && typeof reported === "object" && reported.success === false) {
|
|
3401
|
+
const err = new Error(
|
|
3402
|
+
`resume of run '${runId}' failed${reported.code ? ` [${reported.code}]` : ""}: ${reported.error ?? "unknown error"}`
|
|
3403
|
+
);
|
|
3404
|
+
err.resumeCode = reported.code;
|
|
3405
|
+
throw err;
|
|
3406
|
+
}
|
|
3407
|
+
}
|
|
3408
|
+
/** The engine failure code behind a {@link serviceResume} rejection, if any. */
|
|
3409
|
+
static resumeCodeOf(err) {
|
|
3410
|
+
return err?.resumeCode;
|
|
3411
|
+
}
|
|
3412
|
+
/**
|
|
3413
|
+
* Refuse an operation whose whole point is to advance a flow run when that
|
|
3414
|
+
* run no longer exists — BEFORE anything is written down (#4420).
|
|
3415
|
+
*
|
|
3416
|
+
* The half-state this prevents is the one the issue reported: a request
|
|
3417
|
+
* flipped to `approved`, a success toast, and a flow that never moves. Once
|
|
3418
|
+
* the decision row is written there is nothing left to fail cleanly.
|
|
3419
|
+
*
|
|
3420
|
+
* Deliberately permissive at the edges:
|
|
3421
|
+
* - no automation attached, or an engine without `hasSuspendedRun` → no
|
|
3422
|
+
* pre-flight at all (standalone approvals compositions are unaffected);
|
|
3423
|
+
* - the store cannot be READ → fail OPEN. A transient outage must not block
|
|
3424
|
+
* every decision in the tenant; the post-resume check still catches a real
|
|
3425
|
+
* failure and reports it loudly.
|
|
3426
|
+
*/
|
|
3427
|
+
async assertRunResumable(runId, requestId) {
|
|
3428
|
+
if (!runId) return;
|
|
3429
|
+
if (typeof this.automation?.resume !== "function") return;
|
|
3430
|
+
if (typeof this.automation?.hasSuspendedRun !== "function") return;
|
|
3431
|
+
let alive;
|
|
3432
|
+
try {
|
|
3433
|
+
alive = await this.automation.hasSuspendedRun(runId);
|
|
3434
|
+
} catch (err) {
|
|
3435
|
+
this.logger?.warn?.("[approvals] could not verify the flow run is resumable \u2014 proceeding", {
|
|
3436
|
+
request: requestId,
|
|
3437
|
+
run: runId,
|
|
3438
|
+
error: err?.message ?? String(err)
|
|
3439
|
+
});
|
|
3440
|
+
return;
|
|
3441
|
+
}
|
|
3442
|
+
if (!alive) {
|
|
3443
|
+
throw new Error(
|
|
3444
|
+
`RESUME_TARGET_LOST: the flow run '${runId}' behind request ${requestId} no longer exists (it was cancelled, or it paused in a process that did not persist suspended runs). Nothing was recorded. An administrator can recall the request to release the record.`
|
|
3445
|
+
);
|
|
3446
|
+
}
|
|
3447
|
+
}
|
|
3448
|
+
/**
|
|
3449
|
+
* Resume the run behind an outcome that has ALREADY been written down, and
|
|
3450
|
+
* fail loudly when it cannot be (#4420).
|
|
3451
|
+
*
|
|
3452
|
+
* For the operations whose product is the resume — a finalised decision, a
|
|
3453
|
+
* send-back, a resubmit. Their rows are durable by the time this runs, so a
|
|
3454
|
+
* failure here cannot be undone; the one thing left worth doing is refusing
|
|
3455
|
+
* to call it success. {@link assertRunResumable} is what keeps this rare:
|
|
3456
|
+
* everything it catches never reaches a write.
|
|
3457
|
+
*
|
|
3458
|
+
* `RESUME_IN_PROGRESS` is the exception — a concurrent resume is already
|
|
3459
|
+
* advancing the run, so the outcome stands and only `resumed` is false.
|
|
3460
|
+
*
|
|
3461
|
+
* @param what - how the recorded outcome reads in the error, e.g.
|
|
3462
|
+
* `"the approve decision"`.
|
|
3463
|
+
*/
|
|
3464
|
+
async resumeRecordedOutcome(runId, requestId, what, signal) {
|
|
3465
|
+
try {
|
|
3466
|
+
await this.serviceResume(runId, signal);
|
|
3467
|
+
return { resumed: true };
|
|
3468
|
+
} catch (err) {
|
|
3469
|
+
const reason = err?.message ?? String(err);
|
|
3470
|
+
if (_ApprovalService.resumeCodeOf(err) === "RESUME_IN_PROGRESS") {
|
|
3471
|
+
this.logger?.warn?.("[approvals] resume skipped \u2014 already in progress", {
|
|
3472
|
+
request: requestId,
|
|
3473
|
+
run: runId,
|
|
3474
|
+
outcome: what
|
|
3475
|
+
});
|
|
3476
|
+
return { resumed: false, resumeError: reason };
|
|
3477
|
+
}
|
|
3478
|
+
this.logger?.error?.("[approvals] resume failed \u2014 the run is stranded", {
|
|
3479
|
+
request: requestId,
|
|
3480
|
+
run: runId,
|
|
3481
|
+
outcome: what,
|
|
3482
|
+
error: reason
|
|
3483
|
+
});
|
|
3484
|
+
throw new Error(
|
|
3485
|
+
`RESUME_FAILED: ${what} was recorded on request ${requestId}, but its flow run '${runId}' could not be resumed and is now stranded: ${reason}`
|
|
3486
|
+
);
|
|
3487
|
+
}
|
|
3342
3488
|
}
|
|
3343
3489
|
/**
|
|
3344
3490
|
* Public contract entrypoint (ADR-0019). Records a decision on a node-driven
|
|
3345
3491
|
* request via {@link ApprovalService.decideNode} and, when it finalizes,
|
|
3346
3492
|
* resumes the owning flow run down the matching `approve` / `reject` edge.
|
|
3493
|
+
*
|
|
3494
|
+
* A finalising decision whose run cannot be resumed FAILS (#4420). The
|
|
3495
|
+
* decision is already durable by then, so the failure cannot be rolled back
|
|
3496
|
+
* — but it must not be reported as success either: this used to answer HTTP
|
|
3497
|
+
* 200 with `resumed: true` while the flow stayed parked forever, which left
|
|
3498
|
+
* the approver with no signal and the record mirroring a stage it never
|
|
3499
|
+
* reached. `decideNode`'s pre-flight means the common case (the run died
|
|
3500
|
+
* before the decision) never gets this far; what survives here is a genuine
|
|
3501
|
+
* race, and it names the stranded run.
|
|
3347
3502
|
*/
|
|
3348
3503
|
async decide(requestId, input, context) {
|
|
3349
3504
|
const result = await this.decideNode(requestId, input, context);
|
|
3350
3505
|
let resumed = false;
|
|
3506
|
+
let resumeError;
|
|
3351
3507
|
if (result.finalized && result.runId && typeof this.automation?.resume === "function") {
|
|
3352
3508
|
const branchLabel = result.decision === "approve" ? import_automation.APPROVAL_BRANCH_LABELS.approve : import_automation.APPROVAL_BRANCH_LABELS.reject;
|
|
3353
|
-
|
|
3354
|
-
|
|
3509
|
+
const outcome = await this.resumeRecordedOutcome(
|
|
3510
|
+
result.runId,
|
|
3511
|
+
requestId,
|
|
3512
|
+
`the ${result.decision} decision`,
|
|
3513
|
+
{
|
|
3355
3514
|
branchLabel,
|
|
3356
3515
|
// #3447 P2: accepted decision outputs ride the resume envelope and
|
|
3357
3516
|
// land as `<nodeId>.<key>` flow variables — a later approval node's
|
|
@@ -3359,22 +3518,18 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3359
3518
|
// Reserved keys are spread LAST so no output can shadow them (the
|
|
3360
3519
|
// whitelist already rejects them; this is defense in depth).
|
|
3361
3520
|
output: { ...result.outputs ?? {}, decision: result.decision, requestId }
|
|
3362
|
-
}
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
request: requestId,
|
|
3367
|
-
run: result.runId,
|
|
3368
|
-
error: err?.message ?? String(err)
|
|
3369
|
-
});
|
|
3370
|
-
}
|
|
3521
|
+
}
|
|
3522
|
+
);
|
|
3523
|
+
resumed = outcome.resumed;
|
|
3524
|
+
resumeError = outcome.resumeError;
|
|
3371
3525
|
}
|
|
3372
3526
|
return {
|
|
3373
3527
|
request: result.request,
|
|
3374
3528
|
finalized: result.finalized,
|
|
3375
3529
|
decision: result.decision,
|
|
3376
3530
|
runId: result.runId,
|
|
3377
|
-
resumed
|
|
3531
|
+
resumed,
|
|
3532
|
+
...resumeError ? { resumeError } : {}
|
|
3378
3533
|
};
|
|
3379
3534
|
}
|
|
3380
3535
|
/**
|
|
@@ -3442,15 +3597,17 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3442
3597
|
);
|
|
3443
3598
|
}
|
|
3444
3599
|
let resumed = false;
|
|
3600
|
+
let resumeError;
|
|
3445
3601
|
if (inReviseWindow) {
|
|
3446
3602
|
if (runId && typeof this.automation?.cancelRun === "function") {
|
|
3447
3603
|
try {
|
|
3448
3604
|
await this.automation.cancelRun(runId, `approval request ${requestId} recalled during revision`);
|
|
3449
3605
|
} catch (err) {
|
|
3450
|
-
|
|
3606
|
+
resumeError = err?.message ?? String(err);
|
|
3607
|
+
this.logger?.error?.("[approvals] cancelRun after revise-window recall failed \u2014 the run may be stranded", {
|
|
3451
3608
|
request: requestId,
|
|
3452
3609
|
run: runId,
|
|
3453
|
-
error:
|
|
3610
|
+
error: resumeError
|
|
3454
3611
|
});
|
|
3455
3612
|
}
|
|
3456
3613
|
}
|
|
@@ -3462,15 +3619,16 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3462
3619
|
});
|
|
3463
3620
|
resumed = true;
|
|
3464
3621
|
} catch (err) {
|
|
3465
|
-
|
|
3622
|
+
resumeError = err?.message ?? String(err);
|
|
3623
|
+
this.logger?.error?.("[approvals] resume after recall failed \u2014 the run may be stranded", {
|
|
3466
3624
|
request: requestId,
|
|
3467
3625
|
run: runId,
|
|
3468
|
-
error:
|
|
3626
|
+
error: resumeError
|
|
3469
3627
|
});
|
|
3470
3628
|
}
|
|
3471
3629
|
}
|
|
3472
3630
|
const fresh = await this.readBackRequest(requestId, context);
|
|
3473
|
-
return { request: fresh, runId, resumed };
|
|
3631
|
+
return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
|
|
3474
3632
|
}
|
|
3475
3633
|
// ── Send back for revision / resubmit (ADR-0044) ─────────────
|
|
3476
3634
|
/**
|
|
@@ -3498,6 +3656,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3498
3656
|
const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
|
|
3499
3657
|
const runId = raw.flow_run_id ?? null;
|
|
3500
3658
|
await this.assertReviseEdge(raw, nodeId);
|
|
3659
|
+
await this.assertRunResumable(runId, requestId);
|
|
3501
3660
|
const now = this.clock.now().toISOString();
|
|
3502
3661
|
const maxRevisions = typeof config.maxRevisions === "number" ? config.maxRevisions : 3;
|
|
3503
3662
|
let priorSendBacks = 0;
|
|
@@ -3550,20 +3709,19 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3550
3709
|
);
|
|
3551
3710
|
}
|
|
3552
3711
|
let resumed2 = false;
|
|
3712
|
+
let resumeError2;
|
|
3553
3713
|
if (runId && typeof this.automation?.resume === "function") {
|
|
3554
|
-
|
|
3555
|
-
|
|
3714
|
+
const outcome = await this.resumeRecordedOutcome(
|
|
3715
|
+
runId,
|
|
3716
|
+
requestId,
|
|
3717
|
+
"the auto-rejection",
|
|
3718
|
+
{
|
|
3556
3719
|
branchLabel: import_automation.APPROVAL_BRANCH_LABELS.reject,
|
|
3557
3720
|
output: { decision: "reject", autoRejected: true, requestId }
|
|
3558
|
-
}
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
request: requestId,
|
|
3563
|
-
run: runId,
|
|
3564
|
-
error: err?.message ?? String(err)
|
|
3565
|
-
});
|
|
3566
|
-
}
|
|
3721
|
+
}
|
|
3722
|
+
);
|
|
3723
|
+
resumed2 = outcome.resumed;
|
|
3724
|
+
resumeError2 = outcome.resumeError;
|
|
3567
3725
|
}
|
|
3568
3726
|
if (raw.submitter_id) {
|
|
3569
3727
|
await this.notify({
|
|
@@ -3579,7 +3737,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3579
3737
|
});
|
|
3580
3738
|
}
|
|
3581
3739
|
const fresh2 = await this.readBackRequest(requestId, context);
|
|
3582
|
-
return { request: fresh2, runId, resumed: resumed2, autoRejected: true };
|
|
3740
|
+
return { request: fresh2, runId, resumed: resumed2, autoRejected: true, ...resumeError2 ? { resumeError: resumeError2 } : {} };
|
|
3583
3741
|
}
|
|
3584
3742
|
await this.engine.update("sys_approval_request", {
|
|
3585
3743
|
id: requestId,
|
|
@@ -3599,20 +3757,19 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3599
3757
|
);
|
|
3600
3758
|
}
|
|
3601
3759
|
let resumed = false;
|
|
3760
|
+
let resumeError;
|
|
3602
3761
|
if (runId && typeof this.automation?.resume === "function") {
|
|
3603
|
-
|
|
3604
|
-
|
|
3762
|
+
const outcome = await this.resumeRecordedOutcome(
|
|
3763
|
+
runId,
|
|
3764
|
+
requestId,
|
|
3765
|
+
"the send-back",
|
|
3766
|
+
{
|
|
3605
3767
|
branchLabel: import_automation.APPROVAL_BRANCH_LABELS.revise,
|
|
3606
3768
|
output: { decision: "revise", requestId }
|
|
3607
|
-
}
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
request: requestId,
|
|
3612
|
-
run: runId,
|
|
3613
|
-
error: err?.message ?? String(err)
|
|
3614
|
-
});
|
|
3615
|
-
}
|
|
3769
|
+
}
|
|
3770
|
+
);
|
|
3771
|
+
resumed = outcome.resumed;
|
|
3772
|
+
resumeError = outcome.resumeError;
|
|
3616
3773
|
}
|
|
3617
3774
|
if (raw.submitter_id) {
|
|
3618
3775
|
await this.notify({
|
|
@@ -3628,7 +3785,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3628
3785
|
});
|
|
3629
3786
|
}
|
|
3630
3787
|
const fresh = await this.readBackRequest(requestId, context);
|
|
3631
|
-
return { request: fresh, runId, resumed };
|
|
3788
|
+
return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
|
|
3632
3789
|
}
|
|
3633
3790
|
/**
|
|
3634
3791
|
* ADR-0044 resubmit after rework. Valid on the LATEST `returned` request of
|
|
@@ -3667,6 +3824,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3667
3824
|
const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
|
|
3668
3825
|
const runId = raw.flow_run_id ?? null;
|
|
3669
3826
|
const now = this.clock.now().toISOString();
|
|
3827
|
+
await this.assertRunResumable(runId, requestId);
|
|
3670
3828
|
await this.engine.insert("sys_approval_action", {
|
|
3671
3829
|
id: uid("aact"),
|
|
3672
3830
|
request_id: requestId,
|
|
@@ -3679,23 +3837,22 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3679
3837
|
created_at: now
|
|
3680
3838
|
}, { context: SYSTEM_CTX2 });
|
|
3681
3839
|
let resumed = false;
|
|
3840
|
+
let resumeError;
|
|
3682
3841
|
if (runId && typeof this.automation?.resume === "function") {
|
|
3683
|
-
|
|
3684
|
-
|
|
3842
|
+
const outcome = await this.resumeRecordedOutcome(
|
|
3843
|
+
runId,
|
|
3844
|
+
requestId,
|
|
3845
|
+
"the resubmit",
|
|
3846
|
+
{
|
|
3685
3847
|
branchLabel: import_automation.APPROVAL_BRANCH_LABELS.resubmit,
|
|
3686
3848
|
output: { resubmitted: true, requestId }
|
|
3687
|
-
}
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
request: requestId,
|
|
3692
|
-
run: runId,
|
|
3693
|
-
error: err?.message ?? String(err)
|
|
3694
|
-
});
|
|
3695
|
-
}
|
|
3849
|
+
}
|
|
3850
|
+
);
|
|
3851
|
+
resumed = outcome.resumed;
|
|
3852
|
+
resumeError = outcome.resumeError;
|
|
3696
3853
|
}
|
|
3697
3854
|
const fresh = await this.readBackRequest(requestId, context);
|
|
3698
|
-
return { request: fresh, runId, resumed };
|
|
3855
|
+
return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
|
|
3699
3856
|
}
|
|
3700
3857
|
/**
|
|
3701
3858
|
* ADR-0044 guard: the flow's approval node must declare a `revise`
|
|
@@ -3756,6 +3913,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3756
3913
|
}
|
|
3757
3914
|
const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
|
|
3758
3915
|
const from = String(input.from ?? actorId).trim();
|
|
3916
|
+
const viaOverride = isOverride && !pending.includes(actorId);
|
|
3759
3917
|
let next;
|
|
3760
3918
|
if (pending.includes(from)) {
|
|
3761
3919
|
if (!context.isSystem && !isOverride && actorId !== from && !pending.includes(actorId)) {
|
|
@@ -3781,6 +3939,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3781
3939
|
actor_id: actorId,
|
|
3782
3940
|
reassign_from: from,
|
|
3783
3941
|
reassign_to: to,
|
|
3942
|
+
via_override: viaOverride,
|
|
3784
3943
|
comment: input.comment ?? null,
|
|
3785
3944
|
created_at: now
|
|
3786
3945
|
}, { context: SYSTEM_CTX2 });
|
|
@@ -4145,6 +4304,137 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4145
4304
|
* the real cause and {@link DEAD_RUN_ACTOR_ID} the real actor, so a dead-run
|
|
4146
4305
|
* release is never mistaken for a submitter's withdrawal.
|
|
4147
4306
|
*/
|
|
4307
|
+
/**
|
|
4308
|
+
* Read-only inspection for the OTHER dead-run shape: a request that is
|
|
4309
|
+
* already TERMINAL while its `flow_run_id` points at nothing (#4469).
|
|
4310
|
+
*
|
|
4311
|
+
* #4460 stopped new ones being produced; nothing found the ones already
|
|
4312
|
+
* stuck. The failure mode (#4420) is a request row flipped to `approved` /
|
|
4313
|
+
* `rejected` / `returned` whose owning run no longer exists — the decision
|
|
4314
|
+
* landed, the flow never moved. Any deployment on 17.0.0-rc.1 that hit the
|
|
4315
|
+
* wiring hole and crossed a restart mid-approval can be carrying these rows.
|
|
4316
|
+
*
|
|
4317
|
+
* {@link releaseDeadRunRequests} cannot see them, for a reason worth naming:
|
|
4318
|
+
* it scans `status: 'pending'`, and the very step that zombified the request
|
|
4319
|
+
* is the one that took it OUT of `pending`. The act of breaking it removed it
|
|
4320
|
+
* from the only sweeper's field of view — which is a large part of why this
|
|
4321
|
+
* class of failure stayed silent.
|
|
4322
|
+
*
|
|
4323
|
+
* It also could not have answered the question even if it looked: its
|
|
4324
|
+
* liveness oracle is `getRun`, which reads the execution LOG, and after a
|
|
4325
|
+
* restart that returns `null` for a perfectly ALIVE suspended run. It treats
|
|
4326
|
+
* `null` as alive (conservative, correct) — but that means it has no way to
|
|
4327
|
+
* say "this run is really gone".
|
|
4328
|
+
*
|
|
4329
|
+
* So this uses BOTH oracles, and a row must fail both to be reported:
|
|
4330
|
+
*
|
|
4331
|
+
* - `hasSuspendedRun(runId) === false` — the suspension store itself says no
|
|
4332
|
+
* live pause exists. It THROWS when the store cannot be read, and that
|
|
4333
|
+
* case is SKIPPED, never counted as dead: an unreadable store means
|
|
4334
|
+
* "unknown", and a storage outage must not be published as a lost run.
|
|
4335
|
+
* - `getRun(runId) == null` — no terminal history row either (the `run_`
|
|
4336
|
+
* prefixed rows in `sys_automation_run`). A run that merely finished is
|
|
4337
|
+
* not stranded; a request whose run neither waits nor ever completed is.
|
|
4338
|
+
*
|
|
4339
|
+
* **Reports; never rewrites.** No status is changed and no run is cancelled.
|
|
4340
|
+
* The decision genuinely happened — a human approved or rejected — and
|
|
4341
|
+
* silently rolling it back would make the audit trail disagree with the
|
|
4342
|
+
* facts. What an operator needs first is visibility: which requests are stuck
|
|
4343
|
+
* at which step, and what the mirrored status field on the business record
|
|
4344
|
+
* still says. Whether to re-run the downstream actions or re-open the
|
|
4345
|
+
* approval is a judgement call this cannot make.
|
|
4346
|
+
*/
|
|
4347
|
+
async inspectStrandedRequests(options) {
|
|
4348
|
+
const empty = { scanned: 0, stranded: [], undetermined: 0 };
|
|
4349
|
+
if (typeof this.automation?.hasSuspendedRun !== "function") return empty;
|
|
4350
|
+
if (typeof this.automation?.getRun !== "function") return empty;
|
|
4351
|
+
const limit = options?.limit ?? 500;
|
|
4352
|
+
let rows = [];
|
|
4353
|
+
try {
|
|
4354
|
+
rows = await this.engine.find("sys_approval_request", {
|
|
4355
|
+
where: { status: { $in: [...STRANDABLE_REQUEST_STATUSES] } },
|
|
4356
|
+
limit,
|
|
4357
|
+
context: SYSTEM_CTX2
|
|
4358
|
+
}) ?? [];
|
|
4359
|
+
} catch (err) {
|
|
4360
|
+
this.logger?.warn?.("[approvals] stranded-request scan failed to list requests", {
|
|
4361
|
+
error: err?.message ?? String(err)
|
|
4362
|
+
});
|
|
4363
|
+
return empty;
|
|
4364
|
+
}
|
|
4365
|
+
const stranded = [];
|
|
4366
|
+
let undetermined = 0;
|
|
4367
|
+
for (const raw of rows) {
|
|
4368
|
+
const runId = raw?.flow_run_id ? String(raw.flow_run_id) : "";
|
|
4369
|
+
if (!runId) continue;
|
|
4370
|
+
let suspended;
|
|
4371
|
+
try {
|
|
4372
|
+
suspended = await this.automation.hasSuspendedRun(runId);
|
|
4373
|
+
} catch (err) {
|
|
4374
|
+
undetermined++;
|
|
4375
|
+
this.logger?.warn?.("[approvals] stranded-request scan could not read the suspension store", {
|
|
4376
|
+
request: raw?.id,
|
|
4377
|
+
run: runId,
|
|
4378
|
+
error: err?.message ?? String(err)
|
|
4379
|
+
});
|
|
4380
|
+
continue;
|
|
4381
|
+
}
|
|
4382
|
+
if (suspended) continue;
|
|
4383
|
+
let terminal = null;
|
|
4384
|
+
try {
|
|
4385
|
+
terminal = await this.automation.getRun(runId);
|
|
4386
|
+
} catch (err) {
|
|
4387
|
+
undetermined++;
|
|
4388
|
+
this.logger?.warn?.("[approvals] stranded-request scan could not read the run history", {
|
|
4389
|
+
request: raw?.id,
|
|
4390
|
+
run: runId,
|
|
4391
|
+
error: err?.message ?? String(err)
|
|
4392
|
+
});
|
|
4393
|
+
continue;
|
|
4394
|
+
}
|
|
4395
|
+
if (terminal) continue;
|
|
4396
|
+
const config = parseJson(
|
|
4397
|
+
raw.node_config_json,
|
|
4398
|
+
{ approvers: [], behavior: "first_response" }
|
|
4399
|
+
);
|
|
4400
|
+
const mirrorField = config.approvalStatusField;
|
|
4401
|
+
let mirroredStatus;
|
|
4402
|
+
if (mirrorField) {
|
|
4403
|
+
try {
|
|
4404
|
+
const recs = await this.engine.find(raw.object_name, {
|
|
4405
|
+
where: { id: raw.record_id },
|
|
4406
|
+
limit: 1,
|
|
4407
|
+
context: SYSTEM_CTX2
|
|
4408
|
+
});
|
|
4409
|
+
const rec = Array.isArray(recs) ? recs[0] : null;
|
|
4410
|
+
if (rec) mirroredStatus = rec[mirrorField] ?? void 0;
|
|
4411
|
+
} catch {
|
|
4412
|
+
}
|
|
4413
|
+
}
|
|
4414
|
+
stranded.push({
|
|
4415
|
+
requestId: String(raw.id),
|
|
4416
|
+
status: raw.status,
|
|
4417
|
+
runId,
|
|
4418
|
+
flowName: typeof raw.process_name === "string" ? raw.process_name.replace(/^flow:/, "") : void 0,
|
|
4419
|
+
nodeId: raw.flow_node_id ?? raw.current_step ?? void 0,
|
|
4420
|
+
objectName: raw.object_name,
|
|
4421
|
+
recordId: raw.record_id,
|
|
4422
|
+
organizationId: raw.organization_id ?? null,
|
|
4423
|
+
completedAt: raw.completed_at ?? void 0,
|
|
4424
|
+
mirrorField,
|
|
4425
|
+
mirroredStatus
|
|
4426
|
+
});
|
|
4427
|
+
}
|
|
4428
|
+
if (stranded.length || undetermined) {
|
|
4429
|
+
this.logger?.warn?.("[approvals] stranded terminal requests (decision recorded, flow run gone)", {
|
|
4430
|
+
scanned: rows.length,
|
|
4431
|
+
stranded: stranded.length,
|
|
4432
|
+
undetermined,
|
|
4433
|
+
requests: stranded.map((s) => `${s.requestId}@${s.nodeId ?? "?"} \u2192 run ${s.runId}`)
|
|
4434
|
+
});
|
|
4435
|
+
}
|
|
4436
|
+
return { scanned: rows.length, stranded, undetermined };
|
|
4437
|
+
}
|
|
4148
4438
|
async releaseDeadRunRequests() {
|
|
4149
4439
|
if (typeof this.automation?.getRun !== "function") return { scanned: 0, released: 0 };
|
|
4150
4440
|
let rows = [];
|
|
@@ -5205,21 +5495,109 @@ function parseJson2(raw, fallback) {
|
|
|
5205
5495
|
}
|
|
5206
5496
|
return raw;
|
|
5207
5497
|
}
|
|
5498
|
+
var PENDING_LOCK_LIMIT = 1e3;
|
|
5499
|
+
var SYSTEM_CTX3 = { isSystem: true, positions: [], permissions: [] };
|
|
5500
|
+
function lockedError(message) {
|
|
5501
|
+
const err = new Error(`RECORD_LOCKED: ${message}`);
|
|
5502
|
+
err.code = "RECORD_LOCKED";
|
|
5503
|
+
err.statusCode = 409;
|
|
5504
|
+
throw err;
|
|
5505
|
+
}
|
|
5506
|
+
function asIdList(id) {
|
|
5507
|
+
if (typeof id === "number") return [id];
|
|
5508
|
+
if (typeof id === "string") return id === "" ? null : [id];
|
|
5509
|
+
if (id && typeof id === "object" && Array.isArray(id.$in)) {
|
|
5510
|
+
const raw = id.$in;
|
|
5511
|
+
const scalars = raw.filter((v) => typeof v === "string" || typeof v === "number");
|
|
5512
|
+
return scalars.length === raw.length ? scalars : null;
|
|
5513
|
+
}
|
|
5514
|
+
return null;
|
|
5515
|
+
}
|
|
5208
5516
|
async function pendingRequestFor(engine, objectName, recordId) {
|
|
5209
5517
|
try {
|
|
5210
5518
|
const rows = await engine.find("sys_approval_request", {
|
|
5211
5519
|
where: { object_name: objectName, record_id: String(recordId), status: "pending" },
|
|
5212
|
-
limit: 1
|
|
5520
|
+
limit: 1,
|
|
5521
|
+
context: { ...SYSTEM_CTX3 }
|
|
5213
5522
|
});
|
|
5214
5523
|
return Array.isArray(rows) && rows[0] ? rows[0] : null;
|
|
5215
5524
|
} catch {
|
|
5216
5525
|
return null;
|
|
5217
5526
|
}
|
|
5218
5527
|
}
|
|
5528
|
+
async function pendingRequestsForRecords(engine, objectName, recordIds) {
|
|
5529
|
+
if (recordIds.length === 0) return [];
|
|
5530
|
+
if (recordIds.length > PENDING_LOCK_LIMIT) {
|
|
5531
|
+
lockedError(
|
|
5532
|
+
`refusing to authorize an update naming more than ${PENDING_LOCK_LIMIT} records of '${objectName}' \u2014 the approval lock cannot check them row by row; scope the write`
|
|
5533
|
+
);
|
|
5534
|
+
}
|
|
5535
|
+
if (recordIds.length === 1) {
|
|
5536
|
+
const one = await pendingRequestFor(engine, objectName, String(recordIds[0]));
|
|
5537
|
+
return one ? [one] : [];
|
|
5538
|
+
}
|
|
5539
|
+
try {
|
|
5540
|
+
const rows = await engine.find("sys_approval_request", {
|
|
5541
|
+
where: { object_name: objectName, record_id: { $in: recordIds.map(String) }, status: "pending" },
|
|
5542
|
+
limit: PENDING_LOCK_LIMIT + 1,
|
|
5543
|
+
context: { ...SYSTEM_CTX3 }
|
|
5544
|
+
});
|
|
5545
|
+
return Array.isArray(rows) ? rows : [];
|
|
5546
|
+
} catch {
|
|
5547
|
+
return [];
|
|
5548
|
+
}
|
|
5549
|
+
}
|
|
5550
|
+
async function pendingRequestsForObject(engine, objectName) {
|
|
5551
|
+
try {
|
|
5552
|
+
const rows = await engine.find("sys_approval_request", {
|
|
5553
|
+
where: { object_name: objectName, status: "pending" },
|
|
5554
|
+
limit: PENDING_LOCK_LIMIT + 1,
|
|
5555
|
+
context: { ...SYSTEM_CTX3 }
|
|
5556
|
+
});
|
|
5557
|
+
return Array.isArray(rows) ? rows : [];
|
|
5558
|
+
} catch {
|
|
5559
|
+
return null;
|
|
5560
|
+
}
|
|
5561
|
+
}
|
|
5562
|
+
async function narrowToMatchedRecords(engine, objectName, where, candidates) {
|
|
5563
|
+
const lockedIds = candidates.map((c) => String(c?.record_id ?? ""));
|
|
5564
|
+
let rows;
|
|
5565
|
+
try {
|
|
5566
|
+
rows = await engine.find(objectName, {
|
|
5567
|
+
where: { $and: [where, { id: { $in: lockedIds } }] },
|
|
5568
|
+
fields: ["id"],
|
|
5569
|
+
limit: lockedIds.length,
|
|
5570
|
+
context: { ...SYSTEM_CTX3 }
|
|
5571
|
+
});
|
|
5572
|
+
} catch (err) {
|
|
5573
|
+
lockedError(
|
|
5574
|
+
`cannot determine which rows a predicate update on '${objectName}' would touch (${err?.message ?? String(err)}); ${candidates.length} record(s) of it carry a pending approval, so the write is refused`
|
|
5575
|
+
);
|
|
5576
|
+
}
|
|
5577
|
+
const matched = new Set((Array.isArray(rows) ? rows : []).map((r) => String(r?.id)));
|
|
5578
|
+
return candidates.filter((c) => matched.has(String(c?.record_id ?? "")));
|
|
5579
|
+
}
|
|
5580
|
+
async function gatingRequests(engine, ctx, objectName) {
|
|
5581
|
+
const byId = asIdList(ctx?.input?.id);
|
|
5582
|
+
if (byId) return pendingRequestsForRecords(engine, objectName, byId);
|
|
5583
|
+
const rawWhere = ctx?.input?.options?.where;
|
|
5584
|
+
const hasWhere = rawWhere !== void 0 && rawWhere !== null;
|
|
5585
|
+
const whereObj = hasWhere && typeof rawWhere === "object" && !Array.isArray(rawWhere) ? rawWhere : null;
|
|
5586
|
+
const namedIds = whereObj ? asIdList(whereObj.id) : null;
|
|
5587
|
+
const candidates = namedIds ? await pendingRequestsForRecords(engine, objectName, namedIds) : await pendingRequestsForObject(engine, objectName);
|
|
5588
|
+
if (candidates === null) return [];
|
|
5589
|
+
if (candidates.length === 0) return [];
|
|
5590
|
+
if (candidates.length > PENDING_LOCK_LIMIT) {
|
|
5591
|
+
lockedError(
|
|
5592
|
+
`refusing a predicate update on '${objectName}': more than ${PENDING_LOCK_LIMIT} of its records carry a pending approval, so the lock cannot decide row by row; scope the write to the rows you mean`
|
|
5593
|
+
);
|
|
5594
|
+
}
|
|
5595
|
+
if (!hasWhere) return candidates;
|
|
5596
|
+
if (namedIds && whereObj && Object.keys(whereObj).every((k) => k === "id")) return candidates;
|
|
5597
|
+
return narrowToMatchedRecords(engine, objectName, rawWhere, candidates);
|
|
5598
|
+
}
|
|
5219
5599
|
function bindApprovalLockHook(engine, logger) {
|
|
5220
5600
|
engine.registerHook("beforeUpdate", async (ctx) => {
|
|
5221
|
-
const id = String(ctx?.input?.id ?? "");
|
|
5222
|
-
if (!id) return;
|
|
5223
5601
|
const object = ctx?.object ?? ctx?.objectName;
|
|
5224
5602
|
if (!object || String(object).startsWith("sys_approval")) return;
|
|
5225
5603
|
const data = ctx?.input?.data ?? {};
|
|
@@ -5228,18 +5606,19 @@ function bindApprovalLockHook(engine, logger) {
|
|
|
5228
5606
|
if (ctx?.session?.isSystem) return;
|
|
5229
5607
|
const roles = ctx?.session?.roles ?? [];
|
|
5230
5608
|
if (Array.isArray(roles) && roles.includes("admin")) return;
|
|
5231
|
-
const
|
|
5232
|
-
if (
|
|
5609
|
+
const gating = await gatingRequests(engine, ctx, object);
|
|
5610
|
+
if (gating.length === 0) return;
|
|
5233
5611
|
const writerRun = ctx?.provenance?.flowRunId;
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
|
|
5237
|
-
|
|
5238
|
-
|
|
5239
|
-
|
|
5240
|
-
|
|
5241
|
-
|
|
5242
|
-
|
|
5612
|
+
for (const pending of gating) {
|
|
5613
|
+
if (writerRun && pending?.flow_run_id && String(writerRun) === String(pending.flow_run_id)) continue;
|
|
5614
|
+
const config = parseJson2(pending?.node_config_json, {});
|
|
5615
|
+
if (config?.lockRecord === false) continue;
|
|
5616
|
+
const mirror = config?.approvalStatusField;
|
|
5617
|
+
if (typeof mirror === "string" && mirror && changedFields.every((f) => f === mirror)) continue;
|
|
5618
|
+
lockedError(
|
|
5619
|
+
`record '${String(pending?.record_id ?? "")}' of '${object}' is locked while an approval is in progress`
|
|
5620
|
+
);
|
|
5621
|
+
}
|
|
5243
5622
|
}, { packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
|
|
5244
5623
|
logger?.info?.("[approvals] record-lock hook bound");
|
|
5245
5624
|
}
|
|
@@ -5283,7 +5662,7 @@ function unbindAllHooks(engine) {
|
|
|
5283
5662
|
|
|
5284
5663
|
// src/approval-node.ts
|
|
5285
5664
|
var import_automation2 = require("@objectstack/spec/automation");
|
|
5286
|
-
var
|
|
5665
|
+
var SYSTEM_CTX4 = { isSystem: true, positions: [], permissions: [] };
|
|
5287
5666
|
function nestVariables(variables) {
|
|
5288
5667
|
const vars = {};
|
|
5289
5668
|
for (const [key, value] of variables) {
|
|
@@ -5362,7 +5741,7 @@ function registerApprovalNode(automation, service, logger) {
|
|
|
5362
5741
|
// their `trigger.*` snapshot root.
|
|
5363
5742
|
variables: nestVariables(variables)
|
|
5364
5743
|
}, {
|
|
5365
|
-
...
|
|
5744
|
+
...SYSTEM_CTX4,
|
|
5366
5745
|
userId: context?.userId,
|
|
5367
5746
|
organizationId: context?.organizationId,
|
|
5368
5747
|
tenantId: context?.tenantId
|
|
@@ -5506,7 +5885,13 @@ var ApprovalsServicePlugin = class {
|
|
|
5506
5885
|
const sweep = async () => {
|
|
5507
5886
|
const results = await Promise.allSettled([
|
|
5508
5887
|
svc.runEscalations(),
|
|
5509
|
-
svc.releaseDeadRunRequests()
|
|
5888
|
+
svc.releaseDeadRunRequests(),
|
|
5889
|
+
// #4469 — the other half of the dead-run picture, and the one no
|
|
5890
|
+
// sweeper could see: a request already TERMINAL whose run is gone.
|
|
5891
|
+
// Read-only by design (it reports; it never rewrites a decision
|
|
5892
|
+
// that really happened), so it rides the same clock purely to make
|
|
5893
|
+
// the finding surface without an operator knowing to go looking.
|
|
5894
|
+
svc.inspectStrandedRequests()
|
|
5510
5895
|
]);
|
|
5511
5896
|
for (const r of results) {
|
|
5512
5897
|
if (r.status === "rejected") {
|
|
@@ -5581,14 +5966,19 @@ var ApprovalsServicePlugin = class {
|
|
|
5581
5966
|
await mountActionPages();
|
|
5582
5967
|
await backfillApproverIndex();
|
|
5583
5968
|
}
|
|
5969
|
+
let automation;
|
|
5584
5970
|
try {
|
|
5585
|
-
|
|
5586
|
-
if (automation && typeof automation.registerNodeExecutor === "function") {
|
|
5587
|
-
this.service.attachAutomation(automation);
|
|
5588
|
-
registerApprovalNode(automation, this.service, ctx.logger);
|
|
5589
|
-
}
|
|
5971
|
+
automation = ctx.getService("automation");
|
|
5590
5972
|
} catch {
|
|
5591
|
-
|
|
5973
|
+
automation = void 0;
|
|
5974
|
+
}
|
|
5975
|
+
if (automation && typeof automation.registerNodeExecutor === "function") {
|
|
5976
|
+
this.service.attachAutomation(automation);
|
|
5977
|
+
registerApprovalNode(automation, this.service, ctx.logger);
|
|
5978
|
+
} else {
|
|
5979
|
+
ctx.logger.warn(
|
|
5980
|
+
"ApprovalsServicePlugin: no automation engine \u2014 the `approval` flow node is NOT registered. Every ADR-0019 approval flow in this deployment fails at execution time with NO_EXECUTOR. Add @objectstack/service-automation to the stack to enable them."
|
|
5981
|
+
);
|
|
5592
5982
|
}
|
|
5593
5983
|
}
|
|
5594
5984
|
async stop(ctx) {
|