@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.mjs
CHANGED
|
@@ -245,6 +245,10 @@ var init_en_objects_generated = __esm({
|
|
|
245
245
|
comment: {
|
|
246
246
|
label: "Comment"
|
|
247
247
|
},
|
|
248
|
+
via_override: {
|
|
249
|
+
label: "Via Admin Override",
|
|
250
|
+
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."
|
|
251
|
+
},
|
|
248
252
|
reassign_from: {
|
|
249
253
|
label: "Reassigned From",
|
|
250
254
|
help: "User whose pending-approver slot was handed over (reassign actions only)"
|
|
@@ -562,6 +566,10 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
562
566
|
comment: {
|
|
563
567
|
label: "\u8BC4\u8BBA"
|
|
564
568
|
},
|
|
569
|
+
via_override: {
|
|
570
|
+
label: "\u7BA1\u7406\u5458\u8D8A\u6743\u64CD\u4F5C",
|
|
571
|
+
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"
|
|
572
|
+
},
|
|
565
573
|
reassign_from: {
|
|
566
574
|
label: "\u8F6C\u51FA\u4EBA",
|
|
567
575
|
help: "\u88AB\u79FB\u4EA4\u5F85\u5BA1\u6279\u69FD\u4F4D\u7684\u7528\u6237\uFF08\u4EC5\u8F6C\u7B7E\u64CD\u4F5C\uFF09"
|
|
@@ -879,6 +887,10 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
879
887
|
comment: {
|
|
880
888
|
label: "\u30B3\u30E1\u30F3\u30C8"
|
|
881
889
|
},
|
|
890
|
+
via_override: {
|
|
891
|
+
label: "\u7BA1\u7406\u8005\u30AA\u30FC\u30D0\u30FC\u30E9\u30A4\u30C9\u7D4C\u7531",
|
|
892
|
+
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"
|
|
893
|
+
},
|
|
882
894
|
reassign_from: {
|
|
883
895
|
label: "\u5F15\u304D\u7D99\u304E\u5143",
|
|
884
896
|
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"
|
|
@@ -1196,6 +1208,10 @@ var init_es_ES_objects_generated = __esm({
|
|
|
1196
1208
|
comment: {
|
|
1197
1209
|
label: "Comentario"
|
|
1198
1210
|
},
|
|
1211
|
+
via_override: {
|
|
1212
|
+
label: "Mediante anulaci\xF3n de administrador",
|
|
1213
|
+
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."
|
|
1214
|
+
},
|
|
1199
1215
|
reassign_from: {
|
|
1200
1216
|
label: "Reasignado de",
|
|
1201
1217
|
help: "Usuario cuyo turno de aprobaci\xF3n pendiente fue traspasado (solo acciones de reasignaci\xF3n)"
|
|
@@ -1689,7 +1705,7 @@ var SysApprovalAction = ObjectSchema2.create({
|
|
|
1689
1705
|
nameField: "id",
|
|
1690
1706
|
// [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
|
|
1691
1707
|
titleFormat: "{action} \xB7 {step_name}",
|
|
1692
|
-
highlightFields: ["request_id", "step_name", "action", "actor_id", "created_at"],
|
|
1708
|
+
highlightFields: ["request_id", "step_name", "action", "actor_id", "via_override", "created_at"],
|
|
1693
1709
|
// ADR-0104 D3 wave 2. `attachments` is a media field, so the files it holds
|
|
1694
1710
|
// are OWNED by this row — and the storage service would otherwise authorize
|
|
1695
1711
|
// their download by testing whether the caller can READ this row. It cannot:
|
|
@@ -1704,7 +1720,7 @@ var SysApprovalAction = ObjectSchema2.create({
|
|
|
1704
1720
|
name: "recent",
|
|
1705
1721
|
label: "Recent",
|
|
1706
1722
|
data: { provider: "object", object: "sys_approval_action" },
|
|
1707
|
-
columns: ["created_at", "request_id", "step_name", "action", "actor_id", "comment"],
|
|
1723
|
+
columns: ["created_at", "request_id", "step_name", "action", "actor_id", "via_override", "comment"],
|
|
1708
1724
|
sort: [{ field: "created_at", order: "desc" }],
|
|
1709
1725
|
pagination: { pageSize: 50 },
|
|
1710
1726
|
emptyState: { title: "No approval actions yet", message: "Actions are logged automatically when approvals progress." }
|
|
@@ -1724,7 +1740,7 @@ var SysApprovalAction = ObjectSchema2.create({
|
|
|
1724
1740
|
name: "all_actions",
|
|
1725
1741
|
label: "All",
|
|
1726
1742
|
data: { provider: "object", object: "sys_approval_action" },
|
|
1727
|
-
columns: ["created_at", "request_id", "step_name", "action", "actor_id", "comment"],
|
|
1743
|
+
columns: ["created_at", "request_id", "step_name", "action", "actor_id", "via_override", "comment"],
|
|
1728
1744
|
sort: [{ field: "created_at", order: "desc" }],
|
|
1729
1745
|
pagination: { pageSize: 100 }
|
|
1730
1746
|
}
|
|
@@ -1772,6 +1788,29 @@ var SysApprovalAction = ObjectSchema2.create({
|
|
|
1772
1788
|
group: "Action"
|
|
1773
1789
|
}),
|
|
1774
1790
|
comment: Field2.textarea({ label: "Comment", required: false, group: "Action" }),
|
|
1791
|
+
// #4466 — the one bit of "who really decided this" that was still dropped.
|
|
1792
|
+
// A privileged admin may act on a request whose staffed approver slate they
|
|
1793
|
+
// hold no slot in (the #3424 override path); before this column, that
|
|
1794
|
+
// decision was byte-for-byte identical to the designated approver's own
|
|
1795
|
+
// approval. A reader of the timeline saw `approve` by the admin and could
|
|
1796
|
+
// not tell whether the admin WAS an approver or OVERRODE the ones who were,
|
|
1797
|
+
// and the bypassed approver's later `409 INVALID_STATE` was the only trace
|
|
1798
|
+
// — existing only if they happened to try.
|
|
1799
|
+
//
|
|
1800
|
+
// The platform KNOWS at decision time: it took the `isOverrideActor` branch
|
|
1801
|
+
// to admit the call at all. This is dropped information, not unavailable
|
|
1802
|
+
// information.
|
|
1803
|
+
//
|
|
1804
|
+
// Set on exactly the decisions that were admitted BY that branch — an admin
|
|
1805
|
+
// who is also a genuine slot holder is approving normally and is recorded
|
|
1806
|
+
// as such. Nullable and additive: rows written before this column exists
|
|
1807
|
+
// carry `null`, which reads as "not recorded", never as "not an override".
|
|
1808
|
+
via_override: Field2.boolean({
|
|
1809
|
+
label: "Via Admin Override",
|
|
1810
|
+
required: false,
|
|
1811
|
+
group: "Action",
|
|
1812
|
+
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."
|
|
1813
|
+
}),
|
|
1775
1814
|
// Structured hand-off parties for `action: 'reassign'` (#4365). Before
|
|
1776
1815
|
// these existed the pair lived only inside a default free-text comment
|
|
1777
1816
|
// ("<from_id> → <to_id>"), which no client could parse or render readably.
|
|
@@ -1879,12 +1918,11 @@ var SysApprovalDelegation = ObjectSchema4.create({
|
|
|
1879
1918
|
pluralLabel: "Approval Delegations",
|
|
1880
1919
|
icon: "user-clock",
|
|
1881
1920
|
isSystem: true,
|
|
1882
|
-
|
|
1883
|
-
//
|
|
1884
|
-
//
|
|
1885
|
-
//
|
|
1886
|
-
|
|
1887
|
-
userActions: { create: true, edit: true, delete: true },
|
|
1921
|
+
// [ADR-0103, #3355] Admin/user-writable DATA on a platform-defined schema: a
|
|
1922
|
+
// user authors their own out-of-office delegation. The bucket default is full
|
|
1923
|
+
// CRUD (matching the full-CRUD apiMethods below), so no `userActions` block is
|
|
1924
|
+
// needed — RLS/permission sets are the authz.
|
|
1925
|
+
managedBy: "system-data",
|
|
1888
1926
|
description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
|
|
1889
1927
|
titleFormat: "{delegator_id} \u2192 {delegate_id}",
|
|
1890
1928
|
highlightFields: ["delegator_id", "delegate_id", "valid_from", "valid_until"],
|
|
@@ -2120,6 +2158,7 @@ var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
|
|
|
2120
2158
|
"cancelled",
|
|
2121
2159
|
"timed_out"
|
|
2122
2160
|
]);
|
|
2161
|
+
var STRANDABLE_REQUEST_STATUSES = ["approved", "rejected", "returned"];
|
|
2123
2162
|
var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
|
|
2124
2163
|
var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
|
|
2125
2164
|
function actingUserId(context) {
|
|
@@ -2259,6 +2298,11 @@ function rowFromAction(row) {
|
|
|
2259
2298
|
// Structured reassign hand-off parties (#4365).
|
|
2260
2299
|
reassign_from: row.reassign_from ?? void 0,
|
|
2261
2300
|
reassign_to: row.reassign_to ?? void 0,
|
|
2301
|
+
// #4466 — surfaced so a timeline can SAY "overridden the approver slate"
|
|
2302
|
+
// rather than render an override identically to an ordinary approval.
|
|
2303
|
+
// `null` (a row written before the column existed) stays `undefined`:
|
|
2304
|
+
// "not recorded" is not the same claim as "not an override".
|
|
2305
|
+
via_override: row.via_override == null ? void 0 : row.via_override === true,
|
|
2262
2306
|
// Decision attachments (#3266): rich descriptors carrying the display name +
|
|
2263
2307
|
// download URL, so consumers label/open them without reading `sys_file`.
|
|
2264
2308
|
attachments: attachments.length ? attachments : void 0,
|
|
@@ -3190,6 +3234,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3190
3234
|
if (!isSlotHolder && !isOverride) {
|
|
3191
3235
|
throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
|
|
3192
3236
|
}
|
|
3237
|
+
const viaOverride = isOverride && !isSlotHolder;
|
|
3193
3238
|
const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
|
|
3194
3239
|
const org = raw.organization_id ?? null;
|
|
3195
3240
|
const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
|
|
@@ -3227,6 +3272,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3227
3272
|
);
|
|
3228
3273
|
}
|
|
3229
3274
|
}
|
|
3275
|
+
await this.assertRunResumable(runId, requestId);
|
|
3230
3276
|
await this.engine.insert("sys_approval_action", {
|
|
3231
3277
|
id: uid("aact"),
|
|
3232
3278
|
request_id: requestId,
|
|
@@ -3236,6 +3282,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3236
3282
|
action: input.decision,
|
|
3237
3283
|
actor_id: actorId,
|
|
3238
3284
|
comment: input.comment ?? null,
|
|
3285
|
+
// #4466: the override is recorded on the DECISION, not inferred later.
|
|
3286
|
+
// Written as an explicit `false` for an ordinary decision so a reader can
|
|
3287
|
+
// tell "checked, and it was not an override" from a legacy row's `null`.
|
|
3288
|
+
via_override: viaOverride,
|
|
3239
3289
|
attachments: input.attachments?.length ? input.attachments : null,
|
|
3240
3290
|
created_at: now
|
|
3241
3291
|
}, { context: SYSTEM_CTX2 });
|
|
@@ -3322,22 +3372,131 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3322
3372
|
* Callers still guard on `typeof this.automation?.resume === 'function'`
|
|
3323
3373
|
* (approvals runs fine with no automation attached) and keep their own
|
|
3324
3374
|
* try/catch, because what a failed resume means differs per path.
|
|
3375
|
+
*
|
|
3376
|
+
* Throws when the engine REPORTS failure, not only when it throws one. The
|
|
3377
|
+
* engine answers a lost run with `{ success: false, code: 'RUN_NOT_FOUND' }`
|
|
3378
|
+
* — a plain return value that every caller here used to discard, which is
|
|
3379
|
+
* how an approval could be recorded, reported as resumed, and leave its flow
|
|
3380
|
+
* stranded forever (#4420). The thrown error carries {@link resumeCodeOf}'s
|
|
3381
|
+
* `resumeCode` so callers can tell a benign duplicate from a dead run.
|
|
3325
3382
|
*/
|
|
3326
3383
|
async serviceResume(runId, signal) {
|
|
3327
|
-
await this.automation.resume(runId, { ...signal, [RESUME_AUTHORITY_SERVICE]: true });
|
|
3384
|
+
const result = await this.automation.resume(runId, { ...signal, [RESUME_AUTHORITY_SERVICE]: true });
|
|
3385
|
+
const reported = result;
|
|
3386
|
+
if (reported && typeof reported === "object" && reported.success === false) {
|
|
3387
|
+
const err = new Error(
|
|
3388
|
+
`resume of run '${runId}' failed${reported.code ? ` [${reported.code}]` : ""}: ${reported.error ?? "unknown error"}`
|
|
3389
|
+
);
|
|
3390
|
+
err.resumeCode = reported.code;
|
|
3391
|
+
throw err;
|
|
3392
|
+
}
|
|
3393
|
+
}
|
|
3394
|
+
/** The engine failure code behind a {@link serviceResume} rejection, if any. */
|
|
3395
|
+
static resumeCodeOf(err) {
|
|
3396
|
+
return err?.resumeCode;
|
|
3397
|
+
}
|
|
3398
|
+
/**
|
|
3399
|
+
* Refuse an operation whose whole point is to advance a flow run when that
|
|
3400
|
+
* run no longer exists — BEFORE anything is written down (#4420).
|
|
3401
|
+
*
|
|
3402
|
+
* The half-state this prevents is the one the issue reported: a request
|
|
3403
|
+
* flipped to `approved`, a success toast, and a flow that never moves. Once
|
|
3404
|
+
* the decision row is written there is nothing left to fail cleanly.
|
|
3405
|
+
*
|
|
3406
|
+
* Deliberately permissive at the edges:
|
|
3407
|
+
* - no automation attached, or an engine without `hasSuspendedRun` → no
|
|
3408
|
+
* pre-flight at all (standalone approvals compositions are unaffected);
|
|
3409
|
+
* - the store cannot be READ → fail OPEN. A transient outage must not block
|
|
3410
|
+
* every decision in the tenant; the post-resume check still catches a real
|
|
3411
|
+
* failure and reports it loudly.
|
|
3412
|
+
*/
|
|
3413
|
+
async assertRunResumable(runId, requestId) {
|
|
3414
|
+
if (!runId) return;
|
|
3415
|
+
if (typeof this.automation?.resume !== "function") return;
|
|
3416
|
+
if (typeof this.automation?.hasSuspendedRun !== "function") return;
|
|
3417
|
+
let alive;
|
|
3418
|
+
try {
|
|
3419
|
+
alive = await this.automation.hasSuspendedRun(runId);
|
|
3420
|
+
} catch (err) {
|
|
3421
|
+
this.logger?.warn?.("[approvals] could not verify the flow run is resumable \u2014 proceeding", {
|
|
3422
|
+
request: requestId,
|
|
3423
|
+
run: runId,
|
|
3424
|
+
error: err?.message ?? String(err)
|
|
3425
|
+
});
|
|
3426
|
+
return;
|
|
3427
|
+
}
|
|
3428
|
+
if (!alive) {
|
|
3429
|
+
throw new Error(
|
|
3430
|
+
`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.`
|
|
3431
|
+
);
|
|
3432
|
+
}
|
|
3433
|
+
}
|
|
3434
|
+
/**
|
|
3435
|
+
* Resume the run behind an outcome that has ALREADY been written down, and
|
|
3436
|
+
* fail loudly when it cannot be (#4420).
|
|
3437
|
+
*
|
|
3438
|
+
* For the operations whose product is the resume — a finalised decision, a
|
|
3439
|
+
* send-back, a resubmit. Their rows are durable by the time this runs, so a
|
|
3440
|
+
* failure here cannot be undone; the one thing left worth doing is refusing
|
|
3441
|
+
* to call it success. {@link assertRunResumable} is what keeps this rare:
|
|
3442
|
+
* everything it catches never reaches a write.
|
|
3443
|
+
*
|
|
3444
|
+
* `RESUME_IN_PROGRESS` is the exception — a concurrent resume is already
|
|
3445
|
+
* advancing the run, so the outcome stands and only `resumed` is false.
|
|
3446
|
+
*
|
|
3447
|
+
* @param what - how the recorded outcome reads in the error, e.g.
|
|
3448
|
+
* `"the approve decision"`.
|
|
3449
|
+
*/
|
|
3450
|
+
async resumeRecordedOutcome(runId, requestId, what, signal) {
|
|
3451
|
+
try {
|
|
3452
|
+
await this.serviceResume(runId, signal);
|
|
3453
|
+
return { resumed: true };
|
|
3454
|
+
} catch (err) {
|
|
3455
|
+
const reason = err?.message ?? String(err);
|
|
3456
|
+
if (_ApprovalService.resumeCodeOf(err) === "RESUME_IN_PROGRESS") {
|
|
3457
|
+
this.logger?.warn?.("[approvals] resume skipped \u2014 already in progress", {
|
|
3458
|
+
request: requestId,
|
|
3459
|
+
run: runId,
|
|
3460
|
+
outcome: what
|
|
3461
|
+
});
|
|
3462
|
+
return { resumed: false, resumeError: reason };
|
|
3463
|
+
}
|
|
3464
|
+
this.logger?.error?.("[approvals] resume failed \u2014 the run is stranded", {
|
|
3465
|
+
request: requestId,
|
|
3466
|
+
run: runId,
|
|
3467
|
+
outcome: what,
|
|
3468
|
+
error: reason
|
|
3469
|
+
});
|
|
3470
|
+
throw new Error(
|
|
3471
|
+
`RESUME_FAILED: ${what} was recorded on request ${requestId}, but its flow run '${runId}' could not be resumed and is now stranded: ${reason}`
|
|
3472
|
+
);
|
|
3473
|
+
}
|
|
3328
3474
|
}
|
|
3329
3475
|
/**
|
|
3330
3476
|
* Public contract entrypoint (ADR-0019). Records a decision on a node-driven
|
|
3331
3477
|
* request via {@link ApprovalService.decideNode} and, when it finalizes,
|
|
3332
3478
|
* resumes the owning flow run down the matching `approve` / `reject` edge.
|
|
3479
|
+
*
|
|
3480
|
+
* A finalising decision whose run cannot be resumed FAILS (#4420). The
|
|
3481
|
+
* decision is already durable by then, so the failure cannot be rolled back
|
|
3482
|
+
* — but it must not be reported as success either: this used to answer HTTP
|
|
3483
|
+
* 200 with `resumed: true` while the flow stayed parked forever, which left
|
|
3484
|
+
* the approver with no signal and the record mirroring a stage it never
|
|
3485
|
+
* reached. `decideNode`'s pre-flight means the common case (the run died
|
|
3486
|
+
* before the decision) never gets this far; what survives here is a genuine
|
|
3487
|
+
* race, and it names the stranded run.
|
|
3333
3488
|
*/
|
|
3334
3489
|
async decide(requestId, input, context) {
|
|
3335
3490
|
const result = await this.decideNode(requestId, input, context);
|
|
3336
3491
|
let resumed = false;
|
|
3492
|
+
let resumeError;
|
|
3337
3493
|
if (result.finalized && result.runId && typeof this.automation?.resume === "function") {
|
|
3338
3494
|
const branchLabel = result.decision === "approve" ? APPROVAL_BRANCH_LABELS.approve : APPROVAL_BRANCH_LABELS.reject;
|
|
3339
|
-
|
|
3340
|
-
|
|
3495
|
+
const outcome = await this.resumeRecordedOutcome(
|
|
3496
|
+
result.runId,
|
|
3497
|
+
requestId,
|
|
3498
|
+
`the ${result.decision} decision`,
|
|
3499
|
+
{
|
|
3341
3500
|
branchLabel,
|
|
3342
3501
|
// #3447 P2: accepted decision outputs ride the resume envelope and
|
|
3343
3502
|
// land as `<nodeId>.<key>` flow variables — a later approval node's
|
|
@@ -3345,22 +3504,18 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3345
3504
|
// Reserved keys are spread LAST so no output can shadow them (the
|
|
3346
3505
|
// whitelist already rejects them; this is defense in depth).
|
|
3347
3506
|
output: { ...result.outputs ?? {}, decision: result.decision, requestId }
|
|
3348
|
-
}
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
request: requestId,
|
|
3353
|
-
run: result.runId,
|
|
3354
|
-
error: err?.message ?? String(err)
|
|
3355
|
-
});
|
|
3356
|
-
}
|
|
3507
|
+
}
|
|
3508
|
+
);
|
|
3509
|
+
resumed = outcome.resumed;
|
|
3510
|
+
resumeError = outcome.resumeError;
|
|
3357
3511
|
}
|
|
3358
3512
|
return {
|
|
3359
3513
|
request: result.request,
|
|
3360
3514
|
finalized: result.finalized,
|
|
3361
3515
|
decision: result.decision,
|
|
3362
3516
|
runId: result.runId,
|
|
3363
|
-
resumed
|
|
3517
|
+
resumed,
|
|
3518
|
+
...resumeError ? { resumeError } : {}
|
|
3364
3519
|
};
|
|
3365
3520
|
}
|
|
3366
3521
|
/**
|
|
@@ -3428,15 +3583,17 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3428
3583
|
);
|
|
3429
3584
|
}
|
|
3430
3585
|
let resumed = false;
|
|
3586
|
+
let resumeError;
|
|
3431
3587
|
if (inReviseWindow) {
|
|
3432
3588
|
if (runId && typeof this.automation?.cancelRun === "function") {
|
|
3433
3589
|
try {
|
|
3434
3590
|
await this.automation.cancelRun(runId, `approval request ${requestId} recalled during revision`);
|
|
3435
3591
|
} catch (err) {
|
|
3436
|
-
|
|
3592
|
+
resumeError = err?.message ?? String(err);
|
|
3593
|
+
this.logger?.error?.("[approvals] cancelRun after revise-window recall failed \u2014 the run may be stranded", {
|
|
3437
3594
|
request: requestId,
|
|
3438
3595
|
run: runId,
|
|
3439
|
-
error:
|
|
3596
|
+
error: resumeError
|
|
3440
3597
|
});
|
|
3441
3598
|
}
|
|
3442
3599
|
}
|
|
@@ -3448,15 +3605,16 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3448
3605
|
});
|
|
3449
3606
|
resumed = true;
|
|
3450
3607
|
} catch (err) {
|
|
3451
|
-
|
|
3608
|
+
resumeError = err?.message ?? String(err);
|
|
3609
|
+
this.logger?.error?.("[approvals] resume after recall failed \u2014 the run may be stranded", {
|
|
3452
3610
|
request: requestId,
|
|
3453
3611
|
run: runId,
|
|
3454
|
-
error:
|
|
3612
|
+
error: resumeError
|
|
3455
3613
|
});
|
|
3456
3614
|
}
|
|
3457
3615
|
}
|
|
3458
3616
|
const fresh = await this.readBackRequest(requestId, context);
|
|
3459
|
-
return { request: fresh, runId, resumed };
|
|
3617
|
+
return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
|
|
3460
3618
|
}
|
|
3461
3619
|
// ── Send back for revision / resubmit (ADR-0044) ─────────────
|
|
3462
3620
|
/**
|
|
@@ -3484,6 +3642,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3484
3642
|
const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
|
|
3485
3643
|
const runId = raw.flow_run_id ?? null;
|
|
3486
3644
|
await this.assertReviseEdge(raw, nodeId);
|
|
3645
|
+
await this.assertRunResumable(runId, requestId);
|
|
3487
3646
|
const now = this.clock.now().toISOString();
|
|
3488
3647
|
const maxRevisions = typeof config.maxRevisions === "number" ? config.maxRevisions : 3;
|
|
3489
3648
|
let priorSendBacks = 0;
|
|
@@ -3536,20 +3695,19 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3536
3695
|
);
|
|
3537
3696
|
}
|
|
3538
3697
|
let resumed2 = false;
|
|
3698
|
+
let resumeError2;
|
|
3539
3699
|
if (runId && typeof this.automation?.resume === "function") {
|
|
3540
|
-
|
|
3541
|
-
|
|
3700
|
+
const outcome = await this.resumeRecordedOutcome(
|
|
3701
|
+
runId,
|
|
3702
|
+
requestId,
|
|
3703
|
+
"the auto-rejection",
|
|
3704
|
+
{
|
|
3542
3705
|
branchLabel: APPROVAL_BRANCH_LABELS.reject,
|
|
3543
3706
|
output: { decision: "reject", autoRejected: true, requestId }
|
|
3544
|
-
}
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
request: requestId,
|
|
3549
|
-
run: runId,
|
|
3550
|
-
error: err?.message ?? String(err)
|
|
3551
|
-
});
|
|
3552
|
-
}
|
|
3707
|
+
}
|
|
3708
|
+
);
|
|
3709
|
+
resumed2 = outcome.resumed;
|
|
3710
|
+
resumeError2 = outcome.resumeError;
|
|
3553
3711
|
}
|
|
3554
3712
|
if (raw.submitter_id) {
|
|
3555
3713
|
await this.notify({
|
|
@@ -3565,7 +3723,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3565
3723
|
});
|
|
3566
3724
|
}
|
|
3567
3725
|
const fresh2 = await this.readBackRequest(requestId, context);
|
|
3568
|
-
return { request: fresh2, runId, resumed: resumed2, autoRejected: true };
|
|
3726
|
+
return { request: fresh2, runId, resumed: resumed2, autoRejected: true, ...resumeError2 ? { resumeError: resumeError2 } : {} };
|
|
3569
3727
|
}
|
|
3570
3728
|
await this.engine.update("sys_approval_request", {
|
|
3571
3729
|
id: requestId,
|
|
@@ -3585,20 +3743,19 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3585
3743
|
);
|
|
3586
3744
|
}
|
|
3587
3745
|
let resumed = false;
|
|
3746
|
+
let resumeError;
|
|
3588
3747
|
if (runId && typeof this.automation?.resume === "function") {
|
|
3589
|
-
|
|
3590
|
-
|
|
3748
|
+
const outcome = await this.resumeRecordedOutcome(
|
|
3749
|
+
runId,
|
|
3750
|
+
requestId,
|
|
3751
|
+
"the send-back",
|
|
3752
|
+
{
|
|
3591
3753
|
branchLabel: APPROVAL_BRANCH_LABELS.revise,
|
|
3592
3754
|
output: { decision: "revise", requestId }
|
|
3593
|
-
}
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
request: requestId,
|
|
3598
|
-
run: runId,
|
|
3599
|
-
error: err?.message ?? String(err)
|
|
3600
|
-
});
|
|
3601
|
-
}
|
|
3755
|
+
}
|
|
3756
|
+
);
|
|
3757
|
+
resumed = outcome.resumed;
|
|
3758
|
+
resumeError = outcome.resumeError;
|
|
3602
3759
|
}
|
|
3603
3760
|
if (raw.submitter_id) {
|
|
3604
3761
|
await this.notify({
|
|
@@ -3614,7 +3771,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3614
3771
|
});
|
|
3615
3772
|
}
|
|
3616
3773
|
const fresh = await this.readBackRequest(requestId, context);
|
|
3617
|
-
return { request: fresh, runId, resumed };
|
|
3774
|
+
return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
|
|
3618
3775
|
}
|
|
3619
3776
|
/**
|
|
3620
3777
|
* ADR-0044 resubmit after rework. Valid on the LATEST `returned` request of
|
|
@@ -3653,6 +3810,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3653
3810
|
const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
|
|
3654
3811
|
const runId = raw.flow_run_id ?? null;
|
|
3655
3812
|
const now = this.clock.now().toISOString();
|
|
3813
|
+
await this.assertRunResumable(runId, requestId);
|
|
3656
3814
|
await this.engine.insert("sys_approval_action", {
|
|
3657
3815
|
id: uid("aact"),
|
|
3658
3816
|
request_id: requestId,
|
|
@@ -3665,23 +3823,22 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3665
3823
|
created_at: now
|
|
3666
3824
|
}, { context: SYSTEM_CTX2 });
|
|
3667
3825
|
let resumed = false;
|
|
3826
|
+
let resumeError;
|
|
3668
3827
|
if (runId && typeof this.automation?.resume === "function") {
|
|
3669
|
-
|
|
3670
|
-
|
|
3828
|
+
const outcome = await this.resumeRecordedOutcome(
|
|
3829
|
+
runId,
|
|
3830
|
+
requestId,
|
|
3831
|
+
"the resubmit",
|
|
3832
|
+
{
|
|
3671
3833
|
branchLabel: APPROVAL_BRANCH_LABELS.resubmit,
|
|
3672
3834
|
output: { resubmitted: true, requestId }
|
|
3673
|
-
}
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
request: requestId,
|
|
3678
|
-
run: runId,
|
|
3679
|
-
error: err?.message ?? String(err)
|
|
3680
|
-
});
|
|
3681
|
-
}
|
|
3835
|
+
}
|
|
3836
|
+
);
|
|
3837
|
+
resumed = outcome.resumed;
|
|
3838
|
+
resumeError = outcome.resumeError;
|
|
3682
3839
|
}
|
|
3683
3840
|
const fresh = await this.readBackRequest(requestId, context);
|
|
3684
|
-
return { request: fresh, runId, resumed };
|
|
3841
|
+
return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
|
|
3685
3842
|
}
|
|
3686
3843
|
/**
|
|
3687
3844
|
* ADR-0044 guard: the flow's approval node must declare a `revise`
|
|
@@ -3742,6 +3899,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3742
3899
|
}
|
|
3743
3900
|
const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
|
|
3744
3901
|
const from = String(input.from ?? actorId).trim();
|
|
3902
|
+
const viaOverride = isOverride && !pending.includes(actorId);
|
|
3745
3903
|
let next;
|
|
3746
3904
|
if (pending.includes(from)) {
|
|
3747
3905
|
if (!context.isSystem && !isOverride && actorId !== from && !pending.includes(actorId)) {
|
|
@@ -3767,6 +3925,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3767
3925
|
actor_id: actorId,
|
|
3768
3926
|
reassign_from: from,
|
|
3769
3927
|
reassign_to: to,
|
|
3928
|
+
via_override: viaOverride,
|
|
3770
3929
|
comment: input.comment ?? null,
|
|
3771
3930
|
created_at: now
|
|
3772
3931
|
}, { context: SYSTEM_CTX2 });
|
|
@@ -4131,6 +4290,137 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4131
4290
|
* the real cause and {@link DEAD_RUN_ACTOR_ID} the real actor, so a dead-run
|
|
4132
4291
|
* release is never mistaken for a submitter's withdrawal.
|
|
4133
4292
|
*/
|
|
4293
|
+
/**
|
|
4294
|
+
* Read-only inspection for the OTHER dead-run shape: a request that is
|
|
4295
|
+
* already TERMINAL while its `flow_run_id` points at nothing (#4469).
|
|
4296
|
+
*
|
|
4297
|
+
* #4460 stopped new ones being produced; nothing found the ones already
|
|
4298
|
+
* stuck. The failure mode (#4420) is a request row flipped to `approved` /
|
|
4299
|
+
* `rejected` / `returned` whose owning run no longer exists — the decision
|
|
4300
|
+
* landed, the flow never moved. Any deployment on 17.0.0-rc.1 that hit the
|
|
4301
|
+
* wiring hole and crossed a restart mid-approval can be carrying these rows.
|
|
4302
|
+
*
|
|
4303
|
+
* {@link releaseDeadRunRequests} cannot see them, for a reason worth naming:
|
|
4304
|
+
* it scans `status: 'pending'`, and the very step that zombified the request
|
|
4305
|
+
* is the one that took it OUT of `pending`. The act of breaking it removed it
|
|
4306
|
+
* from the only sweeper's field of view — which is a large part of why this
|
|
4307
|
+
* class of failure stayed silent.
|
|
4308
|
+
*
|
|
4309
|
+
* It also could not have answered the question even if it looked: its
|
|
4310
|
+
* liveness oracle is `getRun`, which reads the execution LOG, and after a
|
|
4311
|
+
* restart that returns `null` for a perfectly ALIVE suspended run. It treats
|
|
4312
|
+
* `null` as alive (conservative, correct) — but that means it has no way to
|
|
4313
|
+
* say "this run is really gone".
|
|
4314
|
+
*
|
|
4315
|
+
* So this uses BOTH oracles, and a row must fail both to be reported:
|
|
4316
|
+
*
|
|
4317
|
+
* - `hasSuspendedRun(runId) === false` — the suspension store itself says no
|
|
4318
|
+
* live pause exists. It THROWS when the store cannot be read, and that
|
|
4319
|
+
* case is SKIPPED, never counted as dead: an unreadable store means
|
|
4320
|
+
* "unknown", and a storage outage must not be published as a lost run.
|
|
4321
|
+
* - `getRun(runId) == null` — no terminal history row either (the `run_`
|
|
4322
|
+
* prefixed rows in `sys_automation_run`). A run that merely finished is
|
|
4323
|
+
* not stranded; a request whose run neither waits nor ever completed is.
|
|
4324
|
+
*
|
|
4325
|
+
* **Reports; never rewrites.** No status is changed and no run is cancelled.
|
|
4326
|
+
* The decision genuinely happened — a human approved or rejected — and
|
|
4327
|
+
* silently rolling it back would make the audit trail disagree with the
|
|
4328
|
+
* facts. What an operator needs first is visibility: which requests are stuck
|
|
4329
|
+
* at which step, and what the mirrored status field on the business record
|
|
4330
|
+
* still says. Whether to re-run the downstream actions or re-open the
|
|
4331
|
+
* approval is a judgement call this cannot make.
|
|
4332
|
+
*/
|
|
4333
|
+
async inspectStrandedRequests(options) {
|
|
4334
|
+
const empty = { scanned: 0, stranded: [], undetermined: 0 };
|
|
4335
|
+
if (typeof this.automation?.hasSuspendedRun !== "function") return empty;
|
|
4336
|
+
if (typeof this.automation?.getRun !== "function") return empty;
|
|
4337
|
+
const limit = options?.limit ?? 500;
|
|
4338
|
+
let rows = [];
|
|
4339
|
+
try {
|
|
4340
|
+
rows = await this.engine.find("sys_approval_request", {
|
|
4341
|
+
where: { status: { $in: [...STRANDABLE_REQUEST_STATUSES] } },
|
|
4342
|
+
limit,
|
|
4343
|
+
context: SYSTEM_CTX2
|
|
4344
|
+
}) ?? [];
|
|
4345
|
+
} catch (err) {
|
|
4346
|
+
this.logger?.warn?.("[approvals] stranded-request scan failed to list requests", {
|
|
4347
|
+
error: err?.message ?? String(err)
|
|
4348
|
+
});
|
|
4349
|
+
return empty;
|
|
4350
|
+
}
|
|
4351
|
+
const stranded = [];
|
|
4352
|
+
let undetermined = 0;
|
|
4353
|
+
for (const raw of rows) {
|
|
4354
|
+
const runId = raw?.flow_run_id ? String(raw.flow_run_id) : "";
|
|
4355
|
+
if (!runId) continue;
|
|
4356
|
+
let suspended;
|
|
4357
|
+
try {
|
|
4358
|
+
suspended = await this.automation.hasSuspendedRun(runId);
|
|
4359
|
+
} catch (err) {
|
|
4360
|
+
undetermined++;
|
|
4361
|
+
this.logger?.warn?.("[approvals] stranded-request scan could not read the suspension store", {
|
|
4362
|
+
request: raw?.id,
|
|
4363
|
+
run: runId,
|
|
4364
|
+
error: err?.message ?? String(err)
|
|
4365
|
+
});
|
|
4366
|
+
continue;
|
|
4367
|
+
}
|
|
4368
|
+
if (suspended) continue;
|
|
4369
|
+
let terminal = null;
|
|
4370
|
+
try {
|
|
4371
|
+
terminal = await this.automation.getRun(runId);
|
|
4372
|
+
} catch (err) {
|
|
4373
|
+
undetermined++;
|
|
4374
|
+
this.logger?.warn?.("[approvals] stranded-request scan could not read the run history", {
|
|
4375
|
+
request: raw?.id,
|
|
4376
|
+
run: runId,
|
|
4377
|
+
error: err?.message ?? String(err)
|
|
4378
|
+
});
|
|
4379
|
+
continue;
|
|
4380
|
+
}
|
|
4381
|
+
if (terminal) continue;
|
|
4382
|
+
const config = parseJson(
|
|
4383
|
+
raw.node_config_json,
|
|
4384
|
+
{ approvers: [], behavior: "first_response" }
|
|
4385
|
+
);
|
|
4386
|
+
const mirrorField = config.approvalStatusField;
|
|
4387
|
+
let mirroredStatus;
|
|
4388
|
+
if (mirrorField) {
|
|
4389
|
+
try {
|
|
4390
|
+
const recs = await this.engine.find(raw.object_name, {
|
|
4391
|
+
where: { id: raw.record_id },
|
|
4392
|
+
limit: 1,
|
|
4393
|
+
context: SYSTEM_CTX2
|
|
4394
|
+
});
|
|
4395
|
+
const rec = Array.isArray(recs) ? recs[0] : null;
|
|
4396
|
+
if (rec) mirroredStatus = rec[mirrorField] ?? void 0;
|
|
4397
|
+
} catch {
|
|
4398
|
+
}
|
|
4399
|
+
}
|
|
4400
|
+
stranded.push({
|
|
4401
|
+
requestId: String(raw.id),
|
|
4402
|
+
status: raw.status,
|
|
4403
|
+
runId,
|
|
4404
|
+
flowName: typeof raw.process_name === "string" ? raw.process_name.replace(/^flow:/, "") : void 0,
|
|
4405
|
+
nodeId: raw.flow_node_id ?? raw.current_step ?? void 0,
|
|
4406
|
+
objectName: raw.object_name,
|
|
4407
|
+
recordId: raw.record_id,
|
|
4408
|
+
organizationId: raw.organization_id ?? null,
|
|
4409
|
+
completedAt: raw.completed_at ?? void 0,
|
|
4410
|
+
mirrorField,
|
|
4411
|
+
mirroredStatus
|
|
4412
|
+
});
|
|
4413
|
+
}
|
|
4414
|
+
if (stranded.length || undetermined) {
|
|
4415
|
+
this.logger?.warn?.("[approvals] stranded terminal requests (decision recorded, flow run gone)", {
|
|
4416
|
+
scanned: rows.length,
|
|
4417
|
+
stranded: stranded.length,
|
|
4418
|
+
undetermined,
|
|
4419
|
+
requests: stranded.map((s) => `${s.requestId}@${s.nodeId ?? "?"} \u2192 run ${s.runId}`)
|
|
4420
|
+
});
|
|
4421
|
+
}
|
|
4422
|
+
return { scanned: rows.length, stranded, undetermined };
|
|
4423
|
+
}
|
|
4134
4424
|
async releaseDeadRunRequests() {
|
|
4135
4425
|
if (typeof this.automation?.getRun !== "function") return { scanned: 0, released: 0 };
|
|
4136
4426
|
let rows = [];
|
|
@@ -5191,21 +5481,109 @@ function parseJson2(raw, fallback) {
|
|
|
5191
5481
|
}
|
|
5192
5482
|
return raw;
|
|
5193
5483
|
}
|
|
5484
|
+
var PENDING_LOCK_LIMIT = 1e3;
|
|
5485
|
+
var SYSTEM_CTX3 = { isSystem: true, positions: [], permissions: [] };
|
|
5486
|
+
function lockedError(message) {
|
|
5487
|
+
const err = new Error(`RECORD_LOCKED: ${message}`);
|
|
5488
|
+
err.code = "RECORD_LOCKED";
|
|
5489
|
+
err.statusCode = 409;
|
|
5490
|
+
throw err;
|
|
5491
|
+
}
|
|
5492
|
+
function asIdList(id) {
|
|
5493
|
+
if (typeof id === "number") return [id];
|
|
5494
|
+
if (typeof id === "string") return id === "" ? null : [id];
|
|
5495
|
+
if (id && typeof id === "object" && Array.isArray(id.$in)) {
|
|
5496
|
+
const raw = id.$in;
|
|
5497
|
+
const scalars = raw.filter((v) => typeof v === "string" || typeof v === "number");
|
|
5498
|
+
return scalars.length === raw.length ? scalars : null;
|
|
5499
|
+
}
|
|
5500
|
+
return null;
|
|
5501
|
+
}
|
|
5194
5502
|
async function pendingRequestFor(engine, objectName, recordId) {
|
|
5195
5503
|
try {
|
|
5196
5504
|
const rows = await engine.find("sys_approval_request", {
|
|
5197
5505
|
where: { object_name: objectName, record_id: String(recordId), status: "pending" },
|
|
5198
|
-
limit: 1
|
|
5506
|
+
limit: 1,
|
|
5507
|
+
context: { ...SYSTEM_CTX3 }
|
|
5199
5508
|
});
|
|
5200
5509
|
return Array.isArray(rows) && rows[0] ? rows[0] : null;
|
|
5201
5510
|
} catch {
|
|
5202
5511
|
return null;
|
|
5203
5512
|
}
|
|
5204
5513
|
}
|
|
5514
|
+
async function pendingRequestsForRecords(engine, objectName, recordIds) {
|
|
5515
|
+
if (recordIds.length === 0) return [];
|
|
5516
|
+
if (recordIds.length > PENDING_LOCK_LIMIT) {
|
|
5517
|
+
lockedError(
|
|
5518
|
+
`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`
|
|
5519
|
+
);
|
|
5520
|
+
}
|
|
5521
|
+
if (recordIds.length === 1) {
|
|
5522
|
+
const one = await pendingRequestFor(engine, objectName, String(recordIds[0]));
|
|
5523
|
+
return one ? [one] : [];
|
|
5524
|
+
}
|
|
5525
|
+
try {
|
|
5526
|
+
const rows = await engine.find("sys_approval_request", {
|
|
5527
|
+
where: { object_name: objectName, record_id: { $in: recordIds.map(String) }, status: "pending" },
|
|
5528
|
+
limit: PENDING_LOCK_LIMIT + 1,
|
|
5529
|
+
context: { ...SYSTEM_CTX3 }
|
|
5530
|
+
});
|
|
5531
|
+
return Array.isArray(rows) ? rows : [];
|
|
5532
|
+
} catch {
|
|
5533
|
+
return [];
|
|
5534
|
+
}
|
|
5535
|
+
}
|
|
5536
|
+
async function pendingRequestsForObject(engine, objectName) {
|
|
5537
|
+
try {
|
|
5538
|
+
const rows = await engine.find("sys_approval_request", {
|
|
5539
|
+
where: { object_name: objectName, status: "pending" },
|
|
5540
|
+
limit: PENDING_LOCK_LIMIT + 1,
|
|
5541
|
+
context: { ...SYSTEM_CTX3 }
|
|
5542
|
+
});
|
|
5543
|
+
return Array.isArray(rows) ? rows : [];
|
|
5544
|
+
} catch {
|
|
5545
|
+
return null;
|
|
5546
|
+
}
|
|
5547
|
+
}
|
|
5548
|
+
async function narrowToMatchedRecords(engine, objectName, where, candidates) {
|
|
5549
|
+
const lockedIds = candidates.map((c) => String(c?.record_id ?? ""));
|
|
5550
|
+
let rows;
|
|
5551
|
+
try {
|
|
5552
|
+
rows = await engine.find(objectName, {
|
|
5553
|
+
where: { $and: [where, { id: { $in: lockedIds } }] },
|
|
5554
|
+
fields: ["id"],
|
|
5555
|
+
limit: lockedIds.length,
|
|
5556
|
+
context: { ...SYSTEM_CTX3 }
|
|
5557
|
+
});
|
|
5558
|
+
} catch (err) {
|
|
5559
|
+
lockedError(
|
|
5560
|
+
`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`
|
|
5561
|
+
);
|
|
5562
|
+
}
|
|
5563
|
+
const matched = new Set((Array.isArray(rows) ? rows : []).map((r) => String(r?.id)));
|
|
5564
|
+
return candidates.filter((c) => matched.has(String(c?.record_id ?? "")));
|
|
5565
|
+
}
|
|
5566
|
+
async function gatingRequests(engine, ctx, objectName) {
|
|
5567
|
+
const byId = asIdList(ctx?.input?.id);
|
|
5568
|
+
if (byId) return pendingRequestsForRecords(engine, objectName, byId);
|
|
5569
|
+
const rawWhere = ctx?.input?.options?.where;
|
|
5570
|
+
const hasWhere = rawWhere !== void 0 && rawWhere !== null;
|
|
5571
|
+
const whereObj = hasWhere && typeof rawWhere === "object" && !Array.isArray(rawWhere) ? rawWhere : null;
|
|
5572
|
+
const namedIds = whereObj ? asIdList(whereObj.id) : null;
|
|
5573
|
+
const candidates = namedIds ? await pendingRequestsForRecords(engine, objectName, namedIds) : await pendingRequestsForObject(engine, objectName);
|
|
5574
|
+
if (candidates === null) return [];
|
|
5575
|
+
if (candidates.length === 0) return [];
|
|
5576
|
+
if (candidates.length > PENDING_LOCK_LIMIT) {
|
|
5577
|
+
lockedError(
|
|
5578
|
+
`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`
|
|
5579
|
+
);
|
|
5580
|
+
}
|
|
5581
|
+
if (!hasWhere) return candidates;
|
|
5582
|
+
if (namedIds && whereObj && Object.keys(whereObj).every((k) => k === "id")) return candidates;
|
|
5583
|
+
return narrowToMatchedRecords(engine, objectName, rawWhere, candidates);
|
|
5584
|
+
}
|
|
5205
5585
|
function bindApprovalLockHook(engine, logger) {
|
|
5206
5586
|
engine.registerHook("beforeUpdate", async (ctx) => {
|
|
5207
|
-
const id = String(ctx?.input?.id ?? "");
|
|
5208
|
-
if (!id) return;
|
|
5209
5587
|
const object = ctx?.object ?? ctx?.objectName;
|
|
5210
5588
|
if (!object || String(object).startsWith("sys_approval")) return;
|
|
5211
5589
|
const data = ctx?.input?.data ?? {};
|
|
@@ -5214,18 +5592,19 @@ function bindApprovalLockHook(engine, logger) {
|
|
|
5214
5592
|
if (ctx?.session?.isSystem) return;
|
|
5215
5593
|
const roles = ctx?.session?.roles ?? [];
|
|
5216
5594
|
if (Array.isArray(roles) && roles.includes("admin")) return;
|
|
5217
|
-
const
|
|
5218
|
-
if (
|
|
5595
|
+
const gating = await gatingRequests(engine, ctx, object);
|
|
5596
|
+
if (gating.length === 0) return;
|
|
5219
5597
|
const writerRun = ctx?.provenance?.flowRunId;
|
|
5220
|
-
|
|
5221
|
-
|
|
5222
|
-
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5598
|
+
for (const pending of gating) {
|
|
5599
|
+
if (writerRun && pending?.flow_run_id && String(writerRun) === String(pending.flow_run_id)) continue;
|
|
5600
|
+
const config = parseJson2(pending?.node_config_json, {});
|
|
5601
|
+
if (config?.lockRecord === false) continue;
|
|
5602
|
+
const mirror = config?.approvalStatusField;
|
|
5603
|
+
if (typeof mirror === "string" && mirror && changedFields.every((f) => f === mirror)) continue;
|
|
5604
|
+
lockedError(
|
|
5605
|
+
`record '${String(pending?.record_id ?? "")}' of '${object}' is locked while an approval is in progress`
|
|
5606
|
+
);
|
|
5607
|
+
}
|
|
5229
5608
|
}, { packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
|
|
5230
5609
|
logger?.info?.("[approvals] record-lock hook bound");
|
|
5231
5610
|
}
|
|
@@ -5274,7 +5653,7 @@ import {
|
|
|
5274
5653
|
getApprovalNodeConfigJsonSchema,
|
|
5275
5654
|
APPROVAL_NODE_TYPE
|
|
5276
5655
|
} from "@objectstack/spec/automation";
|
|
5277
|
-
var
|
|
5656
|
+
var SYSTEM_CTX4 = { isSystem: true, positions: [], permissions: [] };
|
|
5278
5657
|
function nestVariables(variables) {
|
|
5279
5658
|
const vars = {};
|
|
5280
5659
|
for (const [key, value] of variables) {
|
|
@@ -5353,7 +5732,7 @@ function registerApprovalNode(automation, service, logger) {
|
|
|
5353
5732
|
// their `trigger.*` snapshot root.
|
|
5354
5733
|
variables: nestVariables(variables)
|
|
5355
5734
|
}, {
|
|
5356
|
-
...
|
|
5735
|
+
...SYSTEM_CTX4,
|
|
5357
5736
|
userId: context?.userId,
|
|
5358
5737
|
organizationId: context?.organizationId,
|
|
5359
5738
|
tenantId: context?.tenantId
|
|
@@ -5497,7 +5876,13 @@ var ApprovalsServicePlugin = class {
|
|
|
5497
5876
|
const sweep = async () => {
|
|
5498
5877
|
const results = await Promise.allSettled([
|
|
5499
5878
|
svc.runEscalations(),
|
|
5500
|
-
svc.releaseDeadRunRequests()
|
|
5879
|
+
svc.releaseDeadRunRequests(),
|
|
5880
|
+
// #4469 — the other half of the dead-run picture, and the one no
|
|
5881
|
+
// sweeper could see: a request already TERMINAL whose run is gone.
|
|
5882
|
+
// Read-only by design (it reports; it never rewrites a decision
|
|
5883
|
+
// that really happened), so it rides the same clock purely to make
|
|
5884
|
+
// the finding surface without an operator knowing to go looking.
|
|
5885
|
+
svc.inspectStrandedRequests()
|
|
5501
5886
|
]);
|
|
5502
5887
|
for (const r of results) {
|
|
5503
5888
|
if (r.status === "rejected") {
|
|
@@ -5572,14 +5957,19 @@ var ApprovalsServicePlugin = class {
|
|
|
5572
5957
|
await mountActionPages();
|
|
5573
5958
|
await backfillApproverIndex();
|
|
5574
5959
|
}
|
|
5960
|
+
let automation;
|
|
5575
5961
|
try {
|
|
5576
|
-
|
|
5577
|
-
if (automation && typeof automation.registerNodeExecutor === "function") {
|
|
5578
|
-
this.service.attachAutomation(automation);
|
|
5579
|
-
registerApprovalNode(automation, this.service, ctx.logger);
|
|
5580
|
-
}
|
|
5962
|
+
automation = ctx.getService("automation");
|
|
5581
5963
|
} catch {
|
|
5582
|
-
|
|
5964
|
+
automation = void 0;
|
|
5965
|
+
}
|
|
5966
|
+
if (automation && typeof automation.registerNodeExecutor === "function") {
|
|
5967
|
+
this.service.attachAutomation(automation);
|
|
5968
|
+
registerApprovalNode(automation, this.service, ctx.logger);
|
|
5969
|
+
} else {
|
|
5970
|
+
ctx.logger.warn(
|
|
5971
|
+
"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."
|
|
5972
|
+
);
|
|
5583
5973
|
}
|
|
5584
5974
|
}
|
|
5585
5975
|
async stop(ctx) {
|