@objectstack/plugin-approvals 17.0.0-rc.0 → 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 +863 -0
- package/dist/index.d.mts +2236 -2688
- package/dist/index.d.ts +2236 -2688
- package/dist/index.js +591 -128
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +590 -127
- package/dist/index.mjs.map +1 -1
- package/package.json +17 -10
- package/.turbo/turbo-build.log +0 -22
- package/scripts/i18n-extract.config.ts +0 -38
- package/src/action-link-pages.ts +0 -102
- package/src/approval-actor-impersonation.test.ts +0 -330
- package/src/approval-node.test.ts +0 -356
- package/src/approval-node.ts +0 -196
- package/src/approval-revise.test.ts +0 -418
- package/src/approval-service.test.ts +0 -2858
- package/src/approval-service.ts +0 -3617
- package/src/approvals-plugin.ts +0 -294
- package/src/approver-cross-org.integration.test.ts +0 -206
- package/src/approver-org-scope.test.ts +0 -201
- package/src/approver-org-scope.ts +0 -261
- package/src/index.ts +0 -42
- package/src/lifecycle-hooks.ts +0 -201
- package/src/nav-contribution.test.ts +0 -50
- package/src/record-lock-schedule-run.integration.test.ts +0 -206
- package/src/status-mirror-cascade.integration.test.ts +0 -224
- package/src/sys-approval-action.object.ts +0 -149
- package/src/sys-approval-approver.object.ts +0 -85
- package/src/sys-approval-delegation.object.test.ts +0 -42
- package/src/sys-approval-delegation.object.ts +0 -142
- package/src/sys-approval-request.object.test.ts +0 -116
- package/src/sys-approval-request.object.ts +0 -413
- package/src/sys-approval-token.object.ts +0 -101
- package/src/translations/bundle-ownership.test.ts +0 -48
- package/src/translations/en.objects.generated.ts +0 -311
- package/src/translations/es-ES.objects.generated.ts +0 -311
- package/src/translations/index.ts +0 -23
- package/src/translations/ja-JP.objects.generated.ts +0 -311
- package/src/translations/zh-CN.objects.generated.ts +0 -311
- package/tsconfig.json +0 -10
package/dist/index.mjs
CHANGED
|
@@ -245,6 +245,18 @@ 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
|
+
},
|
|
252
|
+
reassign_from: {
|
|
253
|
+
label: "Reassigned From",
|
|
254
|
+
help: "User whose pending-approver slot was handed over (reassign actions only)"
|
|
255
|
+
},
|
|
256
|
+
reassign_to: {
|
|
257
|
+
label: "Reassigned To",
|
|
258
|
+
help: "User who received the pending-approver slot (reassign actions only)"
|
|
259
|
+
},
|
|
248
260
|
attachments: {
|
|
249
261
|
label: "Attachments",
|
|
250
262
|
help: "Files supporting this action \u2014 e.g. a signed contract or evidence (#3266)."
|
|
@@ -554,6 +566,18 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
554
566
|
comment: {
|
|
555
567
|
label: "\u8BC4\u8BBA"
|
|
556
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
|
+
},
|
|
573
|
+
reassign_from: {
|
|
574
|
+
label: "\u8F6C\u51FA\u4EBA",
|
|
575
|
+
help: "\u88AB\u79FB\u4EA4\u5F85\u5BA1\u6279\u69FD\u4F4D\u7684\u7528\u6237\uFF08\u4EC5\u8F6C\u7B7E\u64CD\u4F5C\uFF09"
|
|
576
|
+
},
|
|
577
|
+
reassign_to: {
|
|
578
|
+
label: "\u8F6C\u5165\u4EBA",
|
|
579
|
+
help: "\u63A5\u6536\u5F85\u5BA1\u6279\u69FD\u4F4D\u7684\u7528\u6237\uFF08\u4EC5\u8F6C\u7B7E\u64CD\u4F5C\uFF09"
|
|
580
|
+
},
|
|
557
581
|
attachments: {
|
|
558
582
|
label: "\u9644\u4EF6",
|
|
559
583
|
help: "\u652F\u6301\u8BE5\u64CD\u4F5C\u7684\u6587\u4EF6\u2014\u2014\u4F8B\u5982\u5DF2\u7B7E\u7F72\u7684\u5408\u540C\u6216\u8BC1\u660E\u6750\u6599\uFF08#3266\uFF09\u3002"
|
|
@@ -863,6 +887,18 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
863
887
|
comment: {
|
|
864
888
|
label: "\u30B3\u30E1\u30F3\u30C8"
|
|
865
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
|
+
},
|
|
894
|
+
reassign_from: {
|
|
895
|
+
label: "\u5F15\u304D\u7D99\u304E\u5143",
|
|
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"
|
|
897
|
+
},
|
|
898
|
+
reassign_to: {
|
|
899
|
+
label: "\u5F15\u304D\u7D99\u304E\u5148",
|
|
900
|
+
help: "\u627F\u8A8D\u5F85\u3061\u30B9\u30ED\u30C3\u30C8\u3092\u53D7\u3051\u53D6\u3063\u305F\u30E6\u30FC\u30B6\u30FC\uFF08\u5F15\u304D\u7D99\u304E\u64CD\u4F5C\u306E\u307F\uFF09"
|
|
901
|
+
},
|
|
866
902
|
attachments: {
|
|
867
903
|
label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB",
|
|
868
904
|
help: "\u3053\u306E\u64CD\u4F5C\u3092\u88CF\u4ED8\u3051\u308B\u30D5\u30A1\u30A4\u30EB\u2014\u2014\u7F72\u540D\u6E08\u307F\u5951\u7D04\u66F8\u3084\u8A3C\u6191\u306A\u3069\uFF08#3266\uFF09\u3002"
|
|
@@ -1172,6 +1208,18 @@ var init_es_ES_objects_generated = __esm({
|
|
|
1172
1208
|
comment: {
|
|
1173
1209
|
label: "Comentario"
|
|
1174
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
|
+
},
|
|
1215
|
+
reassign_from: {
|
|
1216
|
+
label: "Reasignado de",
|
|
1217
|
+
help: "Usuario cuyo turno de aprobaci\xF3n pendiente fue traspasado (solo acciones de reasignaci\xF3n)"
|
|
1218
|
+
},
|
|
1219
|
+
reassign_to: {
|
|
1220
|
+
label: "Reasignado a",
|
|
1221
|
+
help: "Usuario que recibi\xF3 el turno de aprobaci\xF3n pendiente (solo acciones de reasignaci\xF3n)"
|
|
1222
|
+
},
|
|
1175
1223
|
attachments: {
|
|
1176
1224
|
label: "Adjuntos",
|
|
1177
1225
|
help: "Archivos que respaldan esta acci\xF3n, p. ej. un contrato firmado o pruebas (#3266)."
|
|
@@ -1273,6 +1321,7 @@ var init_translations = __esm({
|
|
|
1273
1321
|
|
|
1274
1322
|
// src/sys-approval-request.object.ts
|
|
1275
1323
|
import { ObjectSchema, Field } from "@objectstack/spec/data";
|
|
1324
|
+
import { APPROVAL_STATUSES } from "@objectstack/spec/contracts";
|
|
1276
1325
|
var SysApprovalRequest = ObjectSchema.create({
|
|
1277
1326
|
name: "sys_approval_request",
|
|
1278
1327
|
label: "Approval Request",
|
|
@@ -1371,9 +1420,10 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1371
1420
|
group: "Target"
|
|
1372
1421
|
}),
|
|
1373
1422
|
status: Field.select(
|
|
1374
|
-
//
|
|
1375
|
-
//
|
|
1376
|
-
|
|
1423
|
+
// Spread from the contract, not re-typed (#3786). `APPROVAL_STATUSES` is
|
|
1424
|
+
// where the list and the reason for each entry live; `ApprovalStatus` is
|
|
1425
|
+
// derived from it, so this column and the contract cannot disagree.
|
|
1426
|
+
[...APPROVAL_STATUSES],
|
|
1377
1427
|
{
|
|
1378
1428
|
label: "Status",
|
|
1379
1429
|
required: true,
|
|
@@ -1642,6 +1692,7 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1642
1692
|
|
|
1643
1693
|
// src/sys-approval-action.object.ts
|
|
1644
1694
|
import { ObjectSchema as ObjectSchema2, Field as Field2 } from "@objectstack/spec/data";
|
|
1695
|
+
import { APPROVAL_ACTION_KINDS } from "@objectstack/spec/contracts";
|
|
1645
1696
|
var SysApprovalAction = ObjectSchema2.create({
|
|
1646
1697
|
name: "sys_approval_action",
|
|
1647
1698
|
label: "Approval Action",
|
|
@@ -1654,7 +1705,7 @@ var SysApprovalAction = ObjectSchema2.create({
|
|
|
1654
1705
|
nameField: "id",
|
|
1655
1706
|
// [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
|
|
1656
1707
|
titleFormat: "{action} \xB7 {step_name}",
|
|
1657
|
-
highlightFields: ["request_id", "step_name", "action", "actor_id", "created_at"],
|
|
1708
|
+
highlightFields: ["request_id", "step_name", "action", "actor_id", "via_override", "created_at"],
|
|
1658
1709
|
// ADR-0104 D3 wave 2. `attachments` is a media field, so the files it holds
|
|
1659
1710
|
// are OWNED by this row — and the storage service would otherwise authorize
|
|
1660
1711
|
// their download by testing whether the caller can READ this row. It cannot:
|
|
@@ -1669,7 +1720,7 @@ var SysApprovalAction = ObjectSchema2.create({
|
|
|
1669
1720
|
name: "recent",
|
|
1670
1721
|
label: "Recent",
|
|
1671
1722
|
data: { provider: "object", object: "sys_approval_action" },
|
|
1672
|
-
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"],
|
|
1673
1724
|
sort: [{ field: "created_at", order: "desc" }],
|
|
1674
1725
|
pagination: { pageSize: 50 },
|
|
1675
1726
|
emptyState: { title: "No approval actions yet", message: "Actions are logged automatically when approvals progress." }
|
|
@@ -1689,7 +1740,7 @@ var SysApprovalAction = ObjectSchema2.create({
|
|
|
1689
1740
|
name: "all_actions",
|
|
1690
1741
|
label: "All",
|
|
1691
1742
|
data: { provider: "object", object: "sys_approval_action" },
|
|
1692
|
-
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"],
|
|
1693
1744
|
sort: [{ field: "created_at", order: "desc" }],
|
|
1694
1745
|
pagination: { pageSize: 100 }
|
|
1695
1746
|
}
|
|
@@ -1720,12 +1771,11 @@ var SysApprovalAction = ObjectSchema2.create({
|
|
|
1720
1771
|
group: "Target"
|
|
1721
1772
|
}),
|
|
1722
1773
|
action: Field2.select(
|
|
1723
|
-
//
|
|
1724
|
-
//
|
|
1725
|
-
//
|
|
1726
|
-
//
|
|
1727
|
-
|
|
1728
|
-
["submit", "approve", "reject", "recall", "escalate", "reassign", "remind", "request_info", "comment", "revise", "resubmit", "ooo_substitute"],
|
|
1774
|
+
// Spread from the contract, not re-typed (#3786). `APPROVAL_ACTION_KINDS`
|
|
1775
|
+
// is where the list and the per-kind notes live (which kinds move the flow
|
|
1776
|
+
// and which are thread-only); `ApprovalActionKind` is derived from it, so
|
|
1777
|
+
// this column and the contract cannot disagree.
|
|
1778
|
+
[...APPROVAL_ACTION_KINDS],
|
|
1729
1779
|
{
|
|
1730
1780
|
label: "Action",
|
|
1731
1781
|
required: true,
|
|
@@ -1738,6 +1788,46 @@ var SysApprovalAction = ObjectSchema2.create({
|
|
|
1738
1788
|
group: "Action"
|
|
1739
1789
|
}),
|
|
1740
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
|
+
}),
|
|
1814
|
+
// Structured hand-off parties for `action: 'reassign'` (#4365). Before
|
|
1815
|
+
// these existed the pair lived only inside a default free-text comment
|
|
1816
|
+
// ("<from_id> → <to_id>"), which no client could parse or render readably.
|
|
1817
|
+
// `comment` is pure user input again; timelines render "from A to B" from
|
|
1818
|
+
// these fields.
|
|
1819
|
+
reassign_from: Field2.lookup("sys_user", {
|
|
1820
|
+
label: "Reassigned From",
|
|
1821
|
+
required: false,
|
|
1822
|
+
group: "Action",
|
|
1823
|
+
description: "User whose pending-approver slot was handed over (reassign actions only)"
|
|
1824
|
+
}),
|
|
1825
|
+
reassign_to: Field2.lookup("sys_user", {
|
|
1826
|
+
label: "Reassigned To",
|
|
1827
|
+
required: false,
|
|
1828
|
+
group: "Action",
|
|
1829
|
+
description: "User who received the pending-approver slot (reassign actions only)"
|
|
1830
|
+
}),
|
|
1741
1831
|
attachments: Field2.file({
|
|
1742
1832
|
label: "Attachments",
|
|
1743
1833
|
required: false,
|
|
@@ -1828,12 +1918,11 @@ var SysApprovalDelegation = ObjectSchema4.create({
|
|
|
1828
1918
|
pluralLabel: "Approval Delegations",
|
|
1829
1919
|
icon: "user-clock",
|
|
1830
1920
|
isSystem: true,
|
|
1831
|
-
|
|
1832
|
-
//
|
|
1833
|
-
//
|
|
1834
|
-
//
|
|
1835
|
-
|
|
1836
|
-
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",
|
|
1837
1926
|
description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
|
|
1838
1927
|
titleFormat: "{delegator_id} \u2192 {delegate_id}",
|
|
1839
1928
|
highlightFields: ["delegator_id", "delegate_id", "valid_from", "valid_until"],
|
|
@@ -1929,6 +2018,7 @@ import {
|
|
|
1929
2018
|
normalizeDecisionOutputs
|
|
1930
2019
|
} from "@objectstack/spec/automation";
|
|
1931
2020
|
import { ExpressionEngine, collectCelRootIdentifiers } from "@objectstack/formula";
|
|
2021
|
+
import { keysetWalk } from "@objectstack/types";
|
|
1932
2022
|
import {
|
|
1933
2023
|
ADMIN_FULL_ACCESS,
|
|
1934
2024
|
ORGANIZATION_ADMIN_GRANTS,
|
|
@@ -1949,7 +2039,7 @@ function fail(message) {
|
|
|
1949
2039
|
async function findOrg(engine, where) {
|
|
1950
2040
|
try {
|
|
1951
2041
|
const rows = await engine.find("sys_organization", {
|
|
1952
|
-
|
|
2042
|
+
where,
|
|
1953
2043
|
fields: ["id", "slug", "parent_organization_id"],
|
|
1954
2044
|
limit: 1,
|
|
1955
2045
|
context: SYSTEM_CTX
|
|
@@ -2036,7 +2126,7 @@ async function filterApproversWhoCanRead(deps, userIds, requestOrgId, context) {
|
|
|
2036
2126
|
let members = [];
|
|
2037
2127
|
try {
|
|
2038
2128
|
members = await deps.engine.find("sys_member", {
|
|
2039
|
-
|
|
2129
|
+
where: { organization_id: requestOrg, user_id: { $in: userIds } },
|
|
2040
2130
|
fields: ["user_id"],
|
|
2041
2131
|
limit: 1e4,
|
|
2042
2132
|
context: SYSTEM_CTX
|
|
@@ -2068,6 +2158,7 @@ var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
|
|
|
2068
2158
|
"cancelled",
|
|
2069
2159
|
"timed_out"
|
|
2070
2160
|
]);
|
|
2161
|
+
var STRANDABLE_REQUEST_STATUSES = ["approved", "rejected", "returned"];
|
|
2071
2162
|
var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
|
|
2072
2163
|
var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
|
|
2073
2164
|
function actingUserId(context) {
|
|
@@ -2106,6 +2197,12 @@ function csvSplit(raw) {
|
|
|
2106
2197
|
if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
|
|
2107
2198
|
return String(raw).split(",").map((s) => s.trim()).filter(Boolean);
|
|
2108
2199
|
}
|
|
2200
|
+
function isBlankDecisionOutput(value) {
|
|
2201
|
+
if (value === void 0 || value === null) return true;
|
|
2202
|
+
if (typeof value === "string") return value.trim() === "";
|
|
2203
|
+
if (Array.isArray(value)) return value.filter((v) => v !== null && v !== void 0 && String(v).trim() !== "").length === 0;
|
|
2204
|
+
return false;
|
|
2205
|
+
}
|
|
2109
2206
|
function prettifyMachineName(raw) {
|
|
2110
2207
|
if (!raw) return void 0;
|
|
2111
2208
|
const base = String(raw).replace(/^flow:/, "").trim();
|
|
@@ -2198,6 +2295,14 @@ function rowFromAction(row) {
|
|
|
2198
2295
|
action: row.action,
|
|
2199
2296
|
actor_id: row.actor_id ?? void 0,
|
|
2200
2297
|
comment: row.comment ?? void 0,
|
|
2298
|
+
// Structured reassign hand-off parties (#4365).
|
|
2299
|
+
reassign_from: row.reassign_from ?? void 0,
|
|
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,
|
|
2201
2306
|
// Decision attachments (#3266): rich descriptors carrying the display name +
|
|
2202
2307
|
// download URL, so consumers label/open them without reading `sys_file`.
|
|
2203
2308
|
attachments: attachments.length ? attachments : void 0,
|
|
@@ -2337,9 +2442,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2337
2442
|
* (having also put them on the context). Those are the only two callers that
|
|
2338
2443
|
* hold a trustworthy actor with no session behind them.
|
|
2339
2444
|
*
|
|
2340
|
-
* A caller with NO identity at all cannot act.
|
|
2341
|
-
*
|
|
2342
|
-
*
|
|
2445
|
+
* A caller with NO identity at all cannot act. Belt-and-suspenders: the REST
|
|
2446
|
+
* anonymous-deny now denies every anonymous request (#3963), but this service
|
|
2447
|
+
* must not rely on a caller upstream — an anonymous actor could otherwise
|
|
2448
|
+
* decide approvals outright by naming one.
|
|
2343
2449
|
*/
|
|
2344
2450
|
async resolveActor(actorId, context) {
|
|
2345
2451
|
if (context?.isSystem) {
|
|
@@ -2625,7 +2731,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2625
2731
|
let rows = [];
|
|
2626
2732
|
try {
|
|
2627
2733
|
rows = await this.engine.find("sys_team_member", {
|
|
2628
|
-
|
|
2734
|
+
where: { team_id: teamId },
|
|
2629
2735
|
fields: ["user_id"],
|
|
2630
2736
|
limit: 1e4,
|
|
2631
2737
|
context: SYSTEM_CTX2
|
|
@@ -2664,7 +2770,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2664
2770
|
if (!businessUnitId) return [];
|
|
2665
2771
|
try {
|
|
2666
2772
|
const seed = await this.engine.find("sys_business_unit", {
|
|
2667
|
-
|
|
2773
|
+
where: this.businessUnitOrgScope({ id: businessUnitId }, organizationId),
|
|
2668
2774
|
fields: ["id", "active"],
|
|
2669
2775
|
limit: 1,
|
|
2670
2776
|
context: SYSTEM_CTX2
|
|
@@ -2699,7 +2805,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2699
2805
|
let rows = [];
|
|
2700
2806
|
try {
|
|
2701
2807
|
rows = await this.engine.find("sys_business_unit_member", {
|
|
2702
|
-
|
|
2808
|
+
where: { business_unit_id: { $in: Array.from(seen) } },
|
|
2703
2809
|
fields: ["user_id"],
|
|
2704
2810
|
limit: 1e4,
|
|
2705
2811
|
context: SYSTEM_CTX2
|
|
@@ -2759,7 +2865,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2759
2865
|
async lookupManager(userId) {
|
|
2760
2866
|
try {
|
|
2761
2867
|
const rows = await this.engine.find("sys_user", {
|
|
2762
|
-
|
|
2868
|
+
where: { id: userId },
|
|
2763
2869
|
fields: ["id", "manager_id"],
|
|
2764
2870
|
limit: 1,
|
|
2765
2871
|
context: SYSTEM_CTX2
|
|
@@ -2809,7 +2915,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2809
2915
|
let rows = [];
|
|
2810
2916
|
try {
|
|
2811
2917
|
rows = await this.engine.find("sys_approval_delegation", {
|
|
2812
|
-
|
|
2918
|
+
where: { delegator_id: delegatorId },
|
|
2813
2919
|
fields: ["id", "delegator_id", "delegate_id", "valid_from", "valid_until", "reason", "organization_id"],
|
|
2814
2920
|
limit: 50,
|
|
2815
2921
|
context: SYSTEM_CTX2
|
|
@@ -3128,6 +3234,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3128
3234
|
if (!isSlotHolder && !isOverride) {
|
|
3129
3235
|
throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
|
|
3130
3236
|
}
|
|
3237
|
+
const viaOverride = isOverride && !isSlotHolder;
|
|
3131
3238
|
const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
|
|
3132
3239
|
const org = raw.organization_id ?? null;
|
|
3133
3240
|
const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
|
|
@@ -3135,8 +3242,9 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3135
3242
|
const now = this.clock.now().toISOString();
|
|
3136
3243
|
const outputKeys = input.outputs ? Object.keys(input.outputs) : [];
|
|
3137
3244
|
let acceptedOutputs;
|
|
3245
|
+
const declaredDefs = normalizeDecisionOutputs(config.decisionOutputs);
|
|
3138
3246
|
if (outputKeys.length) {
|
|
3139
|
-
const declared =
|
|
3247
|
+
const declared = declaredDefs.map((d) => d.key);
|
|
3140
3248
|
if (!declared.length) {
|
|
3141
3249
|
throw new Error(
|
|
3142
3250
|
`VALIDATION_FAILED: this approval node declares no decisionOutputs \u2014 outputs are not accepted. Declare the keys on the node config (decisionOutputs: [${outputKeys.map((k) => `'${k}'`).join(", ")}]) to let approvers hand them to the flow.`
|
|
@@ -3156,6 +3264,15 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3156
3264
|
}
|
|
3157
3265
|
acceptedOutputs = { ...input.outputs };
|
|
3158
3266
|
}
|
|
3267
|
+
if (input.decision === "approve") {
|
|
3268
|
+
const missing = declaredDefs.filter((d) => d.required === true && isBlankDecisionOutput(input.outputs?.[d.key])).map((d) => d.key);
|
|
3269
|
+
if (missing.length) {
|
|
3270
|
+
throw new Error(
|
|
3271
|
+
`VALIDATION_FAILED: decision output(s) \`${missing.join("`, `")}\` are required to approve this request \u2014 open the approval and fill them in before approving.`
|
|
3272
|
+
);
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
await this.assertRunResumable(runId, requestId);
|
|
3159
3276
|
await this.engine.insert("sys_approval_action", {
|
|
3160
3277
|
id: uid("aact"),
|
|
3161
3278
|
request_id: requestId,
|
|
@@ -3165,6 +3282,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3165
3282
|
action: input.decision,
|
|
3166
3283
|
actor_id: actorId,
|
|
3167
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,
|
|
3168
3289
|
attachments: input.attachments?.length ? input.attachments : null,
|
|
3169
3290
|
created_at: now
|
|
3170
3291
|
}, { context: SYSTEM_CTX2 });
|
|
@@ -3251,22 +3372,131 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3251
3372
|
* Callers still guard on `typeof this.automation?.resume === 'function'`
|
|
3252
3373
|
* (approvals runs fine with no automation attached) and keep their own
|
|
3253
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.
|
|
3254
3382
|
*/
|
|
3255
3383
|
async serviceResume(runId, signal) {
|
|
3256
|
-
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
|
+
}
|
|
3257
3474
|
}
|
|
3258
3475
|
/**
|
|
3259
3476
|
* Public contract entrypoint (ADR-0019). Records a decision on a node-driven
|
|
3260
3477
|
* request via {@link ApprovalService.decideNode} and, when it finalizes,
|
|
3261
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.
|
|
3262
3488
|
*/
|
|
3263
3489
|
async decide(requestId, input, context) {
|
|
3264
3490
|
const result = await this.decideNode(requestId, input, context);
|
|
3265
3491
|
let resumed = false;
|
|
3492
|
+
let resumeError;
|
|
3266
3493
|
if (result.finalized && result.runId && typeof this.automation?.resume === "function") {
|
|
3267
3494
|
const branchLabel = result.decision === "approve" ? APPROVAL_BRANCH_LABELS.approve : APPROVAL_BRANCH_LABELS.reject;
|
|
3268
|
-
|
|
3269
|
-
|
|
3495
|
+
const outcome = await this.resumeRecordedOutcome(
|
|
3496
|
+
result.runId,
|
|
3497
|
+
requestId,
|
|
3498
|
+
`the ${result.decision} decision`,
|
|
3499
|
+
{
|
|
3270
3500
|
branchLabel,
|
|
3271
3501
|
// #3447 P2: accepted decision outputs ride the resume envelope and
|
|
3272
3502
|
// land as `<nodeId>.<key>` flow variables — a later approval node's
|
|
@@ -3274,22 +3504,18 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3274
3504
|
// Reserved keys are spread LAST so no output can shadow them (the
|
|
3275
3505
|
// whitelist already rejects them; this is defense in depth).
|
|
3276
3506
|
output: { ...result.outputs ?? {}, decision: result.decision, requestId }
|
|
3277
|
-
}
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
request: requestId,
|
|
3282
|
-
run: result.runId,
|
|
3283
|
-
error: err?.message ?? String(err)
|
|
3284
|
-
});
|
|
3285
|
-
}
|
|
3507
|
+
}
|
|
3508
|
+
);
|
|
3509
|
+
resumed = outcome.resumed;
|
|
3510
|
+
resumeError = outcome.resumeError;
|
|
3286
3511
|
}
|
|
3287
3512
|
return {
|
|
3288
3513
|
request: result.request,
|
|
3289
3514
|
finalized: result.finalized,
|
|
3290
3515
|
decision: result.decision,
|
|
3291
3516
|
runId: result.runId,
|
|
3292
|
-
resumed
|
|
3517
|
+
resumed,
|
|
3518
|
+
...resumeError ? { resumeError } : {}
|
|
3293
3519
|
};
|
|
3294
3520
|
}
|
|
3295
3521
|
/**
|
|
@@ -3357,15 +3583,17 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3357
3583
|
);
|
|
3358
3584
|
}
|
|
3359
3585
|
let resumed = false;
|
|
3586
|
+
let resumeError;
|
|
3360
3587
|
if (inReviseWindow) {
|
|
3361
3588
|
if (runId && typeof this.automation?.cancelRun === "function") {
|
|
3362
3589
|
try {
|
|
3363
3590
|
await this.automation.cancelRun(runId, `approval request ${requestId} recalled during revision`);
|
|
3364
3591
|
} catch (err) {
|
|
3365
|
-
|
|
3592
|
+
resumeError = err?.message ?? String(err);
|
|
3593
|
+
this.logger?.error?.("[approvals] cancelRun after revise-window recall failed \u2014 the run may be stranded", {
|
|
3366
3594
|
request: requestId,
|
|
3367
3595
|
run: runId,
|
|
3368
|
-
error:
|
|
3596
|
+
error: resumeError
|
|
3369
3597
|
});
|
|
3370
3598
|
}
|
|
3371
3599
|
}
|
|
@@ -3377,15 +3605,16 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3377
3605
|
});
|
|
3378
3606
|
resumed = true;
|
|
3379
3607
|
} catch (err) {
|
|
3380
|
-
|
|
3608
|
+
resumeError = err?.message ?? String(err);
|
|
3609
|
+
this.logger?.error?.("[approvals] resume after recall failed \u2014 the run may be stranded", {
|
|
3381
3610
|
request: requestId,
|
|
3382
3611
|
run: runId,
|
|
3383
|
-
error:
|
|
3612
|
+
error: resumeError
|
|
3384
3613
|
});
|
|
3385
3614
|
}
|
|
3386
3615
|
}
|
|
3387
3616
|
const fresh = await this.readBackRequest(requestId, context);
|
|
3388
|
-
return { request: fresh, runId, resumed };
|
|
3617
|
+
return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
|
|
3389
3618
|
}
|
|
3390
3619
|
// ── Send back for revision / resubmit (ADR-0044) ─────────────
|
|
3391
3620
|
/**
|
|
@@ -3413,6 +3642,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3413
3642
|
const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
|
|
3414
3643
|
const runId = raw.flow_run_id ?? null;
|
|
3415
3644
|
await this.assertReviseEdge(raw, nodeId);
|
|
3645
|
+
await this.assertRunResumable(runId, requestId);
|
|
3416
3646
|
const now = this.clock.now().toISOString();
|
|
3417
3647
|
const maxRevisions = typeof config.maxRevisions === "number" ? config.maxRevisions : 3;
|
|
3418
3648
|
let priorSendBacks = 0;
|
|
@@ -3465,20 +3695,19 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3465
3695
|
);
|
|
3466
3696
|
}
|
|
3467
3697
|
let resumed2 = false;
|
|
3698
|
+
let resumeError2;
|
|
3468
3699
|
if (runId && typeof this.automation?.resume === "function") {
|
|
3469
|
-
|
|
3470
|
-
|
|
3700
|
+
const outcome = await this.resumeRecordedOutcome(
|
|
3701
|
+
runId,
|
|
3702
|
+
requestId,
|
|
3703
|
+
"the auto-rejection",
|
|
3704
|
+
{
|
|
3471
3705
|
branchLabel: APPROVAL_BRANCH_LABELS.reject,
|
|
3472
3706
|
output: { decision: "reject", autoRejected: true, requestId }
|
|
3473
|
-
}
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
request: requestId,
|
|
3478
|
-
run: runId,
|
|
3479
|
-
error: err?.message ?? String(err)
|
|
3480
|
-
});
|
|
3481
|
-
}
|
|
3707
|
+
}
|
|
3708
|
+
);
|
|
3709
|
+
resumed2 = outcome.resumed;
|
|
3710
|
+
resumeError2 = outcome.resumeError;
|
|
3482
3711
|
}
|
|
3483
3712
|
if (raw.submitter_id) {
|
|
3484
3713
|
await this.notify({
|
|
@@ -3494,7 +3723,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3494
3723
|
});
|
|
3495
3724
|
}
|
|
3496
3725
|
const fresh2 = await this.readBackRequest(requestId, context);
|
|
3497
|
-
return { request: fresh2, runId, resumed: resumed2, autoRejected: true };
|
|
3726
|
+
return { request: fresh2, runId, resumed: resumed2, autoRejected: true, ...resumeError2 ? { resumeError: resumeError2 } : {} };
|
|
3498
3727
|
}
|
|
3499
3728
|
await this.engine.update("sys_approval_request", {
|
|
3500
3729
|
id: requestId,
|
|
@@ -3514,20 +3743,19 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3514
3743
|
);
|
|
3515
3744
|
}
|
|
3516
3745
|
let resumed = false;
|
|
3746
|
+
let resumeError;
|
|
3517
3747
|
if (runId && typeof this.automation?.resume === "function") {
|
|
3518
|
-
|
|
3519
|
-
|
|
3748
|
+
const outcome = await this.resumeRecordedOutcome(
|
|
3749
|
+
runId,
|
|
3750
|
+
requestId,
|
|
3751
|
+
"the send-back",
|
|
3752
|
+
{
|
|
3520
3753
|
branchLabel: APPROVAL_BRANCH_LABELS.revise,
|
|
3521
3754
|
output: { decision: "revise", requestId }
|
|
3522
|
-
}
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
request: requestId,
|
|
3527
|
-
run: runId,
|
|
3528
|
-
error: err?.message ?? String(err)
|
|
3529
|
-
});
|
|
3530
|
-
}
|
|
3755
|
+
}
|
|
3756
|
+
);
|
|
3757
|
+
resumed = outcome.resumed;
|
|
3758
|
+
resumeError = outcome.resumeError;
|
|
3531
3759
|
}
|
|
3532
3760
|
if (raw.submitter_id) {
|
|
3533
3761
|
await this.notify({
|
|
@@ -3543,7 +3771,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3543
3771
|
});
|
|
3544
3772
|
}
|
|
3545
3773
|
const fresh = await this.readBackRequest(requestId, context);
|
|
3546
|
-
return { request: fresh, runId, resumed };
|
|
3774
|
+
return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
|
|
3547
3775
|
}
|
|
3548
3776
|
/**
|
|
3549
3777
|
* ADR-0044 resubmit after rework. Valid on the LATEST `returned` request of
|
|
@@ -3582,6 +3810,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3582
3810
|
const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
|
|
3583
3811
|
const runId = raw.flow_run_id ?? null;
|
|
3584
3812
|
const now = this.clock.now().toISOString();
|
|
3813
|
+
await this.assertRunResumable(runId, requestId);
|
|
3585
3814
|
await this.engine.insert("sys_approval_action", {
|
|
3586
3815
|
id: uid("aact"),
|
|
3587
3816
|
request_id: requestId,
|
|
@@ -3594,23 +3823,22 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3594
3823
|
created_at: now
|
|
3595
3824
|
}, { context: SYSTEM_CTX2 });
|
|
3596
3825
|
let resumed = false;
|
|
3826
|
+
let resumeError;
|
|
3597
3827
|
if (runId && typeof this.automation?.resume === "function") {
|
|
3598
|
-
|
|
3599
|
-
|
|
3828
|
+
const outcome = await this.resumeRecordedOutcome(
|
|
3829
|
+
runId,
|
|
3830
|
+
requestId,
|
|
3831
|
+
"the resubmit",
|
|
3832
|
+
{
|
|
3600
3833
|
branchLabel: APPROVAL_BRANCH_LABELS.resubmit,
|
|
3601
3834
|
output: { resubmitted: true, requestId }
|
|
3602
|
-
}
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
request: requestId,
|
|
3607
|
-
run: runId,
|
|
3608
|
-
error: err?.message ?? String(err)
|
|
3609
|
-
});
|
|
3610
|
-
}
|
|
3835
|
+
}
|
|
3836
|
+
);
|
|
3837
|
+
resumed = outcome.resumed;
|
|
3838
|
+
resumeError = outcome.resumeError;
|
|
3611
3839
|
}
|
|
3612
3840
|
const fresh = await this.readBackRequest(requestId, context);
|
|
3613
|
-
return { request: fresh, runId, resumed };
|
|
3841
|
+
return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
|
|
3614
3842
|
}
|
|
3615
3843
|
/**
|
|
3616
3844
|
* ADR-0044 guard: the flow's approval node must declare a `revise`
|
|
@@ -3671,6 +3899,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3671
3899
|
}
|
|
3672
3900
|
const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
|
|
3673
3901
|
const from = String(input.from ?? actorId).trim();
|
|
3902
|
+
const viaOverride = isOverride && !pending.includes(actorId);
|
|
3674
3903
|
let next;
|
|
3675
3904
|
if (pending.includes(from)) {
|
|
3676
3905
|
if (!context.isSystem && !isOverride && actorId !== from && !pending.includes(actorId)) {
|
|
@@ -3690,8 +3919,14 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3690
3919
|
step_name: raw.flow_node_id ?? raw.current_step ?? null,
|
|
3691
3920
|
step_index: 0,
|
|
3692
3921
|
action: "reassign",
|
|
3922
|
+
// The hand-off parties are STRUCTURED fields (#4365) — the old default
|
|
3923
|
+
// comment (`"<from> → <to>"`) baked raw user ids into user-facing text.
|
|
3924
|
+
// `comment` is pure user input: absent unless the actor wrote one.
|
|
3693
3925
|
actor_id: actorId,
|
|
3694
|
-
|
|
3926
|
+
reassign_from: from,
|
|
3927
|
+
reassign_to: to,
|
|
3928
|
+
via_override: viaOverride,
|
|
3929
|
+
comment: input.comment ?? null,
|
|
3695
3930
|
created_at: now
|
|
3696
3931
|
}, { context: SYSTEM_CTX2 });
|
|
3697
3932
|
let configPatch = {};
|
|
@@ -4055,6 +4290,137 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4055
4290
|
* the real cause and {@link DEAD_RUN_ACTOR_ID} the real actor, so a dead-run
|
|
4056
4291
|
* release is never mistaken for a submitter's withdrawal.
|
|
4057
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
|
+
}
|
|
4058
4424
|
async releaseDeadRunRequests() {
|
|
4059
4425
|
if (typeof this.automation?.getRun !== "function") return { scanned: 0, released: 0 };
|
|
4060
4426
|
let rows = [];
|
|
@@ -4499,35 +4865,28 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4499
4865
|
async rebuildApproverIndex() {
|
|
4500
4866
|
const desired = /* @__PURE__ */ new Map();
|
|
4501
4867
|
const PAGE = 500;
|
|
4502
|
-
|
|
4503
|
-
|
|
4504
|
-
|
|
4868
|
+
const requests = keysetWalk(
|
|
4869
|
+
(q) => this.engine.find("sys_approval_request", {
|
|
4870
|
+
...q,
|
|
4505
4871
|
fields: ["id", "pending_approvers", "organization_id"],
|
|
4506
|
-
limit: PAGE,
|
|
4507
|
-
offset,
|
|
4508
4872
|
context: SYSTEM_CTX2
|
|
4509
|
-
})
|
|
4510
|
-
|
|
4873
|
+
}),
|
|
4874
|
+
{ where: { status: "pending" }, pageSize: PAGE }
|
|
4875
|
+
);
|
|
4876
|
+
for await (const rows of requests.pages()) {
|
|
4511
4877
|
for (const r of rows) {
|
|
4512
4878
|
desired.set(String(r.id), {
|
|
4513
4879
|
approvers: new Set(csvSplit(r.pending_approvers)),
|
|
4514
4880
|
org: r.organization_id ?? null
|
|
4515
4881
|
});
|
|
4516
4882
|
}
|
|
4517
|
-
if (rows.length < PAGE) break;
|
|
4518
4883
|
}
|
|
4519
4884
|
const indexRows = [];
|
|
4520
|
-
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
4525
|
-
context: SYSTEM_CTX2
|
|
4526
|
-
});
|
|
4527
|
-
const rows = Array.isArray(batch) ? batch : [];
|
|
4528
|
-
indexRows.push(...rows);
|
|
4529
|
-
if (rows.length < PAGE) break;
|
|
4530
|
-
}
|
|
4885
|
+
const index = keysetWalk(
|
|
4886
|
+
(q) => this.engine.find("sys_approval_approver", { ...q, context: SYSTEM_CTX2 }),
|
|
4887
|
+
{ pageSize: PAGE }
|
|
4888
|
+
);
|
|
4889
|
+
for await (const rows of index.pages()) indexRows.push(...rows);
|
|
4531
4890
|
let inserted = 0;
|
|
4532
4891
|
let deleted = 0;
|
|
4533
4892
|
const seen = /* @__PURE__ */ new Map();
|
|
@@ -4911,11 +5270,15 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4911
5270
|
});
|
|
4912
5271
|
const actions = Array.isArray(rows) ? rows.map(rowFromAction) : [];
|
|
4913
5272
|
const names = await this.resolveUserNames(
|
|
4914
|
-
actions.
|
|
5273
|
+
actions.flatMap((a) => [a.actor_id, a.reassign_from, a.reassign_to]).filter((id) => id && !id.includes(":"))
|
|
4915
5274
|
);
|
|
4916
5275
|
for (const a of actions) {
|
|
4917
5276
|
const n = a.actor_id ? names.get(String(a.actor_id)) : void 0;
|
|
4918
5277
|
if (n) a.actor_name = n;
|
|
5278
|
+
const fromName = a.reassign_from ? names.get(String(a.reassign_from)) : void 0;
|
|
5279
|
+
if (fromName) a.reassign_from_name = fromName;
|
|
5280
|
+
const toName = a.reassign_to ? names.get(String(a.reassign_to)) : void 0;
|
|
5281
|
+
if (toName) a.reassign_to_name = toName;
|
|
4919
5282
|
}
|
|
4920
5283
|
return actions;
|
|
4921
5284
|
}
|
|
@@ -5118,21 +5481,109 @@ function parseJson2(raw, fallback) {
|
|
|
5118
5481
|
}
|
|
5119
5482
|
return raw;
|
|
5120
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
|
+
}
|
|
5121
5502
|
async function pendingRequestFor(engine, objectName, recordId) {
|
|
5122
5503
|
try {
|
|
5123
5504
|
const rows = await engine.find("sys_approval_request", {
|
|
5124
5505
|
where: { object_name: objectName, record_id: String(recordId), status: "pending" },
|
|
5125
|
-
limit: 1
|
|
5506
|
+
limit: 1,
|
|
5507
|
+
context: { ...SYSTEM_CTX3 }
|
|
5126
5508
|
});
|
|
5127
5509
|
return Array.isArray(rows) && rows[0] ? rows[0] : null;
|
|
5128
5510
|
} catch {
|
|
5129
5511
|
return null;
|
|
5130
5512
|
}
|
|
5131
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
|
+
}
|
|
5132
5585
|
function bindApprovalLockHook(engine, logger) {
|
|
5133
5586
|
engine.registerHook("beforeUpdate", async (ctx) => {
|
|
5134
|
-
const id = String(ctx?.input?.id ?? "");
|
|
5135
|
-
if (!id) return;
|
|
5136
5587
|
const object = ctx?.object ?? ctx?.objectName;
|
|
5137
5588
|
if (!object || String(object).startsWith("sys_approval")) return;
|
|
5138
5589
|
const data = ctx?.input?.data ?? {};
|
|
@@ -5141,18 +5592,19 @@ function bindApprovalLockHook(engine, logger) {
|
|
|
5141
5592
|
if (ctx?.session?.isSystem) return;
|
|
5142
5593
|
const roles = ctx?.session?.roles ?? [];
|
|
5143
5594
|
if (Array.isArray(roles) && roles.includes("admin")) return;
|
|
5144
|
-
const
|
|
5145
|
-
if (
|
|
5595
|
+
const gating = await gatingRequests(engine, ctx, object);
|
|
5596
|
+
if (gating.length === 0) return;
|
|
5146
5597
|
const writerRun = ctx?.provenance?.flowRunId;
|
|
5147
|
-
|
|
5148
|
-
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
|
|
5152
|
-
|
|
5153
|
-
|
|
5154
|
-
|
|
5155
|
-
|
|
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
|
+
}
|
|
5156
5608
|
}, { packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
|
|
5157
5609
|
logger?.info?.("[approvals] record-lock hook bound");
|
|
5158
5610
|
}
|
|
@@ -5201,7 +5653,7 @@ import {
|
|
|
5201
5653
|
getApprovalNodeConfigJsonSchema,
|
|
5202
5654
|
APPROVAL_NODE_TYPE
|
|
5203
5655
|
} from "@objectstack/spec/automation";
|
|
5204
|
-
var
|
|
5656
|
+
var SYSTEM_CTX4 = { isSystem: true, positions: [], permissions: [] };
|
|
5205
5657
|
function nestVariables(variables) {
|
|
5206
5658
|
const vars = {};
|
|
5207
5659
|
for (const [key, value] of variables) {
|
|
@@ -5280,7 +5732,7 @@ function registerApprovalNode(automation, service, logger) {
|
|
|
5280
5732
|
// their `trigger.*` snapshot root.
|
|
5281
5733
|
variables: nestVariables(variables)
|
|
5282
5734
|
}, {
|
|
5283
|
-
...
|
|
5735
|
+
...SYSTEM_CTX4,
|
|
5284
5736
|
userId: context?.userId,
|
|
5285
5737
|
organizationId: context?.organizationId,
|
|
5286
5738
|
tenantId: context?.tenantId
|
|
@@ -5424,7 +5876,13 @@ var ApprovalsServicePlugin = class {
|
|
|
5424
5876
|
const sweep = async () => {
|
|
5425
5877
|
const results = await Promise.allSettled([
|
|
5426
5878
|
svc.runEscalations(),
|
|
5427
|
-
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()
|
|
5428
5886
|
]);
|
|
5429
5887
|
for (const r of results) {
|
|
5430
5888
|
if (r.status === "rejected") {
|
|
@@ -5499,14 +5957,19 @@ var ApprovalsServicePlugin = class {
|
|
|
5499
5957
|
await mountActionPages();
|
|
5500
5958
|
await backfillApproverIndex();
|
|
5501
5959
|
}
|
|
5960
|
+
let automation;
|
|
5502
5961
|
try {
|
|
5503
|
-
|
|
5504
|
-
if (automation && typeof automation.registerNodeExecutor === "function") {
|
|
5505
|
-
this.service.attachAutomation(automation);
|
|
5506
|
-
registerApprovalNode(automation, this.service, ctx.logger);
|
|
5507
|
-
}
|
|
5962
|
+
automation = ctx.getService("automation");
|
|
5508
5963
|
} catch {
|
|
5509
|
-
|
|
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
|
+
);
|
|
5510
5973
|
}
|
|
5511
5974
|
}
|
|
5512
5975
|
async stop(ctx) {
|