@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.js
CHANGED
|
@@ -257,6 +257,18 @@ var init_en_objects_generated = __esm({
|
|
|
257
257
|
comment: {
|
|
258
258
|
label: "Comment"
|
|
259
259
|
},
|
|
260
|
+
via_override: {
|
|
261
|
+
label: "Via Admin Override",
|
|
262
|
+
help: "True when the actor was admitted to this action only by the privileged-override path (#3424) \u2014 they held no slot in the request\u2019s pending-approver slate."
|
|
263
|
+
},
|
|
264
|
+
reassign_from: {
|
|
265
|
+
label: "Reassigned From",
|
|
266
|
+
help: "User whose pending-approver slot was handed over (reassign actions only)"
|
|
267
|
+
},
|
|
268
|
+
reassign_to: {
|
|
269
|
+
label: "Reassigned To",
|
|
270
|
+
help: "User who received the pending-approver slot (reassign actions only)"
|
|
271
|
+
},
|
|
260
272
|
attachments: {
|
|
261
273
|
label: "Attachments",
|
|
262
274
|
help: "Files supporting this action \u2014 e.g. a signed contract or evidence (#3266)."
|
|
@@ -566,6 +578,18 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
566
578
|
comment: {
|
|
567
579
|
label: "\u8BC4\u8BBA"
|
|
568
580
|
},
|
|
581
|
+
via_override: {
|
|
582
|
+
label: "\u7BA1\u7406\u5458\u8D8A\u6743\u64CD\u4F5C",
|
|
583
|
+
help: "\u4E3A\u771F\u8868\u793A\u8BE5\u64CD\u4F5C\u8005\u53EA\u662F\u51ED\u7279\u6743\u8D8A\u6743\u8DEF\u5F84\uFF08#3424\uFF09\u88AB\u653E\u884C\u2014\u2014\u4ED6\u4EEC\u5E76\u4E0D\u5728\u8BE5\u8BF7\u6C42\u7684\u5F85\u5BA1\u6279\u4EBA\u540D\u5355\u4E2D\u3002"
|
|
584
|
+
},
|
|
585
|
+
reassign_from: {
|
|
586
|
+
label: "\u8F6C\u51FA\u4EBA",
|
|
587
|
+
help: "\u88AB\u79FB\u4EA4\u5F85\u5BA1\u6279\u69FD\u4F4D\u7684\u7528\u6237\uFF08\u4EC5\u8F6C\u7B7E\u64CD\u4F5C\uFF09"
|
|
588
|
+
},
|
|
589
|
+
reassign_to: {
|
|
590
|
+
label: "\u8F6C\u5165\u4EBA",
|
|
591
|
+
help: "\u63A5\u6536\u5F85\u5BA1\u6279\u69FD\u4F4D\u7684\u7528\u6237\uFF08\u4EC5\u8F6C\u7B7E\u64CD\u4F5C\uFF09"
|
|
592
|
+
},
|
|
569
593
|
attachments: {
|
|
570
594
|
label: "\u9644\u4EF6",
|
|
571
595
|
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"
|
|
@@ -875,6 +899,18 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
875
899
|
comment: {
|
|
876
900
|
label: "\u30B3\u30E1\u30F3\u30C8"
|
|
877
901
|
},
|
|
902
|
+
via_override: {
|
|
903
|
+
label: "\u7BA1\u7406\u8005\u30AA\u30FC\u30D0\u30FC\u30E9\u30A4\u30C9\u7D4C\u7531",
|
|
904
|
+
help: "true \u306E\u5834\u5408\u3001\u5B9F\u884C\u8005\u306F\u7279\u6A29\u30AA\u30FC\u30D0\u30FC\u30E9\u30A4\u30C9\u7D4C\u8DEF\uFF08#3424\uFF09\u306B\u3088\u3063\u3066\u306E\u307F\u8A31\u53EF\u3055\u308C\u305F\u3053\u3068\u3092\u793A\u3057\u307E\u3059 \u2014 \u5F53\u8A72\u30EA\u30AF\u30A8\u30B9\u30C8\u306E\u627F\u8A8D\u5F85\u3061\u30EA\u30B9\u30C8\u306B\u306F\u542B\u307E\u308C\u3066\u3044\u307E\u305B\u3093\u3002"
|
|
905
|
+
},
|
|
906
|
+
reassign_from: {
|
|
907
|
+
label: "\u5F15\u304D\u7D99\u304E\u5143",
|
|
908
|
+
help: "\u627F\u8A8D\u5F85\u3061\u30B9\u30ED\u30C3\u30C8\u3092\u5F15\u304D\u6E21\u3057\u305F\u30E6\u30FC\u30B6\u30FC\uFF08\u5F15\u304D\u7D99\u304E\u64CD\u4F5C\u306E\u307F\uFF09"
|
|
909
|
+
},
|
|
910
|
+
reassign_to: {
|
|
911
|
+
label: "\u5F15\u304D\u7D99\u304E\u5148",
|
|
912
|
+
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"
|
|
913
|
+
},
|
|
878
914
|
attachments: {
|
|
879
915
|
label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB",
|
|
880
916
|
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"
|
|
@@ -1184,6 +1220,18 @@ var init_es_ES_objects_generated = __esm({
|
|
|
1184
1220
|
comment: {
|
|
1185
1221
|
label: "Comentario"
|
|
1186
1222
|
},
|
|
1223
|
+
via_override: {
|
|
1224
|
+
label: "Mediante anulaci\xF3n de administrador",
|
|
1225
|
+
help: "Verdadero cuando el actor fue admitido en esta acci\xF3n \xFAnicamente por la v\xEDa de anulaci\xF3n privilegiada (#3424): no ocupaba ning\xFAn puesto en la lista de aprobadores pendientes de la solicitud."
|
|
1226
|
+
},
|
|
1227
|
+
reassign_from: {
|
|
1228
|
+
label: "Reasignado de",
|
|
1229
|
+
help: "Usuario cuyo turno de aprobaci\xF3n pendiente fue traspasado (solo acciones de reasignaci\xF3n)"
|
|
1230
|
+
},
|
|
1231
|
+
reassign_to: {
|
|
1232
|
+
label: "Reasignado a",
|
|
1233
|
+
help: "Usuario que recibi\xF3 el turno de aprobaci\xF3n pendiente (solo acciones de reasignaci\xF3n)"
|
|
1234
|
+
},
|
|
1187
1235
|
attachments: {
|
|
1188
1236
|
label: "Adjuntos",
|
|
1189
1237
|
help: "Archivos que respaldan esta acci\xF3n, p. ej. un contrato firmado o pruebas (#3266)."
|
|
@@ -1298,6 +1346,7 @@ module.exports = __toCommonJS(index_exports);
|
|
|
1298
1346
|
|
|
1299
1347
|
// src/sys-approval-request.object.ts
|
|
1300
1348
|
var import_data = require("@objectstack/spec/data");
|
|
1349
|
+
var import_contracts = require("@objectstack/spec/contracts");
|
|
1301
1350
|
var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
1302
1351
|
name: "sys_approval_request",
|
|
1303
1352
|
label: "Approval Request",
|
|
@@ -1396,9 +1445,10 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1396
1445
|
group: "Target"
|
|
1397
1446
|
}),
|
|
1398
1447
|
status: import_data.Field.select(
|
|
1399
|
-
//
|
|
1400
|
-
//
|
|
1401
|
-
|
|
1448
|
+
// Spread from the contract, not re-typed (#3786). `APPROVAL_STATUSES` is
|
|
1449
|
+
// where the list and the reason for each entry live; `ApprovalStatus` is
|
|
1450
|
+
// derived from it, so this column and the contract cannot disagree.
|
|
1451
|
+
[...import_contracts.APPROVAL_STATUSES],
|
|
1402
1452
|
{
|
|
1403
1453
|
label: "Status",
|
|
1404
1454
|
required: true,
|
|
@@ -1667,6 +1717,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1667
1717
|
|
|
1668
1718
|
// src/sys-approval-action.object.ts
|
|
1669
1719
|
var import_data2 = require("@objectstack/spec/data");
|
|
1720
|
+
var import_contracts2 = require("@objectstack/spec/contracts");
|
|
1670
1721
|
var SysApprovalAction = import_data2.ObjectSchema.create({
|
|
1671
1722
|
name: "sys_approval_action",
|
|
1672
1723
|
label: "Approval Action",
|
|
@@ -1679,7 +1730,7 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
|
|
|
1679
1730
|
nameField: "id",
|
|
1680
1731
|
// [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
|
|
1681
1732
|
titleFormat: "{action} \xB7 {step_name}",
|
|
1682
|
-
highlightFields: ["request_id", "step_name", "action", "actor_id", "created_at"],
|
|
1733
|
+
highlightFields: ["request_id", "step_name", "action", "actor_id", "via_override", "created_at"],
|
|
1683
1734
|
// ADR-0104 D3 wave 2. `attachments` is a media field, so the files it holds
|
|
1684
1735
|
// are OWNED by this row — and the storage service would otherwise authorize
|
|
1685
1736
|
// their download by testing whether the caller can READ this row. It cannot:
|
|
@@ -1694,7 +1745,7 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
|
|
|
1694
1745
|
name: "recent",
|
|
1695
1746
|
label: "Recent",
|
|
1696
1747
|
data: { provider: "object", object: "sys_approval_action" },
|
|
1697
|
-
columns: ["created_at", "request_id", "step_name", "action", "actor_id", "comment"],
|
|
1748
|
+
columns: ["created_at", "request_id", "step_name", "action", "actor_id", "via_override", "comment"],
|
|
1698
1749
|
sort: [{ field: "created_at", order: "desc" }],
|
|
1699
1750
|
pagination: { pageSize: 50 },
|
|
1700
1751
|
emptyState: { title: "No approval actions yet", message: "Actions are logged automatically when approvals progress." }
|
|
@@ -1714,7 +1765,7 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
|
|
|
1714
1765
|
name: "all_actions",
|
|
1715
1766
|
label: "All",
|
|
1716
1767
|
data: { provider: "object", object: "sys_approval_action" },
|
|
1717
|
-
columns: ["created_at", "request_id", "step_name", "action", "actor_id", "comment"],
|
|
1768
|
+
columns: ["created_at", "request_id", "step_name", "action", "actor_id", "via_override", "comment"],
|
|
1718
1769
|
sort: [{ field: "created_at", order: "desc" }],
|
|
1719
1770
|
pagination: { pageSize: 100 }
|
|
1720
1771
|
}
|
|
@@ -1745,12 +1796,11 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
|
|
|
1745
1796
|
group: "Target"
|
|
1746
1797
|
}),
|
|
1747
1798
|
action: import_data2.Field.select(
|
|
1748
|
-
//
|
|
1749
|
-
//
|
|
1750
|
-
//
|
|
1751
|
-
//
|
|
1752
|
-
|
|
1753
|
-
["submit", "approve", "reject", "recall", "escalate", "reassign", "remind", "request_info", "comment", "revise", "resubmit", "ooo_substitute"],
|
|
1799
|
+
// Spread from the contract, not re-typed (#3786). `APPROVAL_ACTION_KINDS`
|
|
1800
|
+
// is where the list and the per-kind notes live (which kinds move the flow
|
|
1801
|
+
// and which are thread-only); `ApprovalActionKind` is derived from it, so
|
|
1802
|
+
// this column and the contract cannot disagree.
|
|
1803
|
+
[...import_contracts2.APPROVAL_ACTION_KINDS],
|
|
1754
1804
|
{
|
|
1755
1805
|
label: "Action",
|
|
1756
1806
|
required: true,
|
|
@@ -1763,6 +1813,46 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
|
|
|
1763
1813
|
group: "Action"
|
|
1764
1814
|
}),
|
|
1765
1815
|
comment: import_data2.Field.textarea({ label: "Comment", required: false, group: "Action" }),
|
|
1816
|
+
// #4466 — the one bit of "who really decided this" that was still dropped.
|
|
1817
|
+
// A privileged admin may act on a request whose staffed approver slate they
|
|
1818
|
+
// hold no slot in (the #3424 override path); before this column, that
|
|
1819
|
+
// decision was byte-for-byte identical to the designated approver's own
|
|
1820
|
+
// approval. A reader of the timeline saw `approve` by the admin and could
|
|
1821
|
+
// not tell whether the admin WAS an approver or OVERRODE the ones who were,
|
|
1822
|
+
// and the bypassed approver's later `409 INVALID_STATE` was the only trace
|
|
1823
|
+
// — existing only if they happened to try.
|
|
1824
|
+
//
|
|
1825
|
+
// The platform KNOWS at decision time: it took the `isOverrideActor` branch
|
|
1826
|
+
// to admit the call at all. This is dropped information, not unavailable
|
|
1827
|
+
// information.
|
|
1828
|
+
//
|
|
1829
|
+
// Set on exactly the decisions that were admitted BY that branch — an admin
|
|
1830
|
+
// who is also a genuine slot holder is approving normally and is recorded
|
|
1831
|
+
// as such. Nullable and additive: rows written before this column exists
|
|
1832
|
+
// carry `null`, which reads as "not recorded", never as "not an override".
|
|
1833
|
+
via_override: import_data2.Field.boolean({
|
|
1834
|
+
label: "Via Admin Override",
|
|
1835
|
+
required: false,
|
|
1836
|
+
group: "Action",
|
|
1837
|
+
description: "True when the actor was admitted to this action only by the privileged-override path (#3424) \u2014 they held no slot in the request\u2019s pending-approver slate."
|
|
1838
|
+
}),
|
|
1839
|
+
// Structured hand-off parties for `action: 'reassign'` (#4365). Before
|
|
1840
|
+
// these existed the pair lived only inside a default free-text comment
|
|
1841
|
+
// ("<from_id> → <to_id>"), which no client could parse or render readably.
|
|
1842
|
+
// `comment` is pure user input again; timelines render "from A to B" from
|
|
1843
|
+
// these fields.
|
|
1844
|
+
reassign_from: import_data2.Field.lookup("sys_user", {
|
|
1845
|
+
label: "Reassigned From",
|
|
1846
|
+
required: false,
|
|
1847
|
+
group: "Action",
|
|
1848
|
+
description: "User whose pending-approver slot was handed over (reassign actions only)"
|
|
1849
|
+
}),
|
|
1850
|
+
reassign_to: import_data2.Field.lookup("sys_user", {
|
|
1851
|
+
label: "Reassigned To",
|
|
1852
|
+
required: false,
|
|
1853
|
+
group: "Action",
|
|
1854
|
+
description: "User who received the pending-approver slot (reassign actions only)"
|
|
1855
|
+
}),
|
|
1766
1856
|
attachments: import_data2.Field.file({
|
|
1767
1857
|
label: "Attachments",
|
|
1768
1858
|
required: false,
|
|
@@ -1853,12 +1943,11 @@ var SysApprovalDelegation = import_data4.ObjectSchema.create({
|
|
|
1853
1943
|
pluralLabel: "Approval Delegations",
|
|
1854
1944
|
icon: "user-clock",
|
|
1855
1945
|
isSystem: true,
|
|
1856
|
-
|
|
1857
|
-
//
|
|
1858
|
-
//
|
|
1859
|
-
//
|
|
1860
|
-
|
|
1861
|
-
userActions: { create: true, edit: true, delete: true },
|
|
1946
|
+
// [ADR-0103, #3355] Admin/user-writable DATA on a platform-defined schema: a
|
|
1947
|
+
// user authors their own out-of-office delegation. The bucket default is full
|
|
1948
|
+
// CRUD (matching the full-CRUD apiMethods below), so no `userActions` block is
|
|
1949
|
+
// needed — RLS/permission sets are the authz.
|
|
1950
|
+
managedBy: "system-data",
|
|
1862
1951
|
description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
|
|
1863
1952
|
titleFormat: "{delegator_id} \u2192 {delegate_id}",
|
|
1864
1953
|
highlightFields: ["delegator_id", "delegate_id", "valid_from", "valid_until"],
|
|
@@ -1949,8 +2038,9 @@ var SysApprovalDelegation = import_data4.ObjectSchema.create({
|
|
|
1949
2038
|
var import_node_crypto = require("crypto");
|
|
1950
2039
|
var import_automation = require("@objectstack/spec/automation");
|
|
1951
2040
|
var import_formula = require("@objectstack/formula");
|
|
2041
|
+
var import_types = require("@objectstack/types");
|
|
1952
2042
|
var import_identity = require("@objectstack/spec/identity");
|
|
1953
|
-
var
|
|
2043
|
+
var import_contracts3 = require("@objectstack/spec/contracts");
|
|
1954
2044
|
var import_data5 = require("@objectstack/spec/data");
|
|
1955
2045
|
var import_core = require("@objectstack/core");
|
|
1956
2046
|
|
|
@@ -1963,7 +2053,7 @@ function fail(message) {
|
|
|
1963
2053
|
async function findOrg(engine, where) {
|
|
1964
2054
|
try {
|
|
1965
2055
|
const rows = await engine.find("sys_organization", {
|
|
1966
|
-
|
|
2056
|
+
where,
|
|
1967
2057
|
fields: ["id", "slug", "parent_organization_id"],
|
|
1968
2058
|
limit: 1,
|
|
1969
2059
|
context: SYSTEM_CTX
|
|
@@ -2050,7 +2140,7 @@ async function filterApproversWhoCanRead(deps, userIds, requestOrgId, context) {
|
|
|
2050
2140
|
let members = [];
|
|
2051
2141
|
try {
|
|
2052
2142
|
members = await deps.engine.find("sys_member", {
|
|
2053
|
-
|
|
2143
|
+
where: { organization_id: requestOrg, user_id: { $in: userIds } },
|
|
2054
2144
|
fields: ["user_id"],
|
|
2055
2145
|
limit: 1e4,
|
|
2056
2146
|
context: SYSTEM_CTX
|
|
@@ -2082,6 +2172,7 @@ var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
|
|
|
2082
2172
|
"cancelled",
|
|
2083
2173
|
"timed_out"
|
|
2084
2174
|
]);
|
|
2175
|
+
var STRANDABLE_REQUEST_STATUSES = ["approved", "rejected", "returned"];
|
|
2085
2176
|
var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
|
|
2086
2177
|
var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
|
|
2087
2178
|
function actingUserId(context) {
|
|
@@ -2120,6 +2211,12 @@ function csvSplit(raw) {
|
|
|
2120
2211
|
if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
|
|
2121
2212
|
return String(raw).split(",").map((s) => s.trim()).filter(Boolean);
|
|
2122
2213
|
}
|
|
2214
|
+
function isBlankDecisionOutput(value) {
|
|
2215
|
+
if (value === void 0 || value === null) return true;
|
|
2216
|
+
if (typeof value === "string") return value.trim() === "";
|
|
2217
|
+
if (Array.isArray(value)) return value.filter((v) => v !== null && v !== void 0 && String(v).trim() !== "").length === 0;
|
|
2218
|
+
return false;
|
|
2219
|
+
}
|
|
2123
2220
|
function prettifyMachineName(raw) {
|
|
2124
2221
|
if (!raw) return void 0;
|
|
2125
2222
|
const base = String(raw).replace(/^flow:/, "").trim();
|
|
@@ -2212,6 +2309,14 @@ function rowFromAction(row) {
|
|
|
2212
2309
|
action: row.action,
|
|
2213
2310
|
actor_id: row.actor_id ?? void 0,
|
|
2214
2311
|
comment: row.comment ?? void 0,
|
|
2312
|
+
// Structured reassign hand-off parties (#4365).
|
|
2313
|
+
reassign_from: row.reassign_from ?? void 0,
|
|
2314
|
+
reassign_to: row.reassign_to ?? void 0,
|
|
2315
|
+
// #4466 — surfaced so a timeline can SAY "overridden the approver slate"
|
|
2316
|
+
// rather than render an override identically to an ordinary approval.
|
|
2317
|
+
// `null` (a row written before the column existed) stays `undefined`:
|
|
2318
|
+
// "not recorded" is not the same claim as "not an override".
|
|
2319
|
+
via_override: row.via_override == null ? void 0 : row.via_override === true,
|
|
2215
2320
|
// Decision attachments (#3266): rich descriptors carrying the display name +
|
|
2216
2321
|
// download URL, so consumers label/open them without reading `sys_file`.
|
|
2217
2322
|
attachments: attachments.length ? attachments : void 0,
|
|
@@ -2351,9 +2456,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2351
2456
|
* (having also put them on the context). Those are the only two callers that
|
|
2352
2457
|
* hold a trustworthy actor with no session behind them.
|
|
2353
2458
|
*
|
|
2354
|
-
* A caller with NO identity at all cannot act.
|
|
2355
|
-
*
|
|
2356
|
-
*
|
|
2459
|
+
* A caller with NO identity at all cannot act. Belt-and-suspenders: the REST
|
|
2460
|
+
* anonymous-deny now denies every anonymous request (#3963), but this service
|
|
2461
|
+
* must not rely on a caller upstream — an anonymous actor could otherwise
|
|
2462
|
+
* decide approvals outright by naming one.
|
|
2357
2463
|
*/
|
|
2358
2464
|
async resolveActor(actorId, context) {
|
|
2359
2465
|
if (context?.isSystem) {
|
|
@@ -2639,7 +2745,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2639
2745
|
let rows = [];
|
|
2640
2746
|
try {
|
|
2641
2747
|
rows = await this.engine.find("sys_team_member", {
|
|
2642
|
-
|
|
2748
|
+
where: { team_id: teamId },
|
|
2643
2749
|
fields: ["user_id"],
|
|
2644
2750
|
limit: 1e4,
|
|
2645
2751
|
context: SYSTEM_CTX2
|
|
@@ -2678,7 +2784,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2678
2784
|
if (!businessUnitId) return [];
|
|
2679
2785
|
try {
|
|
2680
2786
|
const seed = await this.engine.find("sys_business_unit", {
|
|
2681
|
-
|
|
2787
|
+
where: this.businessUnitOrgScope({ id: businessUnitId }, organizationId),
|
|
2682
2788
|
fields: ["id", "active"],
|
|
2683
2789
|
limit: 1,
|
|
2684
2790
|
context: SYSTEM_CTX2
|
|
@@ -2713,7 +2819,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2713
2819
|
let rows = [];
|
|
2714
2820
|
try {
|
|
2715
2821
|
rows = await this.engine.find("sys_business_unit_member", {
|
|
2716
|
-
|
|
2822
|
+
where: { business_unit_id: { $in: Array.from(seen) } },
|
|
2717
2823
|
fields: ["user_id"],
|
|
2718
2824
|
limit: 1e4,
|
|
2719
2825
|
context: SYSTEM_CTX2
|
|
@@ -2773,7 +2879,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2773
2879
|
async lookupManager(userId) {
|
|
2774
2880
|
try {
|
|
2775
2881
|
const rows = await this.engine.find("sys_user", {
|
|
2776
|
-
|
|
2882
|
+
where: { id: userId },
|
|
2777
2883
|
fields: ["id", "manager_id"],
|
|
2778
2884
|
limit: 1,
|
|
2779
2885
|
context: SYSTEM_CTX2
|
|
@@ -2823,7 +2929,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2823
2929
|
let rows = [];
|
|
2824
2930
|
try {
|
|
2825
2931
|
rows = await this.engine.find("sys_approval_delegation", {
|
|
2826
|
-
|
|
2932
|
+
where: { delegator_id: delegatorId },
|
|
2827
2933
|
fields: ["id", "delegator_id", "delegate_id", "valid_from", "valid_until", "reason", "organization_id"],
|
|
2828
2934
|
limit: 50,
|
|
2829
2935
|
context: SYSTEM_CTX2
|
|
@@ -3142,6 +3248,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3142
3248
|
if (!isSlotHolder && !isOverride) {
|
|
3143
3249
|
throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
|
|
3144
3250
|
}
|
|
3251
|
+
const viaOverride = isOverride && !isSlotHolder;
|
|
3145
3252
|
const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
|
|
3146
3253
|
const org = raw.organization_id ?? null;
|
|
3147
3254
|
const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
|
|
@@ -3149,8 +3256,9 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3149
3256
|
const now = this.clock.now().toISOString();
|
|
3150
3257
|
const outputKeys = input.outputs ? Object.keys(input.outputs) : [];
|
|
3151
3258
|
let acceptedOutputs;
|
|
3259
|
+
const declaredDefs = (0, import_automation.normalizeDecisionOutputs)(config.decisionOutputs);
|
|
3152
3260
|
if (outputKeys.length) {
|
|
3153
|
-
const declared =
|
|
3261
|
+
const declared = declaredDefs.map((d) => d.key);
|
|
3154
3262
|
if (!declared.length) {
|
|
3155
3263
|
throw new Error(
|
|
3156
3264
|
`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.`
|
|
@@ -3170,6 +3278,15 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3170
3278
|
}
|
|
3171
3279
|
acceptedOutputs = { ...input.outputs };
|
|
3172
3280
|
}
|
|
3281
|
+
if (input.decision === "approve") {
|
|
3282
|
+
const missing = declaredDefs.filter((d) => d.required === true && isBlankDecisionOutput(input.outputs?.[d.key])).map((d) => d.key);
|
|
3283
|
+
if (missing.length) {
|
|
3284
|
+
throw new Error(
|
|
3285
|
+
`VALIDATION_FAILED: decision output(s) \`${missing.join("`, `")}\` are required to approve this request \u2014 open the approval and fill them in before approving.`
|
|
3286
|
+
);
|
|
3287
|
+
}
|
|
3288
|
+
}
|
|
3289
|
+
await this.assertRunResumable(runId, requestId);
|
|
3173
3290
|
await this.engine.insert("sys_approval_action", {
|
|
3174
3291
|
id: uid("aact"),
|
|
3175
3292
|
request_id: requestId,
|
|
@@ -3179,6 +3296,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3179
3296
|
action: input.decision,
|
|
3180
3297
|
actor_id: actorId,
|
|
3181
3298
|
comment: input.comment ?? null,
|
|
3299
|
+
// #4466: the override is recorded on the DECISION, not inferred later.
|
|
3300
|
+
// Written as an explicit `false` for an ordinary decision so a reader can
|
|
3301
|
+
// tell "checked, and it was not an override" from a legacy row's `null`.
|
|
3302
|
+
via_override: viaOverride,
|
|
3182
3303
|
attachments: input.attachments?.length ? input.attachments : null,
|
|
3183
3304
|
created_at: now
|
|
3184
3305
|
}, { context: SYSTEM_CTX2 });
|
|
@@ -3265,22 +3386,131 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3265
3386
|
* Callers still guard on `typeof this.automation?.resume === 'function'`
|
|
3266
3387
|
* (approvals runs fine with no automation attached) and keep their own
|
|
3267
3388
|
* try/catch, because what a failed resume means differs per path.
|
|
3389
|
+
*
|
|
3390
|
+
* Throws when the engine REPORTS failure, not only when it throws one. The
|
|
3391
|
+
* engine answers a lost run with `{ success: false, code: 'RUN_NOT_FOUND' }`
|
|
3392
|
+
* — a plain return value that every caller here used to discard, which is
|
|
3393
|
+
* how an approval could be recorded, reported as resumed, and leave its flow
|
|
3394
|
+
* stranded forever (#4420). The thrown error carries {@link resumeCodeOf}'s
|
|
3395
|
+
* `resumeCode` so callers can tell a benign duplicate from a dead run.
|
|
3268
3396
|
*/
|
|
3269
3397
|
async serviceResume(runId, signal) {
|
|
3270
|
-
await this.automation.resume(runId, { ...signal, [
|
|
3398
|
+
const result = await this.automation.resume(runId, { ...signal, [import_contracts3.RESUME_AUTHORITY_SERVICE]: true });
|
|
3399
|
+
const reported = result;
|
|
3400
|
+
if (reported && typeof reported === "object" && reported.success === false) {
|
|
3401
|
+
const err = new Error(
|
|
3402
|
+
`resume of run '${runId}' failed${reported.code ? ` [${reported.code}]` : ""}: ${reported.error ?? "unknown error"}`
|
|
3403
|
+
);
|
|
3404
|
+
err.resumeCode = reported.code;
|
|
3405
|
+
throw err;
|
|
3406
|
+
}
|
|
3407
|
+
}
|
|
3408
|
+
/** The engine failure code behind a {@link serviceResume} rejection, if any. */
|
|
3409
|
+
static resumeCodeOf(err) {
|
|
3410
|
+
return err?.resumeCode;
|
|
3411
|
+
}
|
|
3412
|
+
/**
|
|
3413
|
+
* Refuse an operation whose whole point is to advance a flow run when that
|
|
3414
|
+
* run no longer exists — BEFORE anything is written down (#4420).
|
|
3415
|
+
*
|
|
3416
|
+
* The half-state this prevents is the one the issue reported: a request
|
|
3417
|
+
* flipped to `approved`, a success toast, and a flow that never moves. Once
|
|
3418
|
+
* the decision row is written there is nothing left to fail cleanly.
|
|
3419
|
+
*
|
|
3420
|
+
* Deliberately permissive at the edges:
|
|
3421
|
+
* - no automation attached, or an engine without `hasSuspendedRun` → no
|
|
3422
|
+
* pre-flight at all (standalone approvals compositions are unaffected);
|
|
3423
|
+
* - the store cannot be READ → fail OPEN. A transient outage must not block
|
|
3424
|
+
* every decision in the tenant; the post-resume check still catches a real
|
|
3425
|
+
* failure and reports it loudly.
|
|
3426
|
+
*/
|
|
3427
|
+
async assertRunResumable(runId, requestId) {
|
|
3428
|
+
if (!runId) return;
|
|
3429
|
+
if (typeof this.automation?.resume !== "function") return;
|
|
3430
|
+
if (typeof this.automation?.hasSuspendedRun !== "function") return;
|
|
3431
|
+
let alive;
|
|
3432
|
+
try {
|
|
3433
|
+
alive = await this.automation.hasSuspendedRun(runId);
|
|
3434
|
+
} catch (err) {
|
|
3435
|
+
this.logger?.warn?.("[approvals] could not verify the flow run is resumable \u2014 proceeding", {
|
|
3436
|
+
request: requestId,
|
|
3437
|
+
run: runId,
|
|
3438
|
+
error: err?.message ?? String(err)
|
|
3439
|
+
});
|
|
3440
|
+
return;
|
|
3441
|
+
}
|
|
3442
|
+
if (!alive) {
|
|
3443
|
+
throw new Error(
|
|
3444
|
+
`RESUME_TARGET_LOST: the flow run '${runId}' behind request ${requestId} no longer exists (it was cancelled, or it paused in a process that did not persist suspended runs). Nothing was recorded. An administrator can recall the request to release the record.`
|
|
3445
|
+
);
|
|
3446
|
+
}
|
|
3447
|
+
}
|
|
3448
|
+
/**
|
|
3449
|
+
* Resume the run behind an outcome that has ALREADY been written down, and
|
|
3450
|
+
* fail loudly when it cannot be (#4420).
|
|
3451
|
+
*
|
|
3452
|
+
* For the operations whose product is the resume — a finalised decision, a
|
|
3453
|
+
* send-back, a resubmit. Their rows are durable by the time this runs, so a
|
|
3454
|
+
* failure here cannot be undone; the one thing left worth doing is refusing
|
|
3455
|
+
* to call it success. {@link assertRunResumable} is what keeps this rare:
|
|
3456
|
+
* everything it catches never reaches a write.
|
|
3457
|
+
*
|
|
3458
|
+
* `RESUME_IN_PROGRESS` is the exception — a concurrent resume is already
|
|
3459
|
+
* advancing the run, so the outcome stands and only `resumed` is false.
|
|
3460
|
+
*
|
|
3461
|
+
* @param what - how the recorded outcome reads in the error, e.g.
|
|
3462
|
+
* `"the approve decision"`.
|
|
3463
|
+
*/
|
|
3464
|
+
async resumeRecordedOutcome(runId, requestId, what, signal) {
|
|
3465
|
+
try {
|
|
3466
|
+
await this.serviceResume(runId, signal);
|
|
3467
|
+
return { resumed: true };
|
|
3468
|
+
} catch (err) {
|
|
3469
|
+
const reason = err?.message ?? String(err);
|
|
3470
|
+
if (_ApprovalService.resumeCodeOf(err) === "RESUME_IN_PROGRESS") {
|
|
3471
|
+
this.logger?.warn?.("[approvals] resume skipped \u2014 already in progress", {
|
|
3472
|
+
request: requestId,
|
|
3473
|
+
run: runId,
|
|
3474
|
+
outcome: what
|
|
3475
|
+
});
|
|
3476
|
+
return { resumed: false, resumeError: reason };
|
|
3477
|
+
}
|
|
3478
|
+
this.logger?.error?.("[approvals] resume failed \u2014 the run is stranded", {
|
|
3479
|
+
request: requestId,
|
|
3480
|
+
run: runId,
|
|
3481
|
+
outcome: what,
|
|
3482
|
+
error: reason
|
|
3483
|
+
});
|
|
3484
|
+
throw new Error(
|
|
3485
|
+
`RESUME_FAILED: ${what} was recorded on request ${requestId}, but its flow run '${runId}' could not be resumed and is now stranded: ${reason}`
|
|
3486
|
+
);
|
|
3487
|
+
}
|
|
3271
3488
|
}
|
|
3272
3489
|
/**
|
|
3273
3490
|
* Public contract entrypoint (ADR-0019). Records a decision on a node-driven
|
|
3274
3491
|
* request via {@link ApprovalService.decideNode} and, when it finalizes,
|
|
3275
3492
|
* resumes the owning flow run down the matching `approve` / `reject` edge.
|
|
3493
|
+
*
|
|
3494
|
+
* A finalising decision whose run cannot be resumed FAILS (#4420). The
|
|
3495
|
+
* decision is already durable by then, so the failure cannot be rolled back
|
|
3496
|
+
* — but it must not be reported as success either: this used to answer HTTP
|
|
3497
|
+
* 200 with `resumed: true` while the flow stayed parked forever, which left
|
|
3498
|
+
* the approver with no signal and the record mirroring a stage it never
|
|
3499
|
+
* reached. `decideNode`'s pre-flight means the common case (the run died
|
|
3500
|
+
* before the decision) never gets this far; what survives here is a genuine
|
|
3501
|
+
* race, and it names the stranded run.
|
|
3276
3502
|
*/
|
|
3277
3503
|
async decide(requestId, input, context) {
|
|
3278
3504
|
const result = await this.decideNode(requestId, input, context);
|
|
3279
3505
|
let resumed = false;
|
|
3506
|
+
let resumeError;
|
|
3280
3507
|
if (result.finalized && result.runId && typeof this.automation?.resume === "function") {
|
|
3281
3508
|
const branchLabel = result.decision === "approve" ? import_automation.APPROVAL_BRANCH_LABELS.approve : import_automation.APPROVAL_BRANCH_LABELS.reject;
|
|
3282
|
-
|
|
3283
|
-
|
|
3509
|
+
const outcome = await this.resumeRecordedOutcome(
|
|
3510
|
+
result.runId,
|
|
3511
|
+
requestId,
|
|
3512
|
+
`the ${result.decision} decision`,
|
|
3513
|
+
{
|
|
3284
3514
|
branchLabel,
|
|
3285
3515
|
// #3447 P2: accepted decision outputs ride the resume envelope and
|
|
3286
3516
|
// land as `<nodeId>.<key>` flow variables — a later approval node's
|
|
@@ -3288,22 +3518,18 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3288
3518
|
// Reserved keys are spread LAST so no output can shadow them (the
|
|
3289
3519
|
// whitelist already rejects them; this is defense in depth).
|
|
3290
3520
|
output: { ...result.outputs ?? {}, decision: result.decision, requestId }
|
|
3291
|
-
}
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
request: requestId,
|
|
3296
|
-
run: result.runId,
|
|
3297
|
-
error: err?.message ?? String(err)
|
|
3298
|
-
});
|
|
3299
|
-
}
|
|
3521
|
+
}
|
|
3522
|
+
);
|
|
3523
|
+
resumed = outcome.resumed;
|
|
3524
|
+
resumeError = outcome.resumeError;
|
|
3300
3525
|
}
|
|
3301
3526
|
return {
|
|
3302
3527
|
request: result.request,
|
|
3303
3528
|
finalized: result.finalized,
|
|
3304
3529
|
decision: result.decision,
|
|
3305
3530
|
runId: result.runId,
|
|
3306
|
-
resumed
|
|
3531
|
+
resumed,
|
|
3532
|
+
...resumeError ? { resumeError } : {}
|
|
3307
3533
|
};
|
|
3308
3534
|
}
|
|
3309
3535
|
/**
|
|
@@ -3371,15 +3597,17 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3371
3597
|
);
|
|
3372
3598
|
}
|
|
3373
3599
|
let resumed = false;
|
|
3600
|
+
let resumeError;
|
|
3374
3601
|
if (inReviseWindow) {
|
|
3375
3602
|
if (runId && typeof this.automation?.cancelRun === "function") {
|
|
3376
3603
|
try {
|
|
3377
3604
|
await this.automation.cancelRun(runId, `approval request ${requestId} recalled during revision`);
|
|
3378
3605
|
} catch (err) {
|
|
3379
|
-
|
|
3606
|
+
resumeError = err?.message ?? String(err);
|
|
3607
|
+
this.logger?.error?.("[approvals] cancelRun after revise-window recall failed \u2014 the run may be stranded", {
|
|
3380
3608
|
request: requestId,
|
|
3381
3609
|
run: runId,
|
|
3382
|
-
error:
|
|
3610
|
+
error: resumeError
|
|
3383
3611
|
});
|
|
3384
3612
|
}
|
|
3385
3613
|
}
|
|
@@ -3391,15 +3619,16 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3391
3619
|
});
|
|
3392
3620
|
resumed = true;
|
|
3393
3621
|
} catch (err) {
|
|
3394
|
-
|
|
3622
|
+
resumeError = err?.message ?? String(err);
|
|
3623
|
+
this.logger?.error?.("[approvals] resume after recall failed \u2014 the run may be stranded", {
|
|
3395
3624
|
request: requestId,
|
|
3396
3625
|
run: runId,
|
|
3397
|
-
error:
|
|
3626
|
+
error: resumeError
|
|
3398
3627
|
});
|
|
3399
3628
|
}
|
|
3400
3629
|
}
|
|
3401
3630
|
const fresh = await this.readBackRequest(requestId, context);
|
|
3402
|
-
return { request: fresh, runId, resumed };
|
|
3631
|
+
return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
|
|
3403
3632
|
}
|
|
3404
3633
|
// ── Send back for revision / resubmit (ADR-0044) ─────────────
|
|
3405
3634
|
/**
|
|
@@ -3427,6 +3656,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3427
3656
|
const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
|
|
3428
3657
|
const runId = raw.flow_run_id ?? null;
|
|
3429
3658
|
await this.assertReviseEdge(raw, nodeId);
|
|
3659
|
+
await this.assertRunResumable(runId, requestId);
|
|
3430
3660
|
const now = this.clock.now().toISOString();
|
|
3431
3661
|
const maxRevisions = typeof config.maxRevisions === "number" ? config.maxRevisions : 3;
|
|
3432
3662
|
let priorSendBacks = 0;
|
|
@@ -3479,20 +3709,19 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3479
3709
|
);
|
|
3480
3710
|
}
|
|
3481
3711
|
let resumed2 = false;
|
|
3712
|
+
let resumeError2;
|
|
3482
3713
|
if (runId && typeof this.automation?.resume === "function") {
|
|
3483
|
-
|
|
3484
|
-
|
|
3714
|
+
const outcome = await this.resumeRecordedOutcome(
|
|
3715
|
+
runId,
|
|
3716
|
+
requestId,
|
|
3717
|
+
"the auto-rejection",
|
|
3718
|
+
{
|
|
3485
3719
|
branchLabel: import_automation.APPROVAL_BRANCH_LABELS.reject,
|
|
3486
3720
|
output: { decision: "reject", autoRejected: true, requestId }
|
|
3487
|
-
}
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
request: requestId,
|
|
3492
|
-
run: runId,
|
|
3493
|
-
error: err?.message ?? String(err)
|
|
3494
|
-
});
|
|
3495
|
-
}
|
|
3721
|
+
}
|
|
3722
|
+
);
|
|
3723
|
+
resumed2 = outcome.resumed;
|
|
3724
|
+
resumeError2 = outcome.resumeError;
|
|
3496
3725
|
}
|
|
3497
3726
|
if (raw.submitter_id) {
|
|
3498
3727
|
await this.notify({
|
|
@@ -3508,7 +3737,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3508
3737
|
});
|
|
3509
3738
|
}
|
|
3510
3739
|
const fresh2 = await this.readBackRequest(requestId, context);
|
|
3511
|
-
return { request: fresh2, runId, resumed: resumed2, autoRejected: true };
|
|
3740
|
+
return { request: fresh2, runId, resumed: resumed2, autoRejected: true, ...resumeError2 ? { resumeError: resumeError2 } : {} };
|
|
3512
3741
|
}
|
|
3513
3742
|
await this.engine.update("sys_approval_request", {
|
|
3514
3743
|
id: requestId,
|
|
@@ -3528,20 +3757,19 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3528
3757
|
);
|
|
3529
3758
|
}
|
|
3530
3759
|
let resumed = false;
|
|
3760
|
+
let resumeError;
|
|
3531
3761
|
if (runId && typeof this.automation?.resume === "function") {
|
|
3532
|
-
|
|
3533
|
-
|
|
3762
|
+
const outcome = await this.resumeRecordedOutcome(
|
|
3763
|
+
runId,
|
|
3764
|
+
requestId,
|
|
3765
|
+
"the send-back",
|
|
3766
|
+
{
|
|
3534
3767
|
branchLabel: import_automation.APPROVAL_BRANCH_LABELS.revise,
|
|
3535
3768
|
output: { decision: "revise", requestId }
|
|
3536
|
-
}
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
request: requestId,
|
|
3541
|
-
run: runId,
|
|
3542
|
-
error: err?.message ?? String(err)
|
|
3543
|
-
});
|
|
3544
|
-
}
|
|
3769
|
+
}
|
|
3770
|
+
);
|
|
3771
|
+
resumed = outcome.resumed;
|
|
3772
|
+
resumeError = outcome.resumeError;
|
|
3545
3773
|
}
|
|
3546
3774
|
if (raw.submitter_id) {
|
|
3547
3775
|
await this.notify({
|
|
@@ -3557,7 +3785,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3557
3785
|
});
|
|
3558
3786
|
}
|
|
3559
3787
|
const fresh = await this.readBackRequest(requestId, context);
|
|
3560
|
-
return { request: fresh, runId, resumed };
|
|
3788
|
+
return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
|
|
3561
3789
|
}
|
|
3562
3790
|
/**
|
|
3563
3791
|
* ADR-0044 resubmit after rework. Valid on the LATEST `returned` request of
|
|
@@ -3596,6 +3824,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3596
3824
|
const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
|
|
3597
3825
|
const runId = raw.flow_run_id ?? null;
|
|
3598
3826
|
const now = this.clock.now().toISOString();
|
|
3827
|
+
await this.assertRunResumable(runId, requestId);
|
|
3599
3828
|
await this.engine.insert("sys_approval_action", {
|
|
3600
3829
|
id: uid("aact"),
|
|
3601
3830
|
request_id: requestId,
|
|
@@ -3608,23 +3837,22 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3608
3837
|
created_at: now
|
|
3609
3838
|
}, { context: SYSTEM_CTX2 });
|
|
3610
3839
|
let resumed = false;
|
|
3840
|
+
let resumeError;
|
|
3611
3841
|
if (runId && typeof this.automation?.resume === "function") {
|
|
3612
|
-
|
|
3613
|
-
|
|
3842
|
+
const outcome = await this.resumeRecordedOutcome(
|
|
3843
|
+
runId,
|
|
3844
|
+
requestId,
|
|
3845
|
+
"the resubmit",
|
|
3846
|
+
{
|
|
3614
3847
|
branchLabel: import_automation.APPROVAL_BRANCH_LABELS.resubmit,
|
|
3615
3848
|
output: { resubmitted: true, requestId }
|
|
3616
|
-
}
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
request: requestId,
|
|
3621
|
-
run: runId,
|
|
3622
|
-
error: err?.message ?? String(err)
|
|
3623
|
-
});
|
|
3624
|
-
}
|
|
3849
|
+
}
|
|
3850
|
+
);
|
|
3851
|
+
resumed = outcome.resumed;
|
|
3852
|
+
resumeError = outcome.resumeError;
|
|
3625
3853
|
}
|
|
3626
3854
|
const fresh = await this.readBackRequest(requestId, context);
|
|
3627
|
-
return { request: fresh, runId, resumed };
|
|
3855
|
+
return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
|
|
3628
3856
|
}
|
|
3629
3857
|
/**
|
|
3630
3858
|
* ADR-0044 guard: the flow's approval node must declare a `revise`
|
|
@@ -3685,6 +3913,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3685
3913
|
}
|
|
3686
3914
|
const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
|
|
3687
3915
|
const from = String(input.from ?? actorId).trim();
|
|
3916
|
+
const viaOverride = isOverride && !pending.includes(actorId);
|
|
3688
3917
|
let next;
|
|
3689
3918
|
if (pending.includes(from)) {
|
|
3690
3919
|
if (!context.isSystem && !isOverride && actorId !== from && !pending.includes(actorId)) {
|
|
@@ -3704,8 +3933,14 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3704
3933
|
step_name: raw.flow_node_id ?? raw.current_step ?? null,
|
|
3705
3934
|
step_index: 0,
|
|
3706
3935
|
action: "reassign",
|
|
3936
|
+
// The hand-off parties are STRUCTURED fields (#4365) — the old default
|
|
3937
|
+
// comment (`"<from> → <to>"`) baked raw user ids into user-facing text.
|
|
3938
|
+
// `comment` is pure user input: absent unless the actor wrote one.
|
|
3707
3939
|
actor_id: actorId,
|
|
3708
|
-
|
|
3940
|
+
reassign_from: from,
|
|
3941
|
+
reassign_to: to,
|
|
3942
|
+
via_override: viaOverride,
|
|
3943
|
+
comment: input.comment ?? null,
|
|
3709
3944
|
created_at: now
|
|
3710
3945
|
}, { context: SYSTEM_CTX2 });
|
|
3711
3946
|
let configPatch = {};
|
|
@@ -4069,6 +4304,137 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4069
4304
|
* the real cause and {@link DEAD_RUN_ACTOR_ID} the real actor, so a dead-run
|
|
4070
4305
|
* release is never mistaken for a submitter's withdrawal.
|
|
4071
4306
|
*/
|
|
4307
|
+
/**
|
|
4308
|
+
* Read-only inspection for the OTHER dead-run shape: a request that is
|
|
4309
|
+
* already TERMINAL while its `flow_run_id` points at nothing (#4469).
|
|
4310
|
+
*
|
|
4311
|
+
* #4460 stopped new ones being produced; nothing found the ones already
|
|
4312
|
+
* stuck. The failure mode (#4420) is a request row flipped to `approved` /
|
|
4313
|
+
* `rejected` / `returned` whose owning run no longer exists — the decision
|
|
4314
|
+
* landed, the flow never moved. Any deployment on 17.0.0-rc.1 that hit the
|
|
4315
|
+
* wiring hole and crossed a restart mid-approval can be carrying these rows.
|
|
4316
|
+
*
|
|
4317
|
+
* {@link releaseDeadRunRequests} cannot see them, for a reason worth naming:
|
|
4318
|
+
* it scans `status: 'pending'`, and the very step that zombified the request
|
|
4319
|
+
* is the one that took it OUT of `pending`. The act of breaking it removed it
|
|
4320
|
+
* from the only sweeper's field of view — which is a large part of why this
|
|
4321
|
+
* class of failure stayed silent.
|
|
4322
|
+
*
|
|
4323
|
+
* It also could not have answered the question even if it looked: its
|
|
4324
|
+
* liveness oracle is `getRun`, which reads the execution LOG, and after a
|
|
4325
|
+
* restart that returns `null` for a perfectly ALIVE suspended run. It treats
|
|
4326
|
+
* `null` as alive (conservative, correct) — but that means it has no way to
|
|
4327
|
+
* say "this run is really gone".
|
|
4328
|
+
*
|
|
4329
|
+
* So this uses BOTH oracles, and a row must fail both to be reported:
|
|
4330
|
+
*
|
|
4331
|
+
* - `hasSuspendedRun(runId) === false` — the suspension store itself says no
|
|
4332
|
+
* live pause exists. It THROWS when the store cannot be read, and that
|
|
4333
|
+
* case is SKIPPED, never counted as dead: an unreadable store means
|
|
4334
|
+
* "unknown", and a storage outage must not be published as a lost run.
|
|
4335
|
+
* - `getRun(runId) == null` — no terminal history row either (the `run_`
|
|
4336
|
+
* prefixed rows in `sys_automation_run`). A run that merely finished is
|
|
4337
|
+
* not stranded; a request whose run neither waits nor ever completed is.
|
|
4338
|
+
*
|
|
4339
|
+
* **Reports; never rewrites.** No status is changed and no run is cancelled.
|
|
4340
|
+
* The decision genuinely happened — a human approved or rejected — and
|
|
4341
|
+
* silently rolling it back would make the audit trail disagree with the
|
|
4342
|
+
* facts. What an operator needs first is visibility: which requests are stuck
|
|
4343
|
+
* at which step, and what the mirrored status field on the business record
|
|
4344
|
+
* still says. Whether to re-run the downstream actions or re-open the
|
|
4345
|
+
* approval is a judgement call this cannot make.
|
|
4346
|
+
*/
|
|
4347
|
+
async inspectStrandedRequests(options) {
|
|
4348
|
+
const empty = { scanned: 0, stranded: [], undetermined: 0 };
|
|
4349
|
+
if (typeof this.automation?.hasSuspendedRun !== "function") return empty;
|
|
4350
|
+
if (typeof this.automation?.getRun !== "function") return empty;
|
|
4351
|
+
const limit = options?.limit ?? 500;
|
|
4352
|
+
let rows = [];
|
|
4353
|
+
try {
|
|
4354
|
+
rows = await this.engine.find("sys_approval_request", {
|
|
4355
|
+
where: { status: { $in: [...STRANDABLE_REQUEST_STATUSES] } },
|
|
4356
|
+
limit,
|
|
4357
|
+
context: SYSTEM_CTX2
|
|
4358
|
+
}) ?? [];
|
|
4359
|
+
} catch (err) {
|
|
4360
|
+
this.logger?.warn?.("[approvals] stranded-request scan failed to list requests", {
|
|
4361
|
+
error: err?.message ?? String(err)
|
|
4362
|
+
});
|
|
4363
|
+
return empty;
|
|
4364
|
+
}
|
|
4365
|
+
const stranded = [];
|
|
4366
|
+
let undetermined = 0;
|
|
4367
|
+
for (const raw of rows) {
|
|
4368
|
+
const runId = raw?.flow_run_id ? String(raw.flow_run_id) : "";
|
|
4369
|
+
if (!runId) continue;
|
|
4370
|
+
let suspended;
|
|
4371
|
+
try {
|
|
4372
|
+
suspended = await this.automation.hasSuspendedRun(runId);
|
|
4373
|
+
} catch (err) {
|
|
4374
|
+
undetermined++;
|
|
4375
|
+
this.logger?.warn?.("[approvals] stranded-request scan could not read the suspension store", {
|
|
4376
|
+
request: raw?.id,
|
|
4377
|
+
run: runId,
|
|
4378
|
+
error: err?.message ?? String(err)
|
|
4379
|
+
});
|
|
4380
|
+
continue;
|
|
4381
|
+
}
|
|
4382
|
+
if (suspended) continue;
|
|
4383
|
+
let terminal = null;
|
|
4384
|
+
try {
|
|
4385
|
+
terminal = await this.automation.getRun(runId);
|
|
4386
|
+
} catch (err) {
|
|
4387
|
+
undetermined++;
|
|
4388
|
+
this.logger?.warn?.("[approvals] stranded-request scan could not read the run history", {
|
|
4389
|
+
request: raw?.id,
|
|
4390
|
+
run: runId,
|
|
4391
|
+
error: err?.message ?? String(err)
|
|
4392
|
+
});
|
|
4393
|
+
continue;
|
|
4394
|
+
}
|
|
4395
|
+
if (terminal) continue;
|
|
4396
|
+
const config = parseJson(
|
|
4397
|
+
raw.node_config_json,
|
|
4398
|
+
{ approvers: [], behavior: "first_response" }
|
|
4399
|
+
);
|
|
4400
|
+
const mirrorField = config.approvalStatusField;
|
|
4401
|
+
let mirroredStatus;
|
|
4402
|
+
if (mirrorField) {
|
|
4403
|
+
try {
|
|
4404
|
+
const recs = await this.engine.find(raw.object_name, {
|
|
4405
|
+
where: { id: raw.record_id },
|
|
4406
|
+
limit: 1,
|
|
4407
|
+
context: SYSTEM_CTX2
|
|
4408
|
+
});
|
|
4409
|
+
const rec = Array.isArray(recs) ? recs[0] : null;
|
|
4410
|
+
if (rec) mirroredStatus = rec[mirrorField] ?? void 0;
|
|
4411
|
+
} catch {
|
|
4412
|
+
}
|
|
4413
|
+
}
|
|
4414
|
+
stranded.push({
|
|
4415
|
+
requestId: String(raw.id),
|
|
4416
|
+
status: raw.status,
|
|
4417
|
+
runId,
|
|
4418
|
+
flowName: typeof raw.process_name === "string" ? raw.process_name.replace(/^flow:/, "") : void 0,
|
|
4419
|
+
nodeId: raw.flow_node_id ?? raw.current_step ?? void 0,
|
|
4420
|
+
objectName: raw.object_name,
|
|
4421
|
+
recordId: raw.record_id,
|
|
4422
|
+
organizationId: raw.organization_id ?? null,
|
|
4423
|
+
completedAt: raw.completed_at ?? void 0,
|
|
4424
|
+
mirrorField,
|
|
4425
|
+
mirroredStatus
|
|
4426
|
+
});
|
|
4427
|
+
}
|
|
4428
|
+
if (stranded.length || undetermined) {
|
|
4429
|
+
this.logger?.warn?.("[approvals] stranded terminal requests (decision recorded, flow run gone)", {
|
|
4430
|
+
scanned: rows.length,
|
|
4431
|
+
stranded: stranded.length,
|
|
4432
|
+
undetermined,
|
|
4433
|
+
requests: stranded.map((s) => `${s.requestId}@${s.nodeId ?? "?"} \u2192 run ${s.runId}`)
|
|
4434
|
+
});
|
|
4435
|
+
}
|
|
4436
|
+
return { scanned: rows.length, stranded, undetermined };
|
|
4437
|
+
}
|
|
4072
4438
|
async releaseDeadRunRequests() {
|
|
4073
4439
|
if (typeof this.automation?.getRun !== "function") return { scanned: 0, released: 0 };
|
|
4074
4440
|
let rows = [];
|
|
@@ -4513,35 +4879,28 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4513
4879
|
async rebuildApproverIndex() {
|
|
4514
4880
|
const desired = /* @__PURE__ */ new Map();
|
|
4515
4881
|
const PAGE = 500;
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
|
|
4882
|
+
const requests = (0, import_types.keysetWalk)(
|
|
4883
|
+
(q) => this.engine.find("sys_approval_request", {
|
|
4884
|
+
...q,
|
|
4519
4885
|
fields: ["id", "pending_approvers", "organization_id"],
|
|
4520
|
-
limit: PAGE,
|
|
4521
|
-
offset,
|
|
4522
4886
|
context: SYSTEM_CTX2
|
|
4523
|
-
})
|
|
4524
|
-
|
|
4887
|
+
}),
|
|
4888
|
+
{ where: { status: "pending" }, pageSize: PAGE }
|
|
4889
|
+
);
|
|
4890
|
+
for await (const rows of requests.pages()) {
|
|
4525
4891
|
for (const r of rows) {
|
|
4526
4892
|
desired.set(String(r.id), {
|
|
4527
4893
|
approvers: new Set(csvSplit(r.pending_approvers)),
|
|
4528
4894
|
org: r.organization_id ?? null
|
|
4529
4895
|
});
|
|
4530
4896
|
}
|
|
4531
|
-
if (rows.length < PAGE) break;
|
|
4532
4897
|
}
|
|
4533
4898
|
const indexRows = [];
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
context: SYSTEM_CTX2
|
|
4540
|
-
});
|
|
4541
|
-
const rows = Array.isArray(batch) ? batch : [];
|
|
4542
|
-
indexRows.push(...rows);
|
|
4543
|
-
if (rows.length < PAGE) break;
|
|
4544
|
-
}
|
|
4899
|
+
const index = (0, import_types.keysetWalk)(
|
|
4900
|
+
(q) => this.engine.find("sys_approval_approver", { ...q, context: SYSTEM_CTX2 }),
|
|
4901
|
+
{ pageSize: PAGE }
|
|
4902
|
+
);
|
|
4903
|
+
for await (const rows of index.pages()) indexRows.push(...rows);
|
|
4545
4904
|
let inserted = 0;
|
|
4546
4905
|
let deleted = 0;
|
|
4547
4906
|
const seen = /* @__PURE__ */ new Map();
|
|
@@ -4925,11 +5284,15 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4925
5284
|
});
|
|
4926
5285
|
const actions = Array.isArray(rows) ? rows.map(rowFromAction) : [];
|
|
4927
5286
|
const names = await this.resolveUserNames(
|
|
4928
|
-
actions.
|
|
5287
|
+
actions.flatMap((a) => [a.actor_id, a.reassign_from, a.reassign_to]).filter((id) => id && !id.includes(":"))
|
|
4929
5288
|
);
|
|
4930
5289
|
for (const a of actions) {
|
|
4931
5290
|
const n = a.actor_id ? names.get(String(a.actor_id)) : void 0;
|
|
4932
5291
|
if (n) a.actor_name = n;
|
|
5292
|
+
const fromName = a.reassign_from ? names.get(String(a.reassign_from)) : void 0;
|
|
5293
|
+
if (fromName) a.reassign_from_name = fromName;
|
|
5294
|
+
const toName = a.reassign_to ? names.get(String(a.reassign_to)) : void 0;
|
|
5295
|
+
if (toName) a.reassign_to_name = toName;
|
|
4933
5296
|
}
|
|
4934
5297
|
return actions;
|
|
4935
5298
|
}
|
|
@@ -5132,21 +5495,109 @@ function parseJson2(raw, fallback) {
|
|
|
5132
5495
|
}
|
|
5133
5496
|
return raw;
|
|
5134
5497
|
}
|
|
5498
|
+
var PENDING_LOCK_LIMIT = 1e3;
|
|
5499
|
+
var SYSTEM_CTX3 = { isSystem: true, positions: [], permissions: [] };
|
|
5500
|
+
function lockedError(message) {
|
|
5501
|
+
const err = new Error(`RECORD_LOCKED: ${message}`);
|
|
5502
|
+
err.code = "RECORD_LOCKED";
|
|
5503
|
+
err.statusCode = 409;
|
|
5504
|
+
throw err;
|
|
5505
|
+
}
|
|
5506
|
+
function asIdList(id) {
|
|
5507
|
+
if (typeof id === "number") return [id];
|
|
5508
|
+
if (typeof id === "string") return id === "" ? null : [id];
|
|
5509
|
+
if (id && typeof id === "object" && Array.isArray(id.$in)) {
|
|
5510
|
+
const raw = id.$in;
|
|
5511
|
+
const scalars = raw.filter((v) => typeof v === "string" || typeof v === "number");
|
|
5512
|
+
return scalars.length === raw.length ? scalars : null;
|
|
5513
|
+
}
|
|
5514
|
+
return null;
|
|
5515
|
+
}
|
|
5135
5516
|
async function pendingRequestFor(engine, objectName, recordId) {
|
|
5136
5517
|
try {
|
|
5137
5518
|
const rows = await engine.find("sys_approval_request", {
|
|
5138
5519
|
where: { object_name: objectName, record_id: String(recordId), status: "pending" },
|
|
5139
|
-
limit: 1
|
|
5520
|
+
limit: 1,
|
|
5521
|
+
context: { ...SYSTEM_CTX3 }
|
|
5140
5522
|
});
|
|
5141
5523
|
return Array.isArray(rows) && rows[0] ? rows[0] : null;
|
|
5142
5524
|
} catch {
|
|
5143
5525
|
return null;
|
|
5144
5526
|
}
|
|
5145
5527
|
}
|
|
5528
|
+
async function pendingRequestsForRecords(engine, objectName, recordIds) {
|
|
5529
|
+
if (recordIds.length === 0) return [];
|
|
5530
|
+
if (recordIds.length > PENDING_LOCK_LIMIT) {
|
|
5531
|
+
lockedError(
|
|
5532
|
+
`refusing to authorize an update naming more than ${PENDING_LOCK_LIMIT} records of '${objectName}' \u2014 the approval lock cannot check them row by row; scope the write`
|
|
5533
|
+
);
|
|
5534
|
+
}
|
|
5535
|
+
if (recordIds.length === 1) {
|
|
5536
|
+
const one = await pendingRequestFor(engine, objectName, String(recordIds[0]));
|
|
5537
|
+
return one ? [one] : [];
|
|
5538
|
+
}
|
|
5539
|
+
try {
|
|
5540
|
+
const rows = await engine.find("sys_approval_request", {
|
|
5541
|
+
where: { object_name: objectName, record_id: { $in: recordIds.map(String) }, status: "pending" },
|
|
5542
|
+
limit: PENDING_LOCK_LIMIT + 1,
|
|
5543
|
+
context: { ...SYSTEM_CTX3 }
|
|
5544
|
+
});
|
|
5545
|
+
return Array.isArray(rows) ? rows : [];
|
|
5546
|
+
} catch {
|
|
5547
|
+
return [];
|
|
5548
|
+
}
|
|
5549
|
+
}
|
|
5550
|
+
async function pendingRequestsForObject(engine, objectName) {
|
|
5551
|
+
try {
|
|
5552
|
+
const rows = await engine.find("sys_approval_request", {
|
|
5553
|
+
where: { object_name: objectName, status: "pending" },
|
|
5554
|
+
limit: PENDING_LOCK_LIMIT + 1,
|
|
5555
|
+
context: { ...SYSTEM_CTX3 }
|
|
5556
|
+
});
|
|
5557
|
+
return Array.isArray(rows) ? rows : [];
|
|
5558
|
+
} catch {
|
|
5559
|
+
return null;
|
|
5560
|
+
}
|
|
5561
|
+
}
|
|
5562
|
+
async function narrowToMatchedRecords(engine, objectName, where, candidates) {
|
|
5563
|
+
const lockedIds = candidates.map((c) => String(c?.record_id ?? ""));
|
|
5564
|
+
let rows;
|
|
5565
|
+
try {
|
|
5566
|
+
rows = await engine.find(objectName, {
|
|
5567
|
+
where: { $and: [where, { id: { $in: lockedIds } }] },
|
|
5568
|
+
fields: ["id"],
|
|
5569
|
+
limit: lockedIds.length,
|
|
5570
|
+
context: { ...SYSTEM_CTX3 }
|
|
5571
|
+
});
|
|
5572
|
+
} catch (err) {
|
|
5573
|
+
lockedError(
|
|
5574
|
+
`cannot determine which rows a predicate update on '${objectName}' would touch (${err?.message ?? String(err)}); ${candidates.length} record(s) of it carry a pending approval, so the write is refused`
|
|
5575
|
+
);
|
|
5576
|
+
}
|
|
5577
|
+
const matched = new Set((Array.isArray(rows) ? rows : []).map((r) => String(r?.id)));
|
|
5578
|
+
return candidates.filter((c) => matched.has(String(c?.record_id ?? "")));
|
|
5579
|
+
}
|
|
5580
|
+
async function gatingRequests(engine, ctx, objectName) {
|
|
5581
|
+
const byId = asIdList(ctx?.input?.id);
|
|
5582
|
+
if (byId) return pendingRequestsForRecords(engine, objectName, byId);
|
|
5583
|
+
const rawWhere = ctx?.input?.options?.where;
|
|
5584
|
+
const hasWhere = rawWhere !== void 0 && rawWhere !== null;
|
|
5585
|
+
const whereObj = hasWhere && typeof rawWhere === "object" && !Array.isArray(rawWhere) ? rawWhere : null;
|
|
5586
|
+
const namedIds = whereObj ? asIdList(whereObj.id) : null;
|
|
5587
|
+
const candidates = namedIds ? await pendingRequestsForRecords(engine, objectName, namedIds) : await pendingRequestsForObject(engine, objectName);
|
|
5588
|
+
if (candidates === null) return [];
|
|
5589
|
+
if (candidates.length === 0) return [];
|
|
5590
|
+
if (candidates.length > PENDING_LOCK_LIMIT) {
|
|
5591
|
+
lockedError(
|
|
5592
|
+
`refusing a predicate update on '${objectName}': more than ${PENDING_LOCK_LIMIT} of its records carry a pending approval, so the lock cannot decide row by row; scope the write to the rows you mean`
|
|
5593
|
+
);
|
|
5594
|
+
}
|
|
5595
|
+
if (!hasWhere) return candidates;
|
|
5596
|
+
if (namedIds && whereObj && Object.keys(whereObj).every((k) => k === "id")) return candidates;
|
|
5597
|
+
return narrowToMatchedRecords(engine, objectName, rawWhere, candidates);
|
|
5598
|
+
}
|
|
5146
5599
|
function bindApprovalLockHook(engine, logger) {
|
|
5147
5600
|
engine.registerHook("beforeUpdate", async (ctx) => {
|
|
5148
|
-
const id = String(ctx?.input?.id ?? "");
|
|
5149
|
-
if (!id) return;
|
|
5150
5601
|
const object = ctx?.object ?? ctx?.objectName;
|
|
5151
5602
|
if (!object || String(object).startsWith("sys_approval")) return;
|
|
5152
5603
|
const data = ctx?.input?.data ?? {};
|
|
@@ -5155,18 +5606,19 @@ function bindApprovalLockHook(engine, logger) {
|
|
|
5155
5606
|
if (ctx?.session?.isSystem) return;
|
|
5156
5607
|
const roles = ctx?.session?.roles ?? [];
|
|
5157
5608
|
if (Array.isArray(roles) && roles.includes("admin")) return;
|
|
5158
|
-
const
|
|
5159
|
-
if (
|
|
5609
|
+
const gating = await gatingRequests(engine, ctx, object);
|
|
5610
|
+
if (gating.length === 0) return;
|
|
5160
5611
|
const writerRun = ctx?.provenance?.flowRunId;
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5167
|
-
|
|
5168
|
-
|
|
5169
|
-
|
|
5612
|
+
for (const pending of gating) {
|
|
5613
|
+
if (writerRun && pending?.flow_run_id && String(writerRun) === String(pending.flow_run_id)) continue;
|
|
5614
|
+
const config = parseJson2(pending?.node_config_json, {});
|
|
5615
|
+
if (config?.lockRecord === false) continue;
|
|
5616
|
+
const mirror = config?.approvalStatusField;
|
|
5617
|
+
if (typeof mirror === "string" && mirror && changedFields.every((f) => f === mirror)) continue;
|
|
5618
|
+
lockedError(
|
|
5619
|
+
`record '${String(pending?.record_id ?? "")}' of '${object}' is locked while an approval is in progress`
|
|
5620
|
+
);
|
|
5621
|
+
}
|
|
5170
5622
|
}, { packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
|
|
5171
5623
|
logger?.info?.("[approvals] record-lock hook bound");
|
|
5172
5624
|
}
|
|
@@ -5210,7 +5662,7 @@ function unbindAllHooks(engine) {
|
|
|
5210
5662
|
|
|
5211
5663
|
// src/approval-node.ts
|
|
5212
5664
|
var import_automation2 = require("@objectstack/spec/automation");
|
|
5213
|
-
var
|
|
5665
|
+
var SYSTEM_CTX4 = { isSystem: true, positions: [], permissions: [] };
|
|
5214
5666
|
function nestVariables(variables) {
|
|
5215
5667
|
const vars = {};
|
|
5216
5668
|
for (const [key, value] of variables) {
|
|
@@ -5289,7 +5741,7 @@ function registerApprovalNode(automation, service, logger) {
|
|
|
5289
5741
|
// their `trigger.*` snapshot root.
|
|
5290
5742
|
variables: nestVariables(variables)
|
|
5291
5743
|
}, {
|
|
5292
|
-
...
|
|
5744
|
+
...SYSTEM_CTX4,
|
|
5293
5745
|
userId: context?.userId,
|
|
5294
5746
|
organizationId: context?.organizationId,
|
|
5295
5747
|
tenantId: context?.tenantId
|
|
@@ -5433,7 +5885,13 @@ var ApprovalsServicePlugin = class {
|
|
|
5433
5885
|
const sweep = async () => {
|
|
5434
5886
|
const results = await Promise.allSettled([
|
|
5435
5887
|
svc.runEscalations(),
|
|
5436
|
-
svc.releaseDeadRunRequests()
|
|
5888
|
+
svc.releaseDeadRunRequests(),
|
|
5889
|
+
// #4469 — the other half of the dead-run picture, and the one no
|
|
5890
|
+
// sweeper could see: a request already TERMINAL whose run is gone.
|
|
5891
|
+
// Read-only by design (it reports; it never rewrites a decision
|
|
5892
|
+
// that really happened), so it rides the same clock purely to make
|
|
5893
|
+
// the finding surface without an operator knowing to go looking.
|
|
5894
|
+
svc.inspectStrandedRequests()
|
|
5437
5895
|
]);
|
|
5438
5896
|
for (const r of results) {
|
|
5439
5897
|
if (r.status === "rejected") {
|
|
@@ -5508,14 +5966,19 @@ var ApprovalsServicePlugin = class {
|
|
|
5508
5966
|
await mountActionPages();
|
|
5509
5967
|
await backfillApproverIndex();
|
|
5510
5968
|
}
|
|
5969
|
+
let automation;
|
|
5511
5970
|
try {
|
|
5512
|
-
|
|
5513
|
-
if (automation && typeof automation.registerNodeExecutor === "function") {
|
|
5514
|
-
this.service.attachAutomation(automation);
|
|
5515
|
-
registerApprovalNode(automation, this.service, ctx.logger);
|
|
5516
|
-
}
|
|
5971
|
+
automation = ctx.getService("automation");
|
|
5517
5972
|
} catch {
|
|
5518
|
-
|
|
5973
|
+
automation = void 0;
|
|
5974
|
+
}
|
|
5975
|
+
if (automation && typeof automation.registerNodeExecutor === "function") {
|
|
5976
|
+
this.service.attachAutomation(automation);
|
|
5977
|
+
registerApprovalNode(automation, this.service, ctx.logger);
|
|
5978
|
+
} else {
|
|
5979
|
+
ctx.logger.warn(
|
|
5980
|
+
"ApprovalsServicePlugin: no automation engine \u2014 the `approval` flow node is NOT registered. Every ADR-0019 approval flow in this deployment fails at execution time with NO_EXECUTOR. Add @objectstack/service-automation to the stack to enable them."
|
|
5981
|
+
);
|
|
5519
5982
|
}
|
|
5520
5983
|
}
|
|
5521
5984
|
async stop(ctx) {
|