@objectstack/plugin-approvals 17.2.0 → 17.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1144 -0
- package/dist/index.d.mts +1976 -807
- package/dist/index.d.ts +1976 -807
- package/dist/index.js +1198 -87
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1205 -90
- package/dist/index.mjs.map +1 -1
- package/package.json +23 -15
package/dist/index.mjs
CHANGED
|
@@ -29,7 +29,7 @@ var init_en_objects_generated = __esm({
|
|
|
29
29
|
},
|
|
30
30
|
organization_id: {
|
|
31
31
|
label: "Organization",
|
|
32
|
-
help: "
|
|
32
|
+
help: "Organization of the record this request is about (falls back to the acting context when the record has none)"
|
|
33
33
|
},
|
|
34
34
|
process_name: {
|
|
35
35
|
label: "Source",
|
|
@@ -55,7 +55,15 @@ var init_en_objects_generated = __esm({
|
|
|
55
55
|
approved: "Approved",
|
|
56
56
|
rejected: "Rejected",
|
|
57
57
|
recalled: "Recalled",
|
|
58
|
-
returned: "Returned"
|
|
58
|
+
returned: "Returned",
|
|
59
|
+
cancelled: "Cancelled"
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
cancel_reason: {
|
|
63
|
+
label: "Cancellation Reason",
|
|
64
|
+
help: "Why the platform voided this request (set only when the status is cancelled)",
|
|
65
|
+
options: {
|
|
66
|
+
record_deleted: "Related record deleted"
|
|
59
67
|
}
|
|
60
68
|
},
|
|
61
69
|
current_step: {
|
|
@@ -236,7 +244,8 @@ var init_en_objects_generated = __esm({
|
|
|
236
244
|
comment: "Comment",
|
|
237
245
|
revise: "Revise",
|
|
238
246
|
resubmit: "Resubmit",
|
|
239
|
-
ooo_substitute: "Out-of-Office Substitution"
|
|
247
|
+
ooo_substitute: "Out-of-Office Substitution",
|
|
248
|
+
cancel: "Cancel"
|
|
240
249
|
}
|
|
241
250
|
},
|
|
242
251
|
actor_id: {
|
|
@@ -350,7 +359,7 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
350
359
|
},
|
|
351
360
|
organization_id: {
|
|
352
361
|
label: "\u7EC4\u7EC7",
|
|
353
|
-
help: "\
|
|
362
|
+
help: "\u8BE5\u8BF7\u6C42\u6240\u6D89\u8BB0\u5F55\u6240\u5C5E\u7684\u7EC4\u7EC7\uFF08\u8BB0\u5F55\u65E0\u7EC4\u7EC7\u65F6\u56DE\u9000\u5230\u64CD\u4F5C\u4E0A\u4E0B\u6587\uFF09"
|
|
354
363
|
},
|
|
355
364
|
process_name: {
|
|
356
365
|
label: "\u6765\u6E90",
|
|
@@ -376,7 +385,15 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
376
385
|
approved: "\u5DF2\u6279\u51C6",
|
|
377
386
|
rejected: "\u5DF2\u62D2\u7EDD",
|
|
378
387
|
recalled: "\u5DF2\u64A4\u56DE",
|
|
379
|
-
returned: "\u5DF2\u9000\u56DE\u4FEE\u6539"
|
|
388
|
+
returned: "\u5DF2\u9000\u56DE\u4FEE\u6539",
|
|
389
|
+
cancelled: "\u5DF2\u4F5C\u5E9F"
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
cancel_reason: {
|
|
393
|
+
label: "\u4F5C\u5E9F\u539F\u56E0",
|
|
394
|
+
help: "\u5E73\u53F0\u4F5C\u5E9F\u8BE5\u5BA1\u6279\u5355\u7684\u539F\u56E0(\u4EC5\u5728\u72B6\u6001\u4E3A\u300C\u5DF2\u4F5C\u5E9F\u300D\u65F6\u5199\u5165)",
|
|
395
|
+
options: {
|
|
396
|
+
record_deleted: "\u5173\u8054\u8BB0\u5F55\u5DF2\u5220\u9664"
|
|
380
397
|
}
|
|
381
398
|
},
|
|
382
399
|
current_step: {
|
|
@@ -557,7 +574,8 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
557
574
|
comment: "\u8BC4\u8BBA",
|
|
558
575
|
revise: "\u9000\u56DE\u4FEE\u6539",
|
|
559
576
|
resubmit: "\u91CD\u65B0\u63D0\u4EA4",
|
|
560
|
-
ooo_substitute: "\u4E0D\u5728\u5C97\u6539\u6D3E"
|
|
577
|
+
ooo_substitute: "\u4E0D\u5728\u5C97\u6539\u6D3E",
|
|
578
|
+
cancel: "\u4F5C\u5E9F"
|
|
561
579
|
}
|
|
562
580
|
},
|
|
563
581
|
actor_id: {
|
|
@@ -671,7 +689,7 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
671
689
|
},
|
|
672
690
|
organization_id: {
|
|
673
691
|
label: "\u7D44\u7E54",
|
|
674
|
-
help: "\u3053\u306E\
|
|
692
|
+
help: "\u3053\u306E\u30EA\u30AF\u30A8\u30B9\u30C8\u306E\u5BFE\u8C61\u30EC\u30B3\u30FC\u30C9\u304C\u5C5E\u3059\u308B\u7D44\u7E54\uFF08\u30EC\u30B3\u30FC\u30C9\u306B\u7D44\u7E54\u304C\u306A\u3044\u5834\u5408\u306F\u64CD\u4F5C\u30B3\u30F3\u30C6\u30AD\u30B9\u30C8\u306B\u30D5\u30A9\u30FC\u30EB\u30D0\u30C3\u30AF\uFF09"
|
|
675
693
|
},
|
|
676
694
|
process_name: {
|
|
677
695
|
label: "\u30BD\u30FC\u30B9",
|
|
@@ -697,7 +715,15 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
697
715
|
approved: "\u627F\u8A8D\u6E08\u307F",
|
|
698
716
|
rejected: "\u5374\u4E0B\u6E08\u307F",
|
|
699
717
|
recalled: "\u53D6\u308A\u6D88\u3057\u6E08\u307F",
|
|
700
|
-
returned: "\u5DEE\u3057\u623B\u3057\u6E08\u307F"
|
|
718
|
+
returned: "\u5DEE\u3057\u623B\u3057\u6E08\u307F",
|
|
719
|
+
cancelled: "\u7121\u52B9\u5316\u6E08\u307F"
|
|
720
|
+
}
|
|
721
|
+
},
|
|
722
|
+
cancel_reason: {
|
|
723
|
+
label: "\u7121\u52B9\u5316\u7406\u7531",
|
|
724
|
+
help: "\u30D7\u30E9\u30C3\u30C8\u30D5\u30A9\u30FC\u30E0\u304C\u3053\u306E\u7533\u8ACB\u3092\u7121\u52B9\u5316\u3057\u305F\u7406\u7531(\u30B9\u30C6\u30FC\u30BF\u30B9\u304C\u7121\u52B9\u5316\u6E08\u307F\u306E\u5834\u5408\u306E\u307F\u8A2D\u5B9A)",
|
|
725
|
+
options: {
|
|
726
|
+
record_deleted: "\u95A2\u9023\u30EC\u30B3\u30FC\u30C9\u304C\u524A\u9664\u3055\u308C\u307E\u3057\u305F"
|
|
701
727
|
}
|
|
702
728
|
},
|
|
703
729
|
current_step: {
|
|
@@ -878,7 +904,8 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
878
904
|
comment: "\u30B3\u30E1\u30F3\u30C8",
|
|
879
905
|
revise: "\u5DEE\u3057\u623B\u3057",
|
|
880
906
|
resubmit: "\u518D\u63D0\u51FA",
|
|
881
|
-
ooo_substitute: "\u4E0D\u5728\u6642\u4EE3\u7406"
|
|
907
|
+
ooo_substitute: "\u4E0D\u5728\u6642\u4EE3\u7406",
|
|
908
|
+
cancel: "\u7121\u52B9\u5316"
|
|
882
909
|
}
|
|
883
910
|
},
|
|
884
911
|
actor_id: {
|
|
@@ -992,7 +1019,7 @@ var init_es_ES_objects_generated = __esm({
|
|
|
992
1019
|
},
|
|
993
1020
|
organization_id: {
|
|
994
1021
|
label: "Organizaci\xF3n",
|
|
995
|
-
help: "
|
|
1022
|
+
help: "Organizaci\xF3n del registro al que se refiere esta solicitud (recurre al contexto del actor cuando el registro no tiene ninguna)."
|
|
996
1023
|
},
|
|
997
1024
|
process_name: {
|
|
998
1025
|
label: "Origen",
|
|
@@ -1018,7 +1045,15 @@ var init_es_ES_objects_generated = __esm({
|
|
|
1018
1045
|
approved: "Aprobada",
|
|
1019
1046
|
rejected: "Rechazada",
|
|
1020
1047
|
recalled: "Retirada",
|
|
1021
|
-
returned: "Devuelta para revisi\xF3n"
|
|
1048
|
+
returned: "Devuelta para revisi\xF3n",
|
|
1049
|
+
cancelled: "Anulada"
|
|
1050
|
+
}
|
|
1051
|
+
},
|
|
1052
|
+
cancel_reason: {
|
|
1053
|
+
label: "Motivo de anulaci\xF3n",
|
|
1054
|
+
help: "Por qu\xE9 la plataforma anul\xF3 esta solicitud (solo se establece cuando el estado es Anulada)",
|
|
1055
|
+
options: {
|
|
1056
|
+
record_deleted: "Registro relacionado eliminado"
|
|
1022
1057
|
}
|
|
1023
1058
|
},
|
|
1024
1059
|
current_step: {
|
|
@@ -1199,7 +1234,8 @@ var init_es_ES_objects_generated = __esm({
|
|
|
1199
1234
|
comment: "Comentario",
|
|
1200
1235
|
revise: "Devoluci\xF3n",
|
|
1201
1236
|
resubmit: "Reenv\xEDo",
|
|
1202
|
-
ooo_substitute: "Sustituci\xF3n por ausencia"
|
|
1237
|
+
ooo_substitute: "Sustituci\xF3n por ausencia",
|
|
1238
|
+
cancel: "Anulaci\xF3n"
|
|
1203
1239
|
}
|
|
1204
1240
|
},
|
|
1205
1241
|
actor_id: {
|
|
@@ -1297,12 +1333,104 @@ var init_es_ES_objects_generated = __esm({
|
|
|
1297
1333
|
}
|
|
1298
1334
|
});
|
|
1299
1335
|
|
|
1336
|
+
// src/translations/zh-CN.source-hashes.generated.ts
|
|
1337
|
+
var zhCNGeneratedSourceHashes;
|
|
1338
|
+
var init_zh_CN_source_hashes_generated = __esm({
|
|
1339
|
+
"src/translations/zh-CN.source-hashes.generated.ts"() {
|
|
1340
|
+
"use strict";
|
|
1341
|
+
zhCNGeneratedSourceHashes = {
|
|
1342
|
+
"objects.sys_approval_request.fields.flow_node_id.help": "154aa23b4eee4cae",
|
|
1343
|
+
"objects.sys_approval_request.fields.flow_node_id.label": "052ad568aa41227c",
|
|
1344
|
+
"objects.sys_approval_request.fields.flow_run_id.help": "35c92818f5e11090",
|
|
1345
|
+
"objects.sys_approval_request.fields.flow_run_id.label": "d0124e323da966f9",
|
|
1346
|
+
"objects.sys_approval_request.fields.node_config_json.help": "7ee00ef9b3ca7e80",
|
|
1347
|
+
"objects.sys_approval_request.fields.node_config_json.label": "e64eb4bf7d7ea033"
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
// src/translations/ja-JP.source-hashes.generated.ts
|
|
1353
|
+
var jaJPGeneratedSourceHashes;
|
|
1354
|
+
var init_ja_JP_source_hashes_generated = __esm({
|
|
1355
|
+
"src/translations/ja-JP.source-hashes.generated.ts"() {
|
|
1356
|
+
"use strict";
|
|
1357
|
+
jaJPGeneratedSourceHashes = {
|
|
1358
|
+
"objects.sys_approval_delegation._views.active.emptyState.message": "1095d61c017b6db2",
|
|
1359
|
+
"objects.sys_approval_delegation._views.active.emptyState.title": "799d28e51d88963e",
|
|
1360
|
+
"objects.sys_approval_delegation._views.active.label": "eee107cf4e466779",
|
|
1361
|
+
"objects.sys_approval_delegation.description": "432b0435aa15f6b9",
|
|
1362
|
+
"objects.sys_approval_delegation.fields.created_at.label": "1f02d416befb595b",
|
|
1363
|
+
"objects.sys_approval_delegation.fields.delegate_id.help": "9cfc641f7b0b04f9",
|
|
1364
|
+
"objects.sys_approval_delegation.fields.delegate_id.label": "afd6d8733dc5bc14",
|
|
1365
|
+
"objects.sys_approval_delegation.fields.delegator_id.help": "c4686c5c9f24e0be",
|
|
1366
|
+
"objects.sys_approval_delegation.fields.delegator_id.label": "f76b1f95f2fdabff",
|
|
1367
|
+
"objects.sys_approval_delegation.fields.id.label": "3383564051b4b76d",
|
|
1368
|
+
"objects.sys_approval_delegation.fields.organization_id.help": "f02982e88229d9ca",
|
|
1369
|
+
"objects.sys_approval_delegation.fields.organization_id.label": "3e55836156e1c1de",
|
|
1370
|
+
"objects.sys_approval_delegation.fields.reason.help": "712516f1461dff2b",
|
|
1371
|
+
"objects.sys_approval_delegation.fields.reason.label": "ac399f03d7f2e64d",
|
|
1372
|
+
"objects.sys_approval_delegation.fields.updated_at.label": "aba63dc2a9c79b8d",
|
|
1373
|
+
"objects.sys_approval_delegation.fields.valid_from.help": "01386010c454bfbd",
|
|
1374
|
+
"objects.sys_approval_delegation.fields.valid_from.label": "02eaf201e57c9eda",
|
|
1375
|
+
"objects.sys_approval_delegation.fields.valid_until.help": "a48ea34e65933e9d",
|
|
1376
|
+
"objects.sys_approval_delegation.fields.valid_until.label": "a68db5bd4fc72a13",
|
|
1377
|
+
"objects.sys_approval_delegation.label": "4389c4fe5f5c9746",
|
|
1378
|
+
"objects.sys_approval_delegation.pluralLabel": "f07fec3ffd74e6db",
|
|
1379
|
+
"objects.sys_approval_request.fields.flow_node_id.help": "154aa23b4eee4cae",
|
|
1380
|
+
"objects.sys_approval_request.fields.flow_node_id.label": "052ad568aa41227c",
|
|
1381
|
+
"objects.sys_approval_request.fields.flow_run_id.help": "35c92818f5e11090",
|
|
1382
|
+
"objects.sys_approval_request.fields.flow_run_id.label": "d0124e323da966f9",
|
|
1383
|
+
"objects.sys_approval_request.fields.node_config_json.help": "7ee00ef9b3ca7e80",
|
|
1384
|
+
"objects.sys_approval_request.fields.node_config_json.label": "e64eb4bf7d7ea033"
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
// src/translations/es-ES.source-hashes.generated.ts
|
|
1390
|
+
var esESGeneratedSourceHashes;
|
|
1391
|
+
var init_es_ES_source_hashes_generated = __esm({
|
|
1392
|
+
"src/translations/es-ES.source-hashes.generated.ts"() {
|
|
1393
|
+
"use strict";
|
|
1394
|
+
esESGeneratedSourceHashes = {
|
|
1395
|
+
"objects.sys_approval_action.fields.actor_id.label": "b155813f8a7f06e3",
|
|
1396
|
+
"objects.sys_approval_delegation._views.active.emptyState.message": "1095d61c017b6db2",
|
|
1397
|
+
"objects.sys_approval_delegation._views.active.emptyState.title": "799d28e51d88963e",
|
|
1398
|
+
"objects.sys_approval_delegation._views.active.label": "eee107cf4e466779",
|
|
1399
|
+
"objects.sys_approval_delegation.description": "432b0435aa15f6b9",
|
|
1400
|
+
"objects.sys_approval_delegation.fields.created_at.label": "1f02d416befb595b",
|
|
1401
|
+
"objects.sys_approval_delegation.fields.delegate_id.help": "9cfc641f7b0b04f9",
|
|
1402
|
+
"objects.sys_approval_delegation.fields.delegate_id.label": "afd6d8733dc5bc14",
|
|
1403
|
+
"objects.sys_approval_delegation.fields.delegator_id.help": "c4686c5c9f24e0be",
|
|
1404
|
+
"objects.sys_approval_delegation.fields.delegator_id.label": "f76b1f95f2fdabff",
|
|
1405
|
+
"objects.sys_approval_delegation.fields.id.label": "3383564051b4b76d",
|
|
1406
|
+
"objects.sys_approval_delegation.fields.organization_id.help": "f02982e88229d9ca",
|
|
1407
|
+
"objects.sys_approval_delegation.fields.organization_id.label": "3e55836156e1c1de",
|
|
1408
|
+
"objects.sys_approval_delegation.fields.reason.help": "712516f1461dff2b",
|
|
1409
|
+
"objects.sys_approval_delegation.fields.reason.label": "ac399f03d7f2e64d",
|
|
1410
|
+
"objects.sys_approval_delegation.fields.updated_at.label": "aba63dc2a9c79b8d",
|
|
1411
|
+
"objects.sys_approval_delegation.fields.valid_from.help": "01386010c454bfbd",
|
|
1412
|
+
"objects.sys_approval_delegation.fields.valid_from.label": "02eaf201e57c9eda",
|
|
1413
|
+
"objects.sys_approval_delegation.fields.valid_until.help": "a48ea34e65933e9d",
|
|
1414
|
+
"objects.sys_approval_delegation.fields.valid_until.label": "a68db5bd4fc72a13",
|
|
1415
|
+
"objects.sys_approval_delegation.label": "4389c4fe5f5c9746",
|
|
1416
|
+
"objects.sys_approval_delegation.pluralLabel": "f07fec3ffd74e6db",
|
|
1417
|
+
"objects.sys_approval_request.fields.flow_node_id.help": "154aa23b4eee4cae",
|
|
1418
|
+
"objects.sys_approval_request.fields.flow_node_id.label": "052ad568aa41227c",
|
|
1419
|
+
"objects.sys_approval_request.fields.flow_run_id.help": "35c92818f5e11090",
|
|
1420
|
+
"objects.sys_approval_request.fields.flow_run_id.label": "d0124e323da966f9",
|
|
1421
|
+
"objects.sys_approval_request.fields.node_config_json.help": "7ee00ef9b3ca7e80",
|
|
1422
|
+
"objects.sys_approval_request.fields.node_config_json.label": "e64eb4bf7d7ea033"
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1300
1427
|
// src/translations/index.ts
|
|
1301
1428
|
var translations_exports = {};
|
|
1302
1429
|
__export(translations_exports, {
|
|
1303
1430
|
ApprovalsTranslations: () => ApprovalsTranslations
|
|
1304
1431
|
});
|
|
1305
|
-
|
|
1432
|
+
import { withSourceFallback } from "@objectstack/platform-objects/apps";
|
|
1433
|
+
var enSource, ApprovalsTranslations;
|
|
1306
1434
|
var init_translations = __esm({
|
|
1307
1435
|
"src/translations/index.ts"() {
|
|
1308
1436
|
"use strict";
|
|
@@ -1310,18 +1438,27 @@ var init_translations = __esm({
|
|
|
1310
1438
|
init_zh_CN_objects_generated();
|
|
1311
1439
|
init_ja_JP_objects_generated();
|
|
1312
1440
|
init_es_ES_objects_generated();
|
|
1441
|
+
init_zh_CN_source_hashes_generated();
|
|
1442
|
+
init_ja_JP_source_hashes_generated();
|
|
1443
|
+
init_es_ES_source_hashes_generated();
|
|
1444
|
+
enSource = { objects: enObjects };
|
|
1313
1445
|
ApprovalsTranslations = {
|
|
1314
|
-
en:
|
|
1315
|
-
"zh-CN": { objects: zhCNObjects },
|
|
1316
|
-
"ja-JP": { objects: jaJPObjects },
|
|
1317
|
-
"es-ES": { objects: esESObjects }
|
|
1446
|
+
en: enSource,
|
|
1447
|
+
"zh-CN": withSourceFallback({ objects: zhCNObjects }, enSource, void 0, zhCNGeneratedSourceHashes),
|
|
1448
|
+
"ja-JP": withSourceFallback({ objects: jaJPObjects }, enSource, void 0, jaJPGeneratedSourceHashes),
|
|
1449
|
+
"es-ES": withSourceFallback({ objects: esESObjects }, enSource, void 0, esESGeneratedSourceHashes)
|
|
1318
1450
|
};
|
|
1319
1451
|
}
|
|
1320
1452
|
});
|
|
1321
1453
|
|
|
1322
1454
|
// src/sys-approval-request.object.ts
|
|
1323
1455
|
import { ObjectSchema, Field } from "@objectstack/spec/data";
|
|
1324
|
-
import {
|
|
1456
|
+
import {
|
|
1457
|
+
APPROVAL_STATUSES,
|
|
1458
|
+
APPROVAL_STATUS_LABELS,
|
|
1459
|
+
APPROVAL_CANCEL_REASONS,
|
|
1460
|
+
APPROVAL_CANCEL_REASON_LABELS
|
|
1461
|
+
} from "@objectstack/spec/contracts";
|
|
1325
1462
|
var SysApprovalRequest = ObjectSchema.create({
|
|
1326
1463
|
name: "sys_approval_request",
|
|
1327
1464
|
label: "Approval Request",
|
|
@@ -1368,7 +1505,10 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1368
1505
|
label: "Completed",
|
|
1369
1506
|
data: { provider: "object", object: "sys_approval_request" },
|
|
1370
1507
|
columns: ["process_name", "object_name", "record_id", "status", "submitter_id", "completed_at"],
|
|
1371
|
-
|
|
1508
|
+
// Every terminal state, `cancelled` included (#13568) — a platform-voided
|
|
1509
|
+
// request is kept as audit evidence, and a terminal state absent from the
|
|
1510
|
+
// only curated terminal view is evidence nobody can find.
|
|
1511
|
+
filter: [{ field: "status", operator: "in", value: ["approved", "rejected", "recalled", "cancelled"] }],
|
|
1372
1512
|
sort: [{ field: "completed_at", order: "desc" }],
|
|
1373
1513
|
pagination: { pageSize: 25 }
|
|
1374
1514
|
},
|
|
@@ -1384,46 +1524,40 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1384
1524
|
},
|
|
1385
1525
|
fields: {
|
|
1386
1526
|
id: Field.text({ label: "Request ID", required: true, readonly: true, group: "System" }),
|
|
1387
|
-
//
|
|
1527
|
+
// [#10101, the cloud#1395 Option A ruling] The SUBJECT record's
|
|
1528
|
+
// organization, with the acting context as fallback — resolved by the
|
|
1529
|
+
// SHARED platform-row resolver (`resolveRecordOrganizationField`,
|
|
1530
|
+
// `@objectstack/metadata-core`) in `openNodeRequest`, the row's only
|
|
1531
|
+
// writer. The same `requestOrg` stamps `sys_approval_action` and the
|
|
1532
|
+
// `sys_approval_approver` index, so all three move together.
|
|
1388
1533
|
//
|
|
1389
|
-
//
|
|
1390
|
-
//
|
|
1391
|
-
//
|
|
1392
|
-
//
|
|
1393
|
-
//
|
|
1534
|
+
// Why subject-first: an approval request is read through the organization
|
|
1535
|
+
// wall by the approvals inbox, and the acting context is NULL on every
|
|
1536
|
+
// schedule / time-relative / api triggered run (none carries a tenant, by
|
|
1537
|
+
// construction). Stamped from the actor alone — the pre-#10101 behaviour,
|
|
1538
|
+
// measured on cloud#1395 as 27 of 27 rows org-less on a walled HotCRM SaaS
|
|
1539
|
+
// boot — such a request LOCKED the record it was about while being
|
|
1540
|
+
// invisible in every inbox, its owner's included. Subject-first is also
|
|
1541
|
+
// what `sys_audit_log`'s writer already did (#8707 honouring #8287's
|
|
1542
|
+
// ruling), so an approval row and an audit row about the same record now
|
|
1543
|
+
// land behind the same wall instead of two.
|
|
1394
1544
|
//
|
|
1395
|
-
// The
|
|
1396
|
-
// `
|
|
1397
|
-
//
|
|
1398
|
-
//
|
|
1399
|
-
//
|
|
1400
|
-
// `record_id` owned by a specific customer.
|
|
1401
|
-
//
|
|
1402
|
-
// ⛔ Do NOT read that as "platform tables do not carry an organization".
|
|
1403
|
-
// `sys_audit_log` (1669 rows) was correctly attributed on the SAME boot,
|
|
1404
|
-
// because its writer takes the organization from the RECORD the row is
|
|
1405
|
-
// about, with the session only as fallback (plugin-audit
|
|
1406
|
-
// `resolveRecordOrganizationField`, #8707 honouring #8287's ruling). Two
|
|
1407
|
-
// writers read the actor; a third reads the subject. That disagreement is
|
|
1408
|
-
// the defect.
|
|
1409
|
-
//
|
|
1410
|
-
// Which of the two a side-table row should follow is an open contract
|
|
1411
|
-
// question on cloud#1395 — the audit resolver is scope-pinned to audit
|
|
1412
|
-
// stamping by the #8778 ruling, so this writer needs its own. The same
|
|
1413
|
-
// `ctxOrg` also stamps `sys_approval_action` and `sys_approval_approver`,
|
|
1414
|
-
// so all three move together.
|
|
1545
|
+
// The `sys_api_key` divergence is deliberate and preserved: its
|
|
1546
|
+
// `tenancy.organizationField: 'active_organization_id'` (stamp-only,
|
|
1547
|
+
// #8778) wins limb 0 of the shared resolver, while the credential table
|
|
1548
|
+
// itself stays unwalled (`tenancy.enabled: false`) — who a row is ABOUT
|
|
1549
|
+
// and what an object is WALLED by remain different questions.
|
|
1415
1550
|
organization_id: Field.lookup("sys_organization", {
|
|
1416
1551
|
label: "Organization",
|
|
1417
1552
|
required: false,
|
|
1418
1553
|
group: "System",
|
|
1419
|
-
//
|
|
1420
|
-
//
|
|
1421
|
-
//
|
|
1422
|
-
//
|
|
1423
|
-
//
|
|
1424
|
-
//
|
|
1425
|
-
|
|
1426
|
-
description: "Tenant that owns this approval request (propagated from submitter context)"
|
|
1554
|
+
// Reworded with the #10101 write-side fix (was "Tenant that owns this
|
|
1555
|
+
// approval request (propagated from submitter context)" — it claimed a
|
|
1556
|
+
// propagation that measurably did not happen, and said "Tenant" where
|
|
1557
|
+
// ADR-0120 §Terminology requires "organization"). Extracted into the
|
|
1558
|
+
// generated i18n bundles as `help`; the four locales regenerate in the
|
|
1559
|
+
// same pass.
|
|
1560
|
+
description: "Organization of the record this request is about (falls back to the acting context when the record has none)"
|
|
1427
1561
|
}),
|
|
1428
1562
|
process_name: Field.text({
|
|
1429
1563
|
label: "Source",
|
|
@@ -1442,7 +1576,38 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1442
1576
|
label: "Record ID",
|
|
1443
1577
|
required: true,
|
|
1444
1578
|
maxLength: 100,
|
|
1445
|
-
|
|
1579
|
+
// [#11386] The id half of this object's pointer pair (ADR-0052 §5),
|
|
1580
|
+
// adopting the #11339 carrier. VERIFIED for THIS object: the pair is not
|
|
1581
|
+
// decoration but the key the approval machinery QUERIES ON —
|
|
1582
|
+
// `approval-service.ts` finds a record's pending request with
|
|
1583
|
+
// `where: { object_name, record_id, status: 'pending' }`, and
|
|
1584
|
+
// `lifecycle-hooks.ts` holds the record LOCK on the same pair
|
|
1585
|
+
// (single-record and `$in` batch forms). `submit()` writes it from
|
|
1586
|
+
// `input.object` / `input.recordId`, so a stored value is always a
|
|
1587
|
+
// record id of the object the sibling names.
|
|
1588
|
+
//
|
|
1589
|
+
// Consequence of declaring, sharper here than elsewhere: a seeded
|
|
1590
|
+
// request whose `record_id` stayed a verbatim natural key locked
|
|
1591
|
+
// NOTHING and appeared under no record — it looked like a pending
|
|
1592
|
+
// approval while being invisible to both queries that give the row its
|
|
1593
|
+
// meaning. That is now a loud seed-time refusal. Both halves are
|
|
1594
|
+
// `required: true`, so the un-addressable case (id half authored, type
|
|
1595
|
+
// half empty) is already unreachable on this object.
|
|
1596
|
+
group: "Target",
|
|
1597
|
+
//
|
|
1598
|
+
// ⚠️ ORDERING CONSTRAINT — this id half is `required: true`, and that
|
|
1599
|
+
// makes it ORDER-DEPENDENT in seeds even though a pointer pair
|
|
1600
|
+
// contributes no static ordering edge (#11674, measured against the real
|
|
1601
|
+
// engine in `packages/objectql/src/engine-seed-required-deferral.test.ts`):
|
|
1602
|
+
// the seed loader defers an unresolvable reference by DELETING the column
|
|
1603
|
+
// from the pass-1 insert, required-validation rejects that row, and pass 2
|
|
1604
|
+
// is then left with no row to back-fill. So the pass-2 healing that makes
|
|
1605
|
+
// an OPTIONAL id half order-independent (`sys_audit_log`) does not reach
|
|
1606
|
+
// this one. ⇒ SEED THE TARGET DATASET FIRST. The failure if you do not is
|
|
1607
|
+
// loud in three places — a write error naming this column, a
|
|
1608
|
+
// dropped-deferral error, and `success: false` — and since #11674 the
|
|
1609
|
+
// loader also WARNS at load time, before the engine rejects the row.
|
|
1610
|
+
referenceVia: "object_name"
|
|
1446
1611
|
}),
|
|
1447
1612
|
submitter_id: Field.lookup("sys_user", {
|
|
1448
1613
|
label: "Submitter",
|
|
@@ -1470,6 +1635,29 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1470
1635
|
group: "State"
|
|
1471
1636
|
}
|
|
1472
1637
|
),
|
|
1638
|
+
// [#13568] Why a `cancelled` request was cancelled — the machine-readable
|
|
1639
|
+
// half of the maintainer's 2026-08-31 ruling ("状态转 cancelled + 机器可读
|
|
1640
|
+
// 原因"). Derived from `APPROVAL_CANCEL_REASONS`, never re-typed, on the
|
|
1641
|
+
// same #3786 rule the `status` column above follows.
|
|
1642
|
+
//
|
|
1643
|
+
// On the ROW rather than on the `sys_approval_action` audit entry, because
|
|
1644
|
+
// the readers are the inbox and the tombstone presentation, which page the
|
|
1645
|
+
// request table: making them join the append-only action log to learn why a
|
|
1646
|
+
// row they already hold is cancelled buys nothing and would make the reason
|
|
1647
|
+
// unavailable to a plain list view. The action row still records the event
|
|
1648
|
+
// (`action: 'cancel'`); this column records the STATE.
|
|
1649
|
+
//
|
|
1650
|
+
// Null on every non-cancelled row, and on rows written before the column
|
|
1651
|
+
// existed — "not recorded", never "cancelled for no reason".
|
|
1652
|
+
cancel_reason: Field.select(
|
|
1653
|
+
APPROVAL_CANCEL_REASONS.map((value) => ({ value, label: APPROVAL_CANCEL_REASON_LABELS[value] })),
|
|
1654
|
+
{
|
|
1655
|
+
label: "Cancellation Reason",
|
|
1656
|
+
required: false,
|
|
1657
|
+
description: "Why the platform voided this request (set only when the status is cancelled)",
|
|
1658
|
+
group: "State"
|
|
1659
|
+
}
|
|
1660
|
+
),
|
|
1473
1661
|
current_step: Field.text({
|
|
1474
1662
|
label: "Current Step",
|
|
1475
1663
|
required: false,
|
|
@@ -1489,6 +1677,22 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1489
1677
|
description: "Comma-separated user ids who can act on the current step",
|
|
1490
1678
|
group: "State"
|
|
1491
1679
|
}),
|
|
1680
|
+
// The module docstring above used to justify this column with "used by
|
|
1681
|
+
// notifications so they can render before the record is locked or
|
|
1682
|
+
// changed". That consumer does not exist, and the claim was cited as the
|
|
1683
|
+
// reason the column holds a FULL row before anyone checked it. Measured
|
|
1684
|
+
// against every `this.notify(...)` call site in `approval-service.ts` —
|
|
1685
|
+
// all 12 of them: each passes `{ title, message, actionUrl }` (two also
|
|
1686
|
+
// `actions`), built from `object_name` / `record_id` and the caller's
|
|
1687
|
+
// comment. None reads this column or the parsed `payload`. The real
|
|
1688
|
+
// readers are the serve path (`rowFromRequest` -> `payload`, redacted per
|
|
1689
|
+
// reader), the decide-time approver re-resolution and the org backfill,
|
|
1690
|
+
// both under SYSTEM_CTX, and the free-text predicate.
|
|
1691
|
+
//
|
|
1692
|
+
// The `description` below is deliberately UNCHANGED: it is accurate, and
|
|
1693
|
+
// it is extracted into the four generated i18n bundles as `help`. The
|
|
1694
|
+
// false sentence lived only in the JSDoc above, which is not extracted —
|
|
1695
|
+
// so this correction moves no translation leaf.
|
|
1492
1696
|
payload_json: Field.textarea({
|
|
1493
1697
|
label: "Snapshot",
|
|
1494
1698
|
required: false,
|
|
@@ -1559,11 +1763,14 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1559
1763
|
// per-viewer block (#3310): approver actions on `record.viewer.can_act`
|
|
1560
1764
|
// (the caller is a current pending approver — same check the service
|
|
1561
1765
|
// authorizes a decision with, so position/team approvers resolve correctly),
|
|
1562
|
-
// submitter actions on `record.viewer.is_submitter`. The
|
|
1563
|
-
// (approve/reject/reassign
|
|
1564
|
-
//
|
|
1766
|
+
// submitter actions on `record.viewer.is_submitter`. The four levers the
|
|
1767
|
+
// #3424 override covers (approve/reject/reassign, and recall since #12716)
|
|
1768
|
+
// additionally OR in `record.viewer.can_override`
|
|
1769
|
+
// so a platform/tenant admin can rescue a request routed to an
|
|
1565
1770
|
// unstaffed position — otherwise undecidable, locking the record forever — by
|
|
1566
|
-
// approving, rejecting,
|
|
1771
|
+
// approving, rejecting, reassigning it to a real approver, or recalling it
|
|
1772
|
+
// (the lever that releases the record without recording a decision nobody
|
|
1773
|
+
// made). `viewer` is
|
|
1567
1774
|
// attached by getRequest/listRequests; where it is absent the predicate fails
|
|
1568
1775
|
// closed.
|
|
1569
1776
|
//
|
|
@@ -1699,8 +1906,13 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1699
1906
|
// Remind / recall (pending) and resubmit / recall (returned). These are the
|
|
1700
1907
|
// submitter's own levers, so `visible` gates on `record.viewer.is_submitter`
|
|
1701
1908
|
// (server-computed on the current viewer). The service re-checks ownership;
|
|
1702
|
-
// the predicate keeps a non-submitter from ever seeing a button they
|
|
1703
|
-
// use.
|
|
1909
|
+
// the predicate keeps a plain non-submitter from ever seeing a button they
|
|
1910
|
+
// cannot use.
|
|
1911
|
+
//
|
|
1912
|
+
// `recall` is the one exception, and it is not a widening: it ALSO ORs in
|
|
1913
|
+
// the #3424 admin override (#12716), because an override admin is a caller
|
|
1914
|
+
// `ApprovalService.recall` already authorises. Remind and resubmit keep no
|
|
1915
|
+
// override arm.
|
|
1704
1916
|
{
|
|
1705
1917
|
name: "approval_remind",
|
|
1706
1918
|
label: "Send reminder",
|
|
@@ -1731,7 +1943,26 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1731
1943
|
],
|
|
1732
1944
|
// Recall applies while the request is live for the submitter — pending
|
|
1733
1945
|
// (withdraw) or returned (abandon the revision instead of resubmitting).
|
|
1734
|
-
|
|
1946
|
+
//
|
|
1947
|
+
// The second arm is the #3424 admin override, spelled byte-identically to
|
|
1948
|
+
// the three core decision levers above (#12716). `ApprovalService.recall`
|
|
1949
|
+
// has admitted the override caller since #3424 — `isOverrideActor`'s own
|
|
1950
|
+
// doc block names recall as one of the four levers — so until this arm
|
|
1951
|
+
// landed, recall was the one authorised capability with no button: an
|
|
1952
|
+
// admin could approve or reject their way out of a stuck request (writing
|
|
1953
|
+
// a decision that did not happen) or reassign it, but could not withdraw.
|
|
1954
|
+
//
|
|
1955
|
+
// The override arm carries no status test of its own, on purpose, because
|
|
1956
|
+
// it does not need one and the siblings do not have one either: the flag
|
|
1957
|
+
// is already status-scoped where it is COMPUTED. `attachViewers` in
|
|
1958
|
+
// `approval-service.ts` sets
|
|
1959
|
+
// `can_override: row.status === 'pending' && isOverrideActor(...)` —
|
|
1960
|
+
// ANDed — so `record.viewer.can_override` can never be true off `pending`,
|
|
1961
|
+
// and this arm is pending-only in effect however CEL groups the
|
|
1962
|
+
// expression. Pinned in both directions in
|
|
1963
|
+
// `action-predicate-sparse-face.test.ts`, with the flag's own scoping
|
|
1964
|
+
// pinned against the real service in `approval-revise.test.ts`.
|
|
1965
|
+
visible: 'has(record.status) && (record.status == "pending" || record.status == "returned") && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true || has(record.viewer) && has(record.viewer.can_override) && record.viewer.can_override == true',
|
|
1735
1966
|
locations: ["record_section"],
|
|
1736
1967
|
successMessage: "Recalled.",
|
|
1737
1968
|
refreshAfter: true
|
|
@@ -2092,16 +2323,17 @@ import {
|
|
|
2092
2323
|
normalizeDecisionOutputs
|
|
2093
2324
|
} from "@objectstack/spec/automation";
|
|
2094
2325
|
import { ExpressionEngine, collectCelRootIdentifiers } from "@objectstack/formula";
|
|
2095
|
-
import {
|
|
2326
|
+
import { createRecordOrganizationResolver } from "@objectstack/metadata-core";
|
|
2327
|
+
import { keysetWalk, strandedDecisionFailure } from "@objectstack/types";
|
|
2096
2328
|
import {
|
|
2097
2329
|
ADMIN_FULL_ACCESS,
|
|
2098
2330
|
ORGANIZATION_ADMIN_GRANTS,
|
|
2099
|
-
BUILTIN_IDENTITY_PLATFORM_ADMIN,
|
|
2100
2331
|
BUILTIN_IDENTITY_ORG_OWNER,
|
|
2101
2332
|
BUILTIN_IDENTITY_ORG_ADMIN
|
|
2102
2333
|
} from "@objectstack/spec/identity";
|
|
2103
2334
|
import { RESUME_AUTHORITY_SERVICE } from "@objectstack/spec/contracts";
|
|
2104
2335
|
import { isFileIdToken } from "@objectstack/spec/data";
|
|
2336
|
+
import { renderOperationMessage } from "@objectstack/spec/system";
|
|
2105
2337
|
import { isGrantActive } from "@objectstack/core";
|
|
2106
2338
|
|
|
2107
2339
|
// src/approver-org-scope.ts
|
|
@@ -2257,6 +2489,7 @@ var REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1e3;
|
|
|
2257
2489
|
var ESCALATION_JOB_NAME = "approvals-sla-escalation";
|
|
2258
2490
|
var ESCALATION_SCAN_INTERVAL_MS = 5 * 60 * 1e3;
|
|
2259
2491
|
var SLA_ACTOR_ID = "system:sla";
|
|
2492
|
+
var ESCALATION_ENABLED_FLIP_CUTOFF_MS = Date.parse("2026-08-28T00:00:00Z");
|
|
2260
2493
|
var DEAD_RUN_ACTOR_ID = "system:dead-run";
|
|
2261
2494
|
var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
|
|
2262
2495
|
"completed",
|
|
@@ -2265,8 +2498,71 @@ var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
|
|
|
2265
2498
|
"timed_out"
|
|
2266
2499
|
]);
|
|
2267
2500
|
var STRANDABLE_REQUEST_STATUSES = ["approved", "rejected", "returned"];
|
|
2501
|
+
var STRANDED_CONTINUATION_KEY = "__strandedContinuation";
|
|
2502
|
+
var CONTINUATIONS_A_STATUS_CAN_ISSUE = {
|
|
2503
|
+
approved: ["approve"],
|
|
2504
|
+
rejected: ["reject"],
|
|
2505
|
+
returned: ["revise", "resubmit"],
|
|
2506
|
+
recalled: ["recall"]
|
|
2507
|
+
};
|
|
2508
|
+
function classifyStrandedRunState(run) {
|
|
2509
|
+
if (!run) return "missing";
|
|
2510
|
+
switch (run.status) {
|
|
2511
|
+
// A terminal `failed` row. Reported — and, when the engine can be asked,
|
|
2512
|
+
// refined by the third oracle ({@link refineFailedRunState}) into which of
|
|
2513
|
+
// the three `failed` shapes it is. This arm alone cannot tell them apart:
|
|
2514
|
+
// the row reads identically for a resume that consumed the pause and
|
|
2515
|
+
// threw downstream (repairable) and for an ancestor `failAncestors`
|
|
2516
|
+
// cascade-failed (not). `'failed'` here means "reported, undifferentiated".
|
|
2517
|
+
case "failed":
|
|
2518
|
+
return "failed";
|
|
2519
|
+
// ── The negatives, each for its own reason ──────────────────────────────
|
|
2520
|
+
// The decision advanced the flow and the flow finished. Healthy.
|
|
2521
|
+
case "completed":
|
|
2522
|
+
return void 0;
|
|
2523
|
+
// Deliberately terminated by an operator (`cancelRun`, ADR-0044). The run
|
|
2524
|
+
// stopping is the intended outcome, exactly as `recalled` is on the request
|
|
2525
|
+
// side — reporting it would bury the real findings under expected ones.
|
|
2526
|
+
case "cancelled":
|
|
2527
|
+
return void 0;
|
|
2528
|
+
// The history's last row says `paused` while the suspension store says no
|
|
2529
|
+
// live pause. That is AMBIGUOUS, and the ambiguity is not resolvable from
|
|
2530
|
+
// one scan: a resume in flight right now has consumed the suspension and
|
|
2531
|
+
// not yet written its terminal row, and reads exactly like a process that
|
|
2532
|
+
// died in the same window. Condemning it would name every concurrently
|
|
2533
|
+
// resuming approval — so this stays SKIPPED, the conservative arm this
|
|
2534
|
+
// whole method is built on.
|
|
2535
|
+
case "paused":
|
|
2536
|
+
return void 0;
|
|
2537
|
+
default:
|
|
2538
|
+
return void 0;
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
function refineFailedRunState(verdict) {
|
|
2542
|
+
if (verdict.repairable) return "repairable";
|
|
2543
|
+
switch (verdict.reason) {
|
|
2544
|
+
// The strand happened; the store could not keep the snapshot, and the
|
|
2545
|
+
// engine asked holds no hot copy. Its own class — see the type below.
|
|
2546
|
+
case "SNAPSHOT_DROPPED":
|
|
2547
|
+
return "snapshot_dropped";
|
|
2548
|
+
// Neither witness holds anything: cascade-failed, or never paused.
|
|
2549
|
+
case "NO_CONSUMED_SUSPENSION":
|
|
2550
|
+
return "unrepairable";
|
|
2551
|
+
// Re-armed between the two reads (an operator's restore landed while this
|
|
2552
|
+
// scan was running): the run is alive and resumable, which is what the
|
|
2553
|
+
// first oracle would have said a moment later. Not stranded.
|
|
2554
|
+
case "RUN_SUSPENDED":
|
|
2555
|
+
return void 0;
|
|
2556
|
+
// An answer this build does not know (an engine ahead of this plugin).
|
|
2557
|
+
// Fail-closed exactly as an absent member: reported, undifferentiated —
|
|
2558
|
+
// never condemned on a word this code cannot read.
|
|
2559
|
+
default:
|
|
2560
|
+
return "failed";
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2268
2563
|
var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
|
|
2269
2564
|
var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
|
|
2565
|
+
var RECORD_DELETE_CANCEL_LIMIT = 200;
|
|
2270
2566
|
function actingUserId(context) {
|
|
2271
2567
|
const userId = context?.userId;
|
|
2272
2568
|
return typeof userId === "string" && userId ? userId : null;
|
|
@@ -2327,6 +2623,10 @@ function rowFromRequest(row) {
|
|
|
2327
2623
|
submitter_id: row.submitter_id ?? void 0,
|
|
2328
2624
|
submitter_comment: row.submitter_comment ?? void 0,
|
|
2329
2625
|
status: row.status ?? "pending",
|
|
2626
|
+
// [#13568] Why a cancelled row was cancelled. Surfaced on every read for
|
|
2627
|
+
// the same reason `status` is: a client that can see the terminal state and
|
|
2628
|
+
// not the machine-readable cause is back to guessing from prose.
|
|
2629
|
+
cancel_reason: row.cancel_reason ?? void 0,
|
|
2330
2630
|
current_step: row.current_step ?? void 0,
|
|
2331
2631
|
current_step_index: row.current_step_index ?? void 0,
|
|
2332
2632
|
pending_approvers: csvSplit(row.pending_approvers),
|
|
@@ -2419,6 +2719,7 @@ function rowFromAction(row) {
|
|
|
2419
2719
|
var _ApprovalService = class _ApprovalService {
|
|
2420
2720
|
constructor(opts) {
|
|
2421
2721
|
this.engine = opts.engine;
|
|
2722
|
+
this.recordOrgResolver = createRecordOrganizationResolver(opts.engine);
|
|
2422
2723
|
this.clock = opts.clock ?? { now: () => /* @__PURE__ */ new Date() };
|
|
2423
2724
|
this.logger = opts.logger;
|
|
2424
2725
|
this.automation = opts.automation;
|
|
@@ -2426,6 +2727,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2426
2727
|
this.publicBaseUrl = (opts.publicBaseUrl ?? "").replace(/\/$/, "");
|
|
2427
2728
|
this.tenancyPosture = opts.tenancyPosture;
|
|
2428
2729
|
this.fieldVisibility = opts.fieldVisibility;
|
|
2730
|
+
this.messageTranslator = opts.messageTranslator;
|
|
2429
2731
|
this.recordReaderVisibleObjects = new Set(
|
|
2430
2732
|
(Array.isArray(opts.recordReaderVisibleObjects) ? opts.recordReaderVisibleObjects : []).map((n) => String(n ?? "").trim()).filter(Boolean)
|
|
2431
2733
|
);
|
|
@@ -2571,13 +2873,48 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2571
2873
|
const perms = Array.isArray(context.permissions) ? context.permissions : [];
|
|
2572
2874
|
const positions = Array.isArray(context.positions) ? context.positions : [];
|
|
2573
2875
|
const posture = context.posture;
|
|
2574
|
-
const isPlatformAdmin = posture === "PLATFORM_ADMIN" || perms.includes(ADMIN_FULL_ACCESS)
|
|
2876
|
+
const isPlatformAdmin = posture === "PLATFORM_ADMIN" || perms.includes(ADMIN_FULL_ACCESS);
|
|
2575
2877
|
if (isPlatformAdmin) return true;
|
|
2576
2878
|
const isTenantAdmin = posture === "TENANT_ADMIN" || ORGANIZATION_ADMIN_GRANTS.some((n) => perms.includes(n)) || positions.includes(BUILTIN_IDENTITY_ORG_OWNER) || positions.includes(BUILTIN_IDENTITY_ORG_ADMIN);
|
|
2577
2879
|
if (!isTenantAdmin) return false;
|
|
2578
2880
|
const actorTenant = context.tenantId ?? context.organizationId ?? null;
|
|
2579
2881
|
return requestOrg == null || actorTenant != null && String(requestOrg) === String(actorTenant);
|
|
2580
2882
|
}
|
|
2883
|
+
/**
|
|
2884
|
+
* [#11993] The END USER's half of an approval refusal.
|
|
2885
|
+
*
|
|
2886
|
+
* `handleApprovalError` in `@objectstack/rest` maps this service's
|
|
2887
|
+
* `CODE: message` throws onto the wire by testing the prefix for the status
|
|
2888
|
+
* and then STRIPPING it (a leading run of `[A-Z_]` plus a colon and any
|
|
2889
|
+
* following whitespace), shipping what remains as the body's
|
|
2890
|
+
* human-readable `error` — which Console splices under its own localized
|
|
2891
|
+
* label ("撤回审批失败: …"). A hardcoded English reason therefore reaches an
|
|
2892
|
+
* operator in a fully Chinese deployment as a Chinese prefix glued onto an
|
|
2893
|
+
* English sentence they cannot act on.
|
|
2894
|
+
*
|
|
2895
|
+
* Rendered through the SHARED Operation Message Catalog
|
|
2896
|
+
* (`@objectstack/spec/system`), not a second mechanism: same
|
|
2897
|
+
* `errors.<key>` override address, same resolution ladder (deployment
|
|
2898
|
+
* override -> locale catalog -> `en` -> the key), same guarantee that a
|
|
2899
|
+
* misbehaving i18n service cannot turn a 403 into a 500. The catalog's
|
|
2900
|
+
* header names this seat explicitly; `plugin-security`'s
|
|
2901
|
+
* `userFacingDenialMessage` is the sibling consumer this mirrors.
|
|
2902
|
+
*
|
|
2903
|
+
* The CODE PREFIX stays on the message. It is not user copy — it is how the
|
|
2904
|
+
* REST layer derives the status and the ADR-0112 wire code, and it is
|
|
2905
|
+
* stripped before the sentence reaches a body. The developer's half moves
|
|
2906
|
+
* to the log, where the ids it names are legible to an operator and to
|
|
2907
|
+
* nobody else.
|
|
2908
|
+
*/
|
|
2909
|
+
userFacingRefusal(messageKey, context) {
|
|
2910
|
+
let translate;
|
|
2911
|
+
try {
|
|
2912
|
+
translate = this.messageTranslator?.();
|
|
2913
|
+
} catch {
|
|
2914
|
+
translate = void 0;
|
|
2915
|
+
}
|
|
2916
|
+
return renderOperationMessage({ messageKey }, { locale: context?.locale, translate });
|
|
2917
|
+
}
|
|
2581
2918
|
/**
|
|
2582
2919
|
* Pin the acting identity to the AUTHENTICATED CALLER (#3800).
|
|
2583
2920
|
*
|
|
@@ -3110,7 +3447,56 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3110
3447
|
if (!organizationId) return filter;
|
|
3111
3448
|
return { ...filter, $or: [{ organization_id: organizationId }, { organization_id: null }] };
|
|
3112
3449
|
}
|
|
3113
|
-
/**
|
|
3450
|
+
/**
|
|
3451
|
+
* Tenant scope for the `sys_business_unit_member` read — a STRICT equality,
|
|
3452
|
+
* deliberately NOT {@link businessUnitOrgScope} (#14946).
|
|
3453
|
+
*
|
|
3454
|
+
* The two screens answer different questions. The UNIT is the anchor the
|
|
3455
|
+
* approver NAMES, and a seeded unit carries `organization_id = null` by
|
|
3456
|
+
* construction (a seed cannot know the id the runtime mints at boot), so
|
|
3457
|
+
* #3807 admits the null there on purpose. The MEMBER rows are the SET BEING
|
|
3458
|
+
* ROUTED TO — enumerated by the platform, never named by anyone — and a
|
|
3459
|
+
* seeded unit id exists identically in every tenant. Before this screen the
|
|
3460
|
+
* member read carried no organization predicate at all, under
|
|
3461
|
+
* {@link SYSTEM_CTX} which carries no tenant either, so tenant A's request
|
|
3462
|
+
* resolved the shared unit and then collected EVERY tenant's membership rows
|
|
3463
|
+
* hanging off it: approval authority over A's record, routed to B's users.
|
|
3464
|
+
*
|
|
3465
|
+
* Why the null arm is NOT copied here — measured on this tree:
|
|
3466
|
+
* - `sys_business_unit_member` declares no `organization_id`; the column
|
|
3467
|
+
* is injected (`applySystemFields`) and the tenancy census lists it in;
|
|
3468
|
+
* - REST / session writes fill it (`SqlDriver.injectTenantOnInsert`);
|
|
3469
|
+
* - seed replay does NOT (`seed-loader.ts` withholds its `fallbackOrgId`
|
|
3470
|
+
* from every `sys_` object), and elevated system-context writes do NOT
|
|
3471
|
+
* (`unclassified` in `PLATFORM_OBJECT_TENANCY`, tracked as #14570).
|
|
3472
|
+
* So a NULL on a member row means UNKNOWN tenancy, not "platform-global",
|
|
3473
|
+
* and unknown tenancy is not a member of this organization. This is the
|
|
3474
|
+
* ruling `plugin-sharing`'s `memberScope` already applies to the same rows
|
|
3475
|
+
* (#14547 / #14949), and the posture this file already takes for
|
|
3476
|
+
* `sys_team_member` and `sys_user_position`.
|
|
3477
|
+
*
|
|
3478
|
+
* The cost is declared, not hidden: an organization whose MEMBERSHIP rows
|
|
3479
|
+
* were seeded or system-written expands to nobody even on a unit it can
|
|
3480
|
+
* see. That is not silent — the graph-type fallback in `expandApprover`
|
|
3481
|
+
* warns `expanded to nobody` (#3807) and `onEmptyApprovers` governs the
|
|
3482
|
+
* request as for any unstaffed target — and the repair is to stamp the
|
|
3483
|
+
* membership rows, never to widen this screen. ⛔ Do not "unify" the two
|
|
3484
|
+
* screens: one method serving both re-opens whichever half it does not
|
|
3485
|
+
* implement.
|
|
3486
|
+
*/
|
|
3487
|
+
businessUnitMemberScope(filter, organizationId) {
|
|
3488
|
+
if (!organizationId) return filter;
|
|
3489
|
+
return { ...filter, organization_id: organizationId };
|
|
3490
|
+
}
|
|
3491
|
+
/**
|
|
3492
|
+
* Recursive department — walks `sys_business_unit.parent_business_unit_id`.
|
|
3493
|
+
*
|
|
3494
|
+
* Two tenant screens, and they are different on purpose: the UNIT rows
|
|
3495
|
+
* (seed check and descent) go through the null-inclusive
|
|
3496
|
+
* {@link businessUnitOrgScope}; the MEMBER read goes through the strict
|
|
3497
|
+
* {@link businessUnitMemberScope}. `organizationId` is the DIRECTORY
|
|
3498
|
+
* organization the approver resolves in (ADR-0105 D9), for both.
|
|
3499
|
+
*/
|
|
3114
3500
|
async expandBusinessUnitUsers(businessUnitId, organizationId) {
|
|
3115
3501
|
if (!businessUnitId) return [];
|
|
3116
3502
|
try {
|
|
@@ -3150,7 +3536,11 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3150
3536
|
let rows = [];
|
|
3151
3537
|
try {
|
|
3152
3538
|
rows = await this.engine.find("sys_business_unit_member", {
|
|
3153
|
-
|
|
3539
|
+
// #14946: tenant-screened — {@link businessUnitMemberScope} is STRICT
|
|
3540
|
+
// on purpose and is not {@link businessUnitOrgScope}. The units above
|
|
3541
|
+
// proved their tenancy (or are seeded); these rows have not, and the
|
|
3542
|
+
// shared seeded unit id is exactly where other tenants' rows sit.
|
|
3543
|
+
where: this.businessUnitMemberScope({ business_unit_id: { $in: Array.from(seen) } }, organizationId),
|
|
3154
3544
|
fields: ["user_id"],
|
|
3155
3545
|
limit: 1e4,
|
|
3156
3546
|
context: SYSTEM_CTX2
|
|
@@ -3518,11 +3908,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3518
3908
|
const substitutions = [];
|
|
3519
3909
|
const groups = {};
|
|
3520
3910
|
const liveRecord = await this.loadLiveRecord(input.object, input.recordId, input.record);
|
|
3911
|
+
const subjectOrg = this.recordOrgResolver.organizationOf(input.object, liveRecord, input.record);
|
|
3912
|
+
const requestOrg = subjectOrg ?? ctxOrg;
|
|
3521
3913
|
const resolvedFrom = {};
|
|
3522
3914
|
const approvers = await this.expandApprovers(
|
|
3523
3915
|
{ approvers: input.config.approvers },
|
|
3524
3916
|
liveRecord,
|
|
3525
|
-
|
|
3917
|
+
requestOrg,
|
|
3526
3918
|
{
|
|
3527
3919
|
now: nowDate.getTime(),
|
|
3528
3920
|
substitutions,
|
|
@@ -3586,16 +3978,16 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3586
3978
|
flow_run_id: input.runId,
|
|
3587
3979
|
flow_node_id: input.nodeId,
|
|
3588
3980
|
node_config_json: JSON.stringify(configSnapshot),
|
|
3589
|
-
organization_id:
|
|
3981
|
+
organization_id: requestOrg,
|
|
3590
3982
|
created_at: now,
|
|
3591
3983
|
updated_at: now
|
|
3592
3984
|
};
|
|
3593
3985
|
await this.engine.insert("sys_approval_request", row, { context: SYSTEM_CTX2 });
|
|
3594
|
-
await this.syncApproverIndex(id, approvers,
|
|
3986
|
+
await this.syncApproverIndex(id, approvers, requestOrg, now);
|
|
3595
3987
|
await this.engine.insert("sys_approval_action", {
|
|
3596
3988
|
id: uid("aact"),
|
|
3597
3989
|
request_id: id,
|
|
3598
|
-
organization_id:
|
|
3990
|
+
organization_id: requestOrg,
|
|
3599
3991
|
step_name: input.nodeId,
|
|
3600
3992
|
step_index: 0,
|
|
3601
3993
|
action: "submit",
|
|
@@ -3607,7 +3999,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3607
3999
|
await this.engine.insert("sys_approval_action", {
|
|
3608
4000
|
id: uid("aact"),
|
|
3609
4001
|
request_id: id,
|
|
3610
|
-
organization_id:
|
|
4002
|
+
organization_id: requestOrg,
|
|
3611
4003
|
step_name: input.nodeId,
|
|
3612
4004
|
step_index: 0,
|
|
3613
4005
|
action: "ooo_substitute",
|
|
@@ -3861,6 +4253,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3861
4253
|
`resume of run '${runId}' failed${reported.code ? ` [${reported.code}]` : ""}: ${reported.error ?? "unknown error"}`
|
|
3862
4254
|
);
|
|
3863
4255
|
err.resumeCode = reported.code;
|
|
4256
|
+
err.resumeStatus = reported.status;
|
|
3864
4257
|
throw err;
|
|
3865
4258
|
}
|
|
3866
4259
|
}
|
|
@@ -3868,6 +4261,22 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3868
4261
|
static resumeCodeOf(err) {
|
|
3869
4262
|
return err?.resumeCode;
|
|
3870
4263
|
}
|
|
4264
|
+
/**
|
|
4265
|
+
* The engine's own run-state discriminator behind a {@link serviceResume}
|
|
4266
|
+
* rejection — `AutomationResult.status` — if the engine reported one
|
|
4267
|
+
* (#13807).
|
|
4268
|
+
*
|
|
4269
|
+
* Read as a SIBLING of {@link resumeCodeOf}, never as a substitute: the two
|
|
4270
|
+
* answer different questions and the stranded exit proves they are not
|
|
4271
|
+
* interchangeable. It reports `status: 'stranded'` and **no `code` at all**
|
|
4272
|
+
* (`service-automation` `engine.ts`, the resume catch arm), so a door that
|
|
4273
|
+
* reads only the code sees an unnamed failure and cannot tell a repairable
|
|
4274
|
+
* strand from a dead run — which is how the platform's own repairability
|
|
4275
|
+
* signal had a producer and zero consumers until this call site.
|
|
4276
|
+
*/
|
|
4277
|
+
static resumeStatusOf(err) {
|
|
4278
|
+
return err?.resumeStatus;
|
|
4279
|
+
}
|
|
3871
4280
|
/**
|
|
3872
4281
|
* Refuse an operation whose whole point is to advance a flow run when that
|
|
3873
4282
|
* run no longer exists — BEFORE anything is written down (#4420).
|
|
@@ -3961,10 +4370,33 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3961
4370
|
* which cannot throw without breaking every standalone deployment — it
|
|
3962
4371
|
* reports through `resumeError` instead.
|
|
3963
4372
|
*
|
|
4373
|
+
* ## The throw is truthful, not merely loud (#13807)
|
|
4374
|
+
*
|
|
4375
|
+
* Maintainer ruling 2026-09-04 (decision batch #37, option B): this door
|
|
4376
|
+
* KEEPS its status code — the effect landing while the run strands is still
|
|
4377
|
+
* a failure and must still be reported as one — and stops discarding what
|
|
4378
|
+
* the engine said. ⛔ Not "return 200", which the card forbids; ⛔ not
|
|
4379
|
+
* atomic, because rolling a real human decision back is excluded by the
|
|
4380
|
+
* #13937 shape-4 ruling, which binds this door's own writes too (a machine
|
|
4381
|
+
* that re-armed strandings by itself would re-run the node that threw,
|
|
4382
|
+
* forever, with nobody deciding it should).
|
|
4383
|
+
*
|
|
4384
|
+
* So the error carries {@link StrandedDecisionDetails} beside its prose:
|
|
4385
|
+
* `finalized` (the decision stands), `decision`, `runId`, and `repairable`
|
|
4386
|
+
* derived from the engine's `'stranded'` discriminator. Before this a caller
|
|
4387
|
+
* had a 500 and a sentence — and 500 alone reads as "the rejection did not
|
|
4388
|
+
* happen", which is the misreading that makes a caller retry or escalate
|
|
4389
|
+
* against a decision that IS durable.
|
|
4390
|
+
*
|
|
3964
4391
|
* @param what - how the recorded outcome reads in the error, e.g.
|
|
3965
4392
|
* `"the approve decision"`.
|
|
4393
|
+
* @param decision - the outcome label for the machine-readable envelope
|
|
4394
|
+
* (`'approve'` / `'reject'` / `'revise'` / `'resubmit'`). Passed
|
|
4395
|
+
* explicitly rather than parsed back out of `what` or the signal: the
|
|
4396
|
+
* prose is for humans and `output` is the flow's, and neither is a place
|
|
4397
|
+
* to keep a wire value.
|
|
3966
4398
|
*/
|
|
3967
|
-
async resumeRecordedOutcome(runId, requestId, what, signal) {
|
|
4399
|
+
async resumeRecordedOutcome(runId, requestId, what, signal, decision) {
|
|
3968
4400
|
const missing = this.missingRunCapability(runId, requestId, what, "resume");
|
|
3969
4401
|
if (missing) return { resumed: false, resumeError: missing };
|
|
3970
4402
|
try {
|
|
@@ -3980,14 +4412,27 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3980
4412
|
});
|
|
3981
4413
|
return { resumed: false, resumeError: reason };
|
|
3982
4414
|
}
|
|
4415
|
+
const status = _ApprovalService.resumeStatusOf(err);
|
|
4416
|
+
const repairable = status === "stranded";
|
|
3983
4417
|
this.logger?.error?.("[approvals] resume failed \u2014 the run is stranded", {
|
|
3984
4418
|
request: requestId,
|
|
3985
4419
|
run: runId,
|
|
3986
4420
|
outcome: what,
|
|
3987
|
-
error: reason
|
|
4421
|
+
error: reason,
|
|
4422
|
+
status,
|
|
4423
|
+
repairable
|
|
3988
4424
|
});
|
|
3989
|
-
|
|
3990
|
-
|
|
4425
|
+
if (repairable) {
|
|
4426
|
+
await this.journalStrandedContinuation(requestId, {
|
|
4427
|
+
branchLabel: signal.branchLabel,
|
|
4428
|
+
output: signal.output,
|
|
4429
|
+
decision,
|
|
4430
|
+
what
|
|
4431
|
+
});
|
|
4432
|
+
}
|
|
4433
|
+
throw strandedDecisionFailure(
|
|
4434
|
+
`RESUME_FAILED: ${what} was recorded on request ${requestId}, but its flow run '${runId}' could not be resumed and is now stranded: ${reason}`,
|
|
4435
|
+
{ finalized: true, decision, runId, repairable }
|
|
3991
4436
|
);
|
|
3992
4437
|
}
|
|
3993
4438
|
}
|
|
@@ -4023,7 +4468,8 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4023
4468
|
// Reserved keys are spread LAST so no output can shadow them (the
|
|
4024
4469
|
// whitelist already rejects them; this is defense in depth).
|
|
4025
4470
|
output: { ...result.outputs ?? {}, decision: result.decision, requestId }
|
|
4026
|
-
}
|
|
4471
|
+
},
|
|
4472
|
+
result.decision
|
|
4027
4473
|
);
|
|
4028
4474
|
resumed = outcome.resumed;
|
|
4029
4475
|
resumeError = outcome.resumeError;
|
|
@@ -4038,7 +4484,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4038
4484
|
};
|
|
4039
4485
|
}
|
|
4040
4486
|
/**
|
|
4041
|
-
* Withdraw
|
|
4487
|
+
* Withdraw an undecided request. Finalises the row as
|
|
4042
4488
|
* `recalled`, releases the record lock (keyed on pending status), mirrors
|
|
4043
4489
|
* the status field when configured, and resumes the owning flow run down
|
|
4044
4490
|
* the `reject` branch with `output.decision = 'recall'` — leaving the run
|
|
@@ -4049,6 +4495,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4049
4495
|
* is then paused at the revise-window node (no reject edge), so it is
|
|
4050
4496
|
* terminally cancelled via {@link ApprovalResumeSurface.cancelRun} rather
|
|
4051
4497
|
* than resumed.
|
|
4498
|
+
*
|
|
4499
|
+
* The #3424 privileged override reaches a PENDING request only (#12775,
|
|
4500
|
+
* maintainer ruling 2026-09-02). On `returned` an override actor is refused
|
|
4501
|
+
* exactly as any other non-submitter: the gate is spelled as `attachViewers`
|
|
4502
|
+
* computes `viewer.can_override`, so the gate, the
|
|
4503
|
+
* {@link ApprovalService.isOverrideActor} doc block and the viewer flag agree
|
|
4504
|
+
* at one point.
|
|
4052
4505
|
*/
|
|
4053
4506
|
async recall(requestId, input, context) {
|
|
4054
4507
|
if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
|
|
@@ -4064,8 +4517,18 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4064
4517
|
if (raw.status !== "pending" && !inReviseWindow) {
|
|
4065
4518
|
throw new Error(`INVALID_STATE: request is ${raw.status}`);
|
|
4066
4519
|
}
|
|
4067
|
-
|
|
4068
|
-
|
|
4520
|
+
const overrideAdmits = raw.status === "pending" && this.isOverrideActor(context, raw.organization_id ?? null);
|
|
4521
|
+
if (!overrideAdmits && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
|
|
4522
|
+
const developerMessage = `[approvals] recall refused: actor '${actorId}' is not the submitter of request '${requestId}' (submitter '${String(raw.submitter_id)}') and holds no #3424 override for a '${raw.status}' request (the override reaches pending requests only)`;
|
|
4523
|
+
this.logger?.warn?.(developerMessage, {
|
|
4524
|
+
request: requestId,
|
|
4525
|
+
actor: actorId,
|
|
4526
|
+
submitter: String(raw.submitter_id),
|
|
4527
|
+
status: raw.status
|
|
4528
|
+
});
|
|
4529
|
+
throw new Error(
|
|
4530
|
+
`FORBIDDEN: ${this.userFacingRefusal("approval_recall_not_submitter", context)}`
|
|
4531
|
+
);
|
|
4069
4532
|
}
|
|
4070
4533
|
if (inReviseWindow) await this.assertLatestForRun(raw);
|
|
4071
4534
|
const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
|
|
@@ -4135,12 +4598,141 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4135
4598
|
run: runId,
|
|
4136
4599
|
error: resumeError
|
|
4137
4600
|
});
|
|
4601
|
+
if (_ApprovalService.resumeStatusOf(err) === "stranded") {
|
|
4602
|
+
await this.journalStrandedContinuation(requestId, {
|
|
4603
|
+
branchLabel: APPROVAL_BRANCH_LABELS.reject,
|
|
4604
|
+
output: { decision: "recall", requestId },
|
|
4605
|
+
decision: "recall",
|
|
4606
|
+
what: "the recall"
|
|
4607
|
+
});
|
|
4608
|
+
}
|
|
4138
4609
|
}
|
|
4139
4610
|
}
|
|
4140
4611
|
}
|
|
4141
4612
|
const fresh = await this.readBackRequest(requestId, context);
|
|
4142
4613
|
return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
|
|
4143
4614
|
}
|
|
4615
|
+
// ── Record-delete lifecycle linkage (#13568) ─────────────────
|
|
4616
|
+
/**
|
|
4617
|
+
* Void every `pending` request about a record that has just been DELETED
|
|
4618
|
+
* (#13568, maintainer ruling 2026-08-31 「同意」).
|
|
4619
|
+
*
|
|
4620
|
+
* ## What the ruling asked for, and what each half costs
|
|
4621
|
+
*
|
|
4622
|
+
* status → `cancelled`, a machine-readable `cancel_reason`, the row KEPT
|
|
4623
|
+
* for audit, and the request out of the pending count and the inbox's
|
|
4624
|
+
* default view.
|
|
4625
|
+
*
|
|
4626
|
+
* The last clause needs both writes below, not just the status one: the
|
|
4627
|
+
* inbox's "My Pending" tab pages the request table by `status`, but the
|
|
4628
|
+
* approver filter resolves through the normalized `sys_approval_approver`
|
|
4629
|
+
* index (#1745), which is keyed on nothing but `request_id`. Leaving the
|
|
4630
|
+
* index rows behind would keep the request in `approverRequestIds`' answer
|
|
4631
|
+
* for every approver forever — so {@link syncApproverIndex} clears them, the
|
|
4632
|
+
* same call every other exit from `pending` makes.
|
|
4633
|
+
*
|
|
4634
|
+
* ## Three things this deliberately does NOT do
|
|
4635
|
+
*
|
|
4636
|
+
* 1. **No status mirror-back.** Every other terminal transition calls
|
|
4637
|
+
* {@link mirrorStatusField} to write the outcome onto the subject record's
|
|
4638
|
+
* `approvalStatusField`. Here the subject record is precisely what no
|
|
4639
|
+
* longer exists, so that write is guaranteed to fail — it is the very
|
|
4640
|
+
* `update_record(...) failed: Record ... not found` this card's own
|
|
4641
|
+
* forensics recorded on the reject door. Skipped by construction, not by a
|
|
4642
|
+
* swallowed error.
|
|
4643
|
+
*
|
|
4644
|
+
* 2. **No flow resume, and no run cancel.** A cancellation is a status write
|
|
4645
|
+
* plus a reason; it is not a decision, so there is no branch to resume
|
|
4646
|
+
* down and no downstream node that should run. The suspended run this
|
|
4647
|
+
* request gated is reported (below) and left alone — what becomes of an
|
|
4648
|
+
* approval run whose request was voided is a lifecycle question that
|
|
4649
|
+
* belongs to the automation service, not to a delete hook, and answering
|
|
4650
|
+
* it here by guessing would be exactly the kind of consumer-side
|
|
4651
|
+
* accommodation PD #12 refuses.
|
|
4652
|
+
*
|
|
4653
|
+
* 3. **No refusal of the delete.** The delete has already landed by the time
|
|
4654
|
+
* this runs, and the "forbid delete while an approval is pending"
|
|
4655
|
+
* direction was VETOED in the same ruling (`lockRecord` already blocks the
|
|
4656
|
+
* edit; blocking the delete too locks an author onto a record they cannot
|
|
4657
|
+
* fix). So nothing here throws — a failure degrades to a warning and the
|
|
4658
|
+
* stale row, which is the pre-existing state, never to a failed delete.
|
|
4659
|
+
*
|
|
4660
|
+
* Terminal rows are untouched: the `where` below names `status: 'pending'`,
|
|
4661
|
+
* so an `approved` / `rejected` / `recalled` / `returned` request about the
|
|
4662
|
+
* same record keeps its recorded outcome. That is the ruling's second half —
|
|
4663
|
+
* history is kept; only the presentation of its dead record reference is
|
|
4664
|
+
* someone else's card.
|
|
4665
|
+
*
|
|
4666
|
+
* @returns the request ids cancelled and the suspended run ids left behind.
|
|
4667
|
+
*/
|
|
4668
|
+
async cancelForDeletedRecord(objectName, recordId) {
|
|
4669
|
+
const empty = { cancelled: [], suspendedRuns: [] };
|
|
4670
|
+
const object = String(objectName ?? "").trim();
|
|
4671
|
+
const record = String(recordId ?? "").trim();
|
|
4672
|
+
if (!object || !record) return empty;
|
|
4673
|
+
let pending;
|
|
4674
|
+
try {
|
|
4675
|
+
const rows = await this.engine.find("sys_approval_request", {
|
|
4676
|
+
where: { object_name: object, record_id: record, status: "pending" },
|
|
4677
|
+
limit: RECORD_DELETE_CANCEL_LIMIT,
|
|
4678
|
+
context: SYSTEM_CTX2
|
|
4679
|
+
});
|
|
4680
|
+
pending = Array.isArray(rows) ? rows : [];
|
|
4681
|
+
} catch (err) {
|
|
4682
|
+
this.logger?.warn?.(
|
|
4683
|
+
"[approvals] could not read the pending requests of a deleted record \u2014 they stay in the inbox until the record is deleted again or an operator finalises them",
|
|
4684
|
+
{ object, record, error: err?.message ?? String(err) }
|
|
4685
|
+
);
|
|
4686
|
+
return empty;
|
|
4687
|
+
}
|
|
4688
|
+
if (pending.length === 0) return empty;
|
|
4689
|
+
const now = this.clock.now().toISOString();
|
|
4690
|
+
const cancelled = [];
|
|
4691
|
+
const suspendedRuns = [];
|
|
4692
|
+
for (const raw of pending) {
|
|
4693
|
+
const requestId = String(raw?.id ?? "");
|
|
4694
|
+
if (!requestId) continue;
|
|
4695
|
+
const org = raw?.organization_id ?? null;
|
|
4696
|
+
const nodeId = raw?.flow_node_id ?? raw?.current_step ?? null;
|
|
4697
|
+
try {
|
|
4698
|
+
await this.engine.insert("sys_approval_action", {
|
|
4699
|
+
id: uid("aact"),
|
|
4700
|
+
request_id: requestId,
|
|
4701
|
+
organization_id: org,
|
|
4702
|
+
step_name: nodeId,
|
|
4703
|
+
step_index: 0,
|
|
4704
|
+
action: "cancel",
|
|
4705
|
+
actor_id: null,
|
|
4706
|
+
comment: `Auto-cancelled: the ${object} record '${record}' this request is about was deleted`,
|
|
4707
|
+
created_at: now
|
|
4708
|
+
}, { context: SYSTEM_CTX2 });
|
|
4709
|
+
await this.engine.update("sys_approval_request", {
|
|
4710
|
+
id: requestId,
|
|
4711
|
+
status: "cancelled",
|
|
4712
|
+
cancel_reason: "record_deleted",
|
|
4713
|
+
pending_approvers: null,
|
|
4714
|
+
completed_at: now,
|
|
4715
|
+
updated_at: now
|
|
4716
|
+
}, { context: SYSTEM_CTX2 });
|
|
4717
|
+
await this.syncApproverIndex(requestId, [], org, now);
|
|
4718
|
+
cancelled.push(requestId);
|
|
4719
|
+
const runId = raw?.flow_run_id ? String(raw.flow_run_id) : "";
|
|
4720
|
+
if (runId) suspendedRuns.push(runId);
|
|
4721
|
+
} catch (err) {
|
|
4722
|
+
this.logger?.warn?.(
|
|
4723
|
+
"[approvals] could not auto-cancel a pending request whose record was deleted \u2014 it stays in the inbox pointing at a record that no longer exists",
|
|
4724
|
+
{ object, record, request: requestId, error: err?.message ?? String(err) }
|
|
4725
|
+
);
|
|
4726
|
+
}
|
|
4727
|
+
}
|
|
4728
|
+
if (suspendedRuns.length) {
|
|
4729
|
+
this.logger?.warn?.(
|
|
4730
|
+
"[approvals] auto-cancelled approval request(s) for a deleted record \u2014 the automation run(s) they gated stay suspended and are not resumed by a cancellation",
|
|
4731
|
+
{ object, record, requests: cancelled, runs: suspendedRuns }
|
|
4732
|
+
);
|
|
4733
|
+
}
|
|
4734
|
+
return { cancelled, suspendedRuns };
|
|
4735
|
+
}
|
|
4144
4736
|
// ── Send back for revision / resubmit (ADR-0044) ─────────────
|
|
4145
4737
|
/**
|
|
4146
4738
|
* ADR-0044 send back for revision. Finalises the pending request as
|
|
@@ -4229,7 +4821,8 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4229
4821
|
{
|
|
4230
4822
|
branchLabel: APPROVAL_BRANCH_LABELS.reject,
|
|
4231
4823
|
output: { decision: "reject", autoRejected: true, requestId }
|
|
4232
|
-
}
|
|
4824
|
+
},
|
|
4825
|
+
"reject"
|
|
4233
4826
|
);
|
|
4234
4827
|
resumed2 = outcome.resumed;
|
|
4235
4828
|
resumeError2 = outcome.resumeError;
|
|
@@ -4277,7 +4870,8 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4277
4870
|
{
|
|
4278
4871
|
branchLabel: APPROVAL_BRANCH_LABELS.revise,
|
|
4279
4872
|
output: { decision: "revise", requestId }
|
|
4280
|
-
}
|
|
4873
|
+
},
|
|
4874
|
+
"revise"
|
|
4281
4875
|
);
|
|
4282
4876
|
resumed = outcome.resumed;
|
|
4283
4877
|
resumeError = outcome.resumeError;
|
|
@@ -4357,7 +4951,8 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4357
4951
|
{
|
|
4358
4952
|
branchLabel: APPROVAL_BRANCH_LABELS.resubmit,
|
|
4359
4953
|
output: { resubmitted: true, requestId }
|
|
4360
|
-
}
|
|
4954
|
+
},
|
|
4955
|
+
"resubmit"
|
|
4361
4956
|
);
|
|
4362
4957
|
resumed = outcome.resumed;
|
|
4363
4958
|
resumeError = outcome.resumeError;
|
|
@@ -4771,7 +5366,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4771
5366
|
* `escalation.timeoutHours` and whose deadline has passed is escalated
|
|
4772
5367
|
* **at most once, ever** — the `escalate` audit row is the idempotency
|
|
4773
5368
|
* marker, written before any mutation (audit-first, like reassign). One
|
|
4774
|
-
* bad row never stops the sweep.
|
|
5369
|
+
* bad row never stops the sweep. A block that explicitly declares
|
|
5370
|
+
* `enabled: false` is skipped (#12278 — the declared switch is enforced;
|
|
5371
|
+
* pre-flip snapshots ride the legacy window on
|
|
5372
|
+
* {@link ESCALATION_ENABLED_FLIP_CUTOFF_MS}).
|
|
4775
5373
|
*/
|
|
4776
5374
|
async runEscalations() {
|
|
4777
5375
|
let rows = [];
|
|
@@ -4793,6 +5391,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4793
5391
|
const cfg = parseJson(raw.node_config_json, void 0);
|
|
4794
5392
|
const esc2 = cfg?.escalation;
|
|
4795
5393
|
if (!esc2 || typeof esc2.timeoutHours !== "number" || esc2.timeoutHours <= 0) continue;
|
|
5394
|
+
if (esc2.enabled === false && Date.parse(raw.created_at ?? "") >= ESCALATION_ENABLED_FLIP_CUTOFF_MS) continue;
|
|
4796
5395
|
const due = slaDueAt(raw.created_at, cfg);
|
|
4797
5396
|
if (!due || Date.parse(due) > this.clock.now().getTime()) continue;
|
|
4798
5397
|
const prior = await this.engine.find("sys_approval_action", {
|
|
@@ -4873,9 +5472,52 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4873
5472
|
* live pause exists. It THROWS when the store cannot be read, and that
|
|
4874
5473
|
* case is SKIPPED, never counted as dead: an unreadable store means
|
|
4875
5474
|
* "unknown", and a storage outage must not be published as a lost run.
|
|
4876
|
-
* - `getRun(runId)
|
|
4877
|
-
* prefixed rows in `sys_automation_run`). A run
|
|
4878
|
-
* not stranded; a request whose run neither waits
|
|
5475
|
+
* - `classifyStrandedRunState` over `getRun(runId)` — the run's own
|
|
5476
|
+
* history row (the `run_` prefixed rows in `sys_automation_run`). A run
|
|
5477
|
+
* that merely finished is not stranded; a request whose run neither waits
|
|
5478
|
+
* nor completed is.
|
|
5479
|
+
*
|
|
5480
|
+
* **The second oracle was widened (#13909), and this is the whole point of
|
|
5481
|
+
* that card's first slice.** It used to be `if (terminal) continue` — the
|
|
5482
|
+
* existence of ANY history row ended the check, on the reading "the run ran to
|
|
5483
|
+
* a terminal state, it is not dangling". That is true of a run that COMPLETED
|
|
5484
|
+
* and false of one that FAILED: the engine consumes a suspension *before*
|
|
5485
|
+
* running the downstream nodes (`AutomationEngine.resumeInternal` calls
|
|
5486
|
+
* `forgetSuspendedRun(run, 'resumed')` and only then `traverseNext`), so a
|
|
5487
|
+
* downstream node that merely threw threw with the pause already gone, and the
|
|
5488
|
+
* catch arm wrote a terminal `failed` row. The decision is durable, the
|
|
5489
|
+
* continuation stopped half-way, `resume` answers `RUN_NOT_FOUND` and
|
|
5490
|
+
* `cancelRun` is a no-op — and the terminal row this oracle used to read as
|
|
5491
|
+
* health is written BY the very failure that stranded it. So this inspection
|
|
5492
|
+
* reported `0` for the one shape an operator most needs to see.
|
|
5493
|
+
*
|
|
5494
|
+
* ⚠️ The widening does NOT reverse the conservatism: `completed`, `cancelled`
|
|
5495
|
+
* and `paused` are each still skipped, for reasons named one at a time in
|
|
5496
|
+
* `classifyStrandedRunState`, and an unrecognised status is skipped too.
|
|
5497
|
+
* What the widening buys is that a `failed` run is now reported instead of
|
|
5498
|
+
* counted as healthy.
|
|
5499
|
+
*
|
|
5500
|
+
* **A THIRD oracle tells the `failed` rows apart (#15358).** `status ===
|
|
5501
|
+
* 'failed'` over-reports in one specific direction: a cascade-failed run
|
|
5502
|
+
* — an ancestor `failAncestors` failed while it was parked at its `subflow`
|
|
5503
|
+
* node, whose pause `failSuspendedRun` consumed and journalled nothing — has
|
|
5504
|
+
* the same terminal row as the #13909 strand, and `restoreConsumedSuspension`
|
|
5505
|
+
* refuses it. The engine's discriminator (the consumed-suspension snapshot)
|
|
5506
|
+
* is deliberately NOT on the object `getRun` answers, so it is asked through
|
|
5507
|
+
* a dedicated read-only member, `inspectConsumedSuspension`, and only for
|
|
5508
|
+
* `failed` rows: the answer splits `'failed'` into `'repairable'`,
|
|
5509
|
+
* `'snapshot_dropped'` and `'unrepairable'` (see {@link StrandedRunState}).
|
|
5510
|
+
* A surface without that member leaves the row `'failed'` — reported,
|
|
5511
|
+
* undifferentiated — because absence of the discriminator is not evidence
|
|
5512
|
+
* of anything. So does a read that THREW or answered a malformed verdict
|
|
5513
|
+
* (#16709): by the time this oracle is asked the row is already known to be
|
|
5514
|
+
* stranded, so a failure to differentiate it is not a reason to drop it from
|
|
5515
|
+
* a report — it is counted `undetermined` as telemetry AND reported.
|
|
5516
|
+
*
|
|
5517
|
+
* ⚠️ **What this can and cannot size.** It makes the condition *visible* in a
|
|
5518
|
+
* deployment; it is not itself a census, and it says nothing about this
|
|
5519
|
+
* repository. How many runs are already in this state can only be answered
|
|
5520
|
+
* against a real deployment's tables — see the card.
|
|
4879
5521
|
*
|
|
4880
5522
|
* **Reports; never rewrites.** No status is changed and no run is cancelled.
|
|
4881
5523
|
* The decision genuinely happened — a human approved or rejected — and
|
|
@@ -4933,7 +5575,27 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4933
5575
|
});
|
|
4934
5576
|
continue;
|
|
4935
5577
|
}
|
|
4936
|
-
|
|
5578
|
+
let runState = classifyStrandedRunState(terminal);
|
|
5579
|
+
if (!runState) continue;
|
|
5580
|
+
if (runState === "failed" && typeof this.automation.inspectConsumedSuspension === "function") {
|
|
5581
|
+
let refined;
|
|
5582
|
+
let differentiated = true;
|
|
5583
|
+
try {
|
|
5584
|
+
refined = refineFailedRunState(await this.automation.inspectConsumedSuspension(runId));
|
|
5585
|
+
} catch (err) {
|
|
5586
|
+
differentiated = false;
|
|
5587
|
+
undetermined++;
|
|
5588
|
+
this.logger?.warn?.("[approvals] stranded-request scan could not read the consumed-suspension state", {
|
|
5589
|
+
request: raw?.id,
|
|
5590
|
+
run: runId,
|
|
5591
|
+
error: err?.message ?? String(err)
|
|
5592
|
+
});
|
|
5593
|
+
}
|
|
5594
|
+
if (differentiated) {
|
|
5595
|
+
if (!refined) continue;
|
|
5596
|
+
runState = refined;
|
|
5597
|
+
}
|
|
5598
|
+
}
|
|
4937
5599
|
const config = parseJson(
|
|
4938
5600
|
raw.node_config_json,
|
|
4939
5601
|
{ approvers: [], behavior: "first_response" }
|
|
@@ -4956,6 +5618,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4956
5618
|
requestId: String(raw.id),
|
|
4957
5619
|
status: raw.status,
|
|
4958
5620
|
runId,
|
|
5621
|
+
runState,
|
|
4959
5622
|
flowName: typeof raw.process_name === "string" ? raw.process_name.replace(/^flow:/, "") : void 0,
|
|
4960
5623
|
nodeId: raw.flow_node_id ?? raw.current_step ?? void 0,
|
|
4961
5624
|
objectName: raw.object_name,
|
|
@@ -4967,15 +5630,395 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4967
5630
|
});
|
|
4968
5631
|
}
|
|
4969
5632
|
if (stranded.length || undetermined) {
|
|
4970
|
-
this.logger?.warn?.("[approvals] stranded terminal requests (decision recorded, flow run
|
|
5633
|
+
this.logger?.warn?.("[approvals] stranded terminal requests (decision recorded, flow run unrecoverable)", {
|
|
4971
5634
|
scanned: rows.length,
|
|
4972
5635
|
stranded: stranded.length,
|
|
4973
5636
|
undetermined,
|
|
4974
|
-
|
|
5637
|
+
runMissing: stranded.filter((s) => s.runState === "missing").length,
|
|
5638
|
+
runFailed: stranded.filter((s) => s.runState === "failed").length,
|
|
5639
|
+
runRepairable: stranded.filter((s) => s.runState === "repairable").length,
|
|
5640
|
+
runSnapshotDropped: stranded.filter((s) => s.runState === "snapshot_dropped").length,
|
|
5641
|
+
runUnrepairable: stranded.filter((s) => s.runState === "unrepairable").length,
|
|
5642
|
+
requests: stranded.map((s) => `${s.requestId}@${s.nodeId ?? "?"} \u2192 run ${s.runId} (${s.runState})`)
|
|
4975
5643
|
});
|
|
4976
5644
|
}
|
|
4977
5645
|
return { scanned: rows.length, stranded, undetermined };
|
|
4978
5646
|
}
|
|
5647
|
+
/**
|
|
5648
|
+
* Stash the continuation a door just failed to deliver, so it can be issued
|
|
5649
|
+
* again after the pause is re-armed (#15389).
|
|
5650
|
+
*
|
|
5651
|
+
* `AutomationEngine.restoreConsumedSuspension` puts a stranded approval run
|
|
5652
|
+
* back on its pause and tells the operator to *re-issue the continuation* —
|
|
5653
|
+
* but for an `approval` node the only issuers are this service's doors, and
|
|
5654
|
+
* every one of them guards on a `pending` request that the stranding call
|
|
5655
|
+
* itself just made terminal. Re-opening the row is excluded (it would let a
|
|
5656
|
+
* decided request be decided again), so what is kept instead is the SIGNAL:
|
|
5657
|
+
* the exact `branchLabel` + `output` the failed resume carried.
|
|
5658
|
+
*
|
|
5659
|
+
* ⚠️ Best-effort by construction, and it must stay that way: the decision is
|
|
5660
|
+
* already durable and its caller is already owed a `RESUME_FAILED` throw. A
|
|
5661
|
+
* failure to write recovery bookkeeping must not replace that throw with a
|
|
5662
|
+
* storage error — {@link ApprovalService.continueRestoredRun} rebuilds the
|
|
5663
|
+
* signal from the row when the stash is absent, so this failing costs
|
|
5664
|
+
* fidelity on one shape, not the repair path.
|
|
5665
|
+
*/
|
|
5666
|
+
async journalStrandedContinuation(requestId, signal) {
|
|
5667
|
+
try {
|
|
5668
|
+
const rows = await this.engine.find("sys_approval_request", {
|
|
5669
|
+
where: { id: requestId },
|
|
5670
|
+
limit: 1,
|
|
5671
|
+
context: SYSTEM_CTX2
|
|
5672
|
+
});
|
|
5673
|
+
const raw = Array.isArray(rows) ? rows[0] : null;
|
|
5674
|
+
if (!raw) return;
|
|
5675
|
+
const config = parseJson(raw.node_config_json, {});
|
|
5676
|
+
await this.engine.update("sys_approval_request", {
|
|
5677
|
+
id: requestId,
|
|
5678
|
+
node_config_json: JSON.stringify({ ...config, [STRANDED_CONTINUATION_KEY]: signal })
|
|
5679
|
+
}, { context: SYSTEM_CTX2 });
|
|
5680
|
+
} catch (err) {
|
|
5681
|
+
this.logger?.warn?.(
|
|
5682
|
+
"[approvals] could not journal the stranded continuation \u2014 the repair path falls back to rebuilding it from the row",
|
|
5683
|
+
{ request: requestId, error: err?.message ?? String(err) }
|
|
5684
|
+
);
|
|
5685
|
+
}
|
|
5686
|
+
}
|
|
5687
|
+
/**
|
|
5688
|
+
* The continuation to re-issue for a request whose recorded outcome stranded
|
|
5689
|
+
* its run — the journalled one when there is one, otherwise rebuilt from the
|
|
5690
|
+
* row (#15389).
|
|
5691
|
+
*
|
|
5692
|
+
* ## Why a rebuild path exists at all
|
|
5693
|
+
*
|
|
5694
|
+
* The journal only covers runs stranded by a build that HAS it. The card is
|
|
5695
|
+
* explicitly about *"the runs already in this state"*, and one of those can
|
|
5696
|
+
* still be restored whenever the durable run-history row carried its
|
|
5697
|
+
* suspension snapshot — so a repair verb that only served future strands
|
|
5698
|
+
* would miss the population the card was filed for.
|
|
5699
|
+
*
|
|
5700
|
+
* ## Which statuses it rebuilds, which it discriminates, and which it refuses
|
|
5701
|
+
*
|
|
5702
|
+
* ⛔ A status is NOT the same thing as a continuation. Three of the four
|
|
5703
|
+
* terminal statuses have more than one writer or more than one issuer, so
|
|
5704
|
+
* "one status, one signal" is false and is not what this relies on. Each row
|
|
5705
|
+
* below states its own population and its own discriminator:
|
|
5706
|
+
*
|
|
5707
|
+
* | status | writers / issuers | rebuilt as | how it is decided |
|
|
5708
|
+
* |---|---|---|---|
|
|
5709
|
+
* | `approved` | 1 (`decide`; escalation auto-approve routes through it) | `approve` | unambiguous |
|
|
5710
|
+
* | `rejected` | 2 (`decide`; ADR-0044 revision-limit auto-reject) | `reject`, or REFUSED | a `revise` action row means the auto-reject arm is possible |
|
|
5711
|
+
* | `returned` | 1 writer, 2 issuers (`sendBack` → `revise`; a later `resubmit` → `resubmit`, writing no status) | `resubmit` or `revise` | a `resubmit` action row, whose sole writer is `resubmit` |
|
|
5712
|
+
* | `recalled` | 2 writers, 3 behaviours, 2 issuing NO continuation | REFUSED | nothing on the row distinguishes them |
|
|
5713
|
+
*
|
|
5714
|
+
* ⚠️ **Both refusals are deliberate and neither is best-effort.** The failure
|
|
5715
|
+
* mode of a wrong rebuild is a flow advanced down a branch nobody chose —
|
|
5716
|
+
* strictly worse than the dead end this verb exists to open. Where the signal
|
|
5717
|
+
* cannot be proved, this refuses and names what the operator can do instead;
|
|
5718
|
+
* the journal is what makes both shapes recoverable going forward.
|
|
5719
|
+
*
|
|
5720
|
+
* ⛔ `pending` and `cancelled` are refused outright: neither names a recorded
|
|
5721
|
+
* outcome to replay. A `pending` request's continuation is an ordinary
|
|
5722
|
+
* decision through the front door, which is exactly the guard this verb
|
|
5723
|
+
* exists to avoid weakening.
|
|
5724
|
+
*/
|
|
5725
|
+
async resolveRecordedContinuation(raw, requestId) {
|
|
5726
|
+
const config = parseJson(raw.node_config_json, {});
|
|
5727
|
+
const status = String(raw.status ?? "");
|
|
5728
|
+
const stashed = config?.[STRANDED_CONTINUATION_KEY];
|
|
5729
|
+
if (stashed && typeof stashed === "object" && typeof stashed.decision === "string") {
|
|
5730
|
+
const issuable = CONTINUATIONS_A_STATUS_CAN_ISSUE[status];
|
|
5731
|
+
const journalled = String(stashed.decision);
|
|
5732
|
+
if (!issuable?.includes(journalled)) {
|
|
5733
|
+
throw new Error(
|
|
5734
|
+
`INVALID_STATE: request ${requestId} is '${status || "unknown"}' and its journalled continuation is the ${journalled}, which a '${status || "unknown"}' request cannot have issued \u2014 ${issuable ? `a '${status}' row is replayable only for ${issuable.map((d) => `'${d}'`).join(" or ")}` : `no continuation is replayable for '${status || "unknown"}'`}. The journal records what the last FAILED resume was carrying, so a later recall (or any other door that moved this row on) leaves a signal behind that the row's own status no longer stands behind, and replaying it would advance a step nobody is waiting on. Refusing to replay it: cancel the run with the engine's cancelRun('${raw.flow_run_id}') if the newer outcome should stand, or resume it by hand with the signal the flow expects.`
|
|
5735
|
+
);
|
|
5736
|
+
}
|
|
5737
|
+
return { signal: stashed, source: "journal" };
|
|
5738
|
+
}
|
|
5739
|
+
const outputs = { ...config?.__decisionOutputs ?? {} };
|
|
5740
|
+
if (status === "approved" || status === "rejected") {
|
|
5741
|
+
if (status === "rejected") {
|
|
5742
|
+
const priorRevise = await this.engine.find("sys_approval_action", {
|
|
5743
|
+
where: { request_id: requestId, action: "revise" },
|
|
5744
|
+
limit: 1,
|
|
5745
|
+
context: SYSTEM_CTX2
|
|
5746
|
+
});
|
|
5747
|
+
if (Array.isArray(priorRevise) && priorRevise.length) {
|
|
5748
|
+
throw new Error(
|
|
5749
|
+
`INVALID_STATE: request ${requestId} is 'rejected' and also carries a 'revise' action, so this service cannot tell a decided rejection from an ADR-0044 revision-limit auto-rejection \u2014 and the two resume the same edge with different flow output (\`autoRejected\`). Refusing to guess: replay it by hand with the signal the flow expects, or cancel the run.`
|
|
5750
|
+
);
|
|
5751
|
+
}
|
|
5752
|
+
}
|
|
5753
|
+
const decision = status === "approved" ? "approve" : "reject";
|
|
5754
|
+
return {
|
|
5755
|
+
source: "reconstructed",
|
|
5756
|
+
signal: {
|
|
5757
|
+
branchLabel: status === "approved" ? APPROVAL_BRANCH_LABELS.approve : APPROVAL_BRANCH_LABELS.reject,
|
|
5758
|
+
output: { ...outputs, decision, requestId },
|
|
5759
|
+
decision,
|
|
5760
|
+
what: `the ${decision} decision`
|
|
5761
|
+
}
|
|
5762
|
+
};
|
|
5763
|
+
}
|
|
5764
|
+
if (status === "returned") {
|
|
5765
|
+
const resubmitted = await this.engine.find("sys_approval_action", {
|
|
5766
|
+
where: { request_id: requestId, action: "resubmit" },
|
|
5767
|
+
limit: 1,
|
|
5768
|
+
context: SYSTEM_CTX2
|
|
5769
|
+
});
|
|
5770
|
+
if (Array.isArray(resubmitted) && resubmitted.length) {
|
|
5771
|
+
return {
|
|
5772
|
+
source: "reconstructed",
|
|
5773
|
+
signal: {
|
|
5774
|
+
branchLabel: APPROVAL_BRANCH_LABELS.resubmit,
|
|
5775
|
+
output: { resubmitted: true, requestId },
|
|
5776
|
+
decision: "resubmit",
|
|
5777
|
+
what: "the resubmit"
|
|
5778
|
+
}
|
|
5779
|
+
};
|
|
5780
|
+
}
|
|
5781
|
+
return {
|
|
5782
|
+
source: "reconstructed",
|
|
5783
|
+
signal: {
|
|
5784
|
+
branchLabel: APPROVAL_BRANCH_LABELS.revise,
|
|
5785
|
+
output: { decision: "revise", requestId },
|
|
5786
|
+
decision: "revise",
|
|
5787
|
+
what: "the send-back"
|
|
5788
|
+
}
|
|
5789
|
+
};
|
|
5790
|
+
}
|
|
5791
|
+
if (status === "recalled") {
|
|
5792
|
+
throw new Error(
|
|
5793
|
+
`INVALID_STATE: request ${requestId} is 'recalled' and carries no journalled continuation, so the signal cannot be rebuilt: a recall reaches this state three ways (resumed down 'reject', terminally cancelled inside a revision window, or swept as a dead run) and two of them issue no continuation at all \u2014 replaying the wrong one would re-open a request that was deliberately withdrawn. Refusing to guess: cancel the run with the engine's cancelRun('${raw.flow_run_id}') if the withdrawal should stand, or resume it by hand with the signal the flow expects.`
|
|
5794
|
+
);
|
|
5795
|
+
}
|
|
5796
|
+
throw new Error(
|
|
5797
|
+
`INVALID_STATE: request is ${status || "unknown"} \u2014 only a request whose recorded outcome already resumed its run can have that continuation re-issued (approved, rejected, returned, recalled)`
|
|
5798
|
+
);
|
|
5799
|
+
}
|
|
5800
|
+
/**
|
|
5801
|
+
* WHERE the pause a recorded continuation was refused on actually sits
|
|
5802
|
+
* (#15389) — the expected node guard 3 compares the run's parked node against.
|
|
5803
|
+
*
|
|
5804
|
+
* ⚠️ This is signal-aware, and that is the whole point of it. "This request's
|
|
5805
|
+
* own node" is the right answer for three of the four signals and the WRONG
|
|
5806
|
+
* answer for the fourth:
|
|
5807
|
+
*
|
|
5808
|
+
* | signal | issued from | why |
|
|
5809
|
+
* |---|---|---|
|
|
5810
|
+
* | `approve` / `reject` | the request's own approval node | the decision is taken at the pause it gates |
|
|
5811
|
+
* | `revise` (send-back) | the request's own approval node | send-back resumes that same pause down the `revise` edge |
|
|
5812
|
+
* | `recall` | the request's own approval node | recall-on-pending resumes that same pause down `reject` |
|
|
5813
|
+
* | `resubmit` | the **revise window** the request's `revise` edge leads to | by construction: a resubmit is only reachable AFTER a send-back moved the run there, and it resumes THAT pause down the `resubmit` back-edge |
|
|
5814
|
+
*
|
|
5815
|
+
* Measured before this existed: a `returned` row whose resubmit stranded was
|
|
5816
|
+
* refused by guard 3 on both the journal and the rebuild paths — the pause
|
|
5817
|
+
* re-armed at the revise window while the row's `flow_node_id` still read the
|
|
5818
|
+
* approval node — and the refusal told the operator the pause was not this
|
|
5819
|
+
* request's when it was exactly this request's. A refusal may ship; a refusal
|
|
5820
|
+
* that names a cause the code did not take may not.
|
|
5821
|
+
*
|
|
5822
|
+
* ⛔ It stays FAIL-CLOSED: the revise window is derived from the flow
|
|
5823
|
+
* definition the same way {@link ApprovalService.assertReviseEdge} derives it
|
|
5824
|
+
* — a `revise` out-edge of this request's node into a node the flow declares
|
|
5825
|
+
* as `{@link APPROVAL_REVISE_NODE_TYPE}`, which is the pause only this service
|
|
5826
|
+
* can continue. No engine, no flow, no such edge, or more than one candidate
|
|
5827
|
+
* ⇒ refuse. It needs no automation surface `assertReviseEdge` did not already
|
|
5828
|
+
* use (`getFlow`), and no engine change.
|
|
5829
|
+
*
|
|
5830
|
+
* ⚠️ It does not widen what guard 3 admits beyond that one signal: for every
|
|
5831
|
+
* other decision the answer is byte-identical to the row's own node.
|
|
5832
|
+
*
|
|
5833
|
+
* ⛔ It is NOT what keeps the recall-in-revise-window shape (row `recalled`,
|
|
5834
|
+
* run at the revise window) refused, and an earlier revision of this comment
|
|
5835
|
+
* claimed it was — on the reasoning that such a row's journalled signal is
|
|
5836
|
+
* `recall` rather than `resubmit`. That is false: a recall taken inside the
|
|
5837
|
+
* revise window calls `cancelRun` and journals NOTHING, so the journal on
|
|
5838
|
+
* such a row is whatever an EARLIER strand left there — a `resubmit`, most
|
|
5839
|
+
* often, since the resubmit is what the window exists to receive. Measured:
|
|
5840
|
+
* with the journal returned before the row's status was looked at, that
|
|
5841
|
+
* stale `resubmit` reached this method, was answered with the revise window,
|
|
5842
|
+
* matched the parked node, and opened a fresh `pending` round on a withdrawn
|
|
5843
|
+
* request. What refuses it is the journal/status compatibility check in
|
|
5844
|
+
* {@link ApprovalService.resolveRecordedContinuation} — see
|
|
5845
|
+
* {@link CONTINUATIONS_A_STATUS_CAN_ISSUE} — which runs BEFORE this method
|
|
5846
|
+
* and never hands it a signal the row's status cannot have issued.
|
|
5847
|
+
*/
|
|
5848
|
+
async expectedPauseNode(raw, signal, requestId, runId) {
|
|
5849
|
+
const ownNode = raw.flow_node_id ?? raw.current_step ?? null;
|
|
5850
|
+
if (!ownNode) {
|
|
5851
|
+
throw new Error(
|
|
5852
|
+
`INVALID_STATE: request ${requestId} records no approval node, so the pause on run '${runId}' cannot be proved to be the one ${signal.what} was refused on \u2014 refusing rather than resuming a pause that may belong to another node`
|
|
5853
|
+
);
|
|
5854
|
+
}
|
|
5855
|
+
if (signal.decision !== "resubmit") {
|
|
5856
|
+
return { nodeId: ownNode, describe: `its own approval node '${ownNode}'` };
|
|
5857
|
+
}
|
|
5858
|
+
const processName = String(raw.process_name ?? "");
|
|
5859
|
+
const flowName = processName.startsWith("flow:") ? processName.slice("flow:".length) : "";
|
|
5860
|
+
if (!flowName || typeof this.automation?.getFlow !== "function") {
|
|
5861
|
+
throw new Error(
|
|
5862
|
+
`INVALID_STATE: ${signal.what} on request ${requestId} was issued from the revise window that approval node '${ownNode}' sends back to, and this service cannot read the owning flow definition to say which node that is \u2014 refusing, because continuing a pause it cannot identify advances a step nobody decided`
|
|
5863
|
+
);
|
|
5864
|
+
}
|
|
5865
|
+
const flow = await this.automation.getFlow(flowName);
|
|
5866
|
+
const nodeTypeById = new Map(
|
|
5867
|
+
(Array.isArray(flow?.nodes) ? flow.nodes : []).filter((n) => typeof n?.id === "string").map((n) => [n.id, typeof n.type === "string" ? n.type : ""])
|
|
5868
|
+
);
|
|
5869
|
+
const windows = Array.from(new Set(
|
|
5870
|
+
(Array.isArray(flow?.edges) ? flow.edges : []).filter((e) => e?.source === ownNode && e?.label === APPROVAL_BRANCH_LABELS.revise).map((e) => typeof e?.target === "string" ? e.target : "").filter((t) => t && nodeTypeById.get(t) === APPROVAL_REVISE_NODE_TYPE)
|
|
5871
|
+
));
|
|
5872
|
+
if (windows.length !== 1) {
|
|
5873
|
+
throw new Error(
|
|
5874
|
+
`INVALID_STATE: ${signal.what} on request ${requestId} was issued from the revise window that approval node '${ownNode}' sends back to, and flow '${flowName}' declares ${windows.length === 0 ? "no such window" : `${windows.length} of them (${windows.join(", ")})`} \u2014 refusing, because a pause this service cannot identify must not be continued`
|
|
5875
|
+
);
|
|
5876
|
+
}
|
|
5877
|
+
return {
|
|
5878
|
+
nodeId: windows[0],
|
|
5879
|
+
describe: `the revise window '${windows[0]}' that its approval node '${ownNode}' sends back to`
|
|
5880
|
+
};
|
|
5881
|
+
}
|
|
5882
|
+
/**
|
|
5883
|
+
* Re-issue the continuation for a run an operator has re-armed with
|
|
5884
|
+
* `AutomationEngine.restoreConsumedSuspension` — the missing half of that
|
|
5885
|
+
* repair verb, for approvals (#15389).
|
|
5886
|
+
*
|
|
5887
|
+
* ## The dead end this exits
|
|
5888
|
+
*
|
|
5889
|
+
* A decision whose downstream node throws strands the run: the suspension is
|
|
5890
|
+
* consumed, the decision is durable, and the caller gets `RESUME_FAILED`
|
|
5891
|
+
* carrying `repairable: true`. `restoreConsumedSuspension` then genuinely
|
|
5892
|
+
* re-arms the pause — measured `restored: true`, `hasSuspendedRun` back to
|
|
5893
|
+
* `true` — and its own reason string tells the operator to *re-issue the
|
|
5894
|
+
* continuation*. For an `approval` node there was then nobody who could:
|
|
5895
|
+
*
|
|
5896
|
+
* - `decide` / `recall` / `sendBack` / `resubmit` all guard on a `pending`
|
|
5897
|
+
* request, and the row is terminal — written by the very call that
|
|
5898
|
+
* stranded the run;
|
|
5899
|
+
* - the generic `engine.resume` refuses, because the `approval` node
|
|
5900
|
+
* declares `resumeAuthority: 'service'` and the #3801 gate turns away any
|
|
5901
|
+
* resume that is not the tail of a decision this service authorized.
|
|
5902
|
+
*
|
|
5903
|
+
* So the only verb left was `cancelRun`, which discards the branch's
|
|
5904
|
+
* downstream work. Measured on the real engine and the real door: the
|
|
5905
|
+
* restored pause IS resumable, and a `resumeAuthority`-marked resume walks
|
|
5906
|
+
* the reject branch to completion. Nothing was missing in the engine — what
|
|
5907
|
+
* was missing was an ISSUER on this side. This is that issuer.
|
|
5908
|
+
*
|
|
5909
|
+
* ## What it deliberately does NOT do
|
|
5910
|
+
*
|
|
5911
|
+
* ⛔ It does not re-open, re-decide, or rewrite the request row: all four
|
|
5912
|
+
* `pending` guards stay exactly as they are, and no status, mirror field or
|
|
5913
|
+
* audit row is written. A person decided this once; this replays what they
|
|
5914
|
+
* decided onto the pause that was put back, and replays nothing else.
|
|
5915
|
+
* ⛔ It does not relax `resumeAuthority: 'service'` — the resume goes through
|
|
5916
|
+
* {@link ApprovalService.serviceResume} like every other, so the marker is
|
|
5917
|
+
* still stamped in exactly one place.
|
|
5918
|
+
* ⛔ It grants no capability that in-process code did not already have:
|
|
5919
|
+
* `RESUME_AUTHORITY_SERVICE` is importable by anything in the host, so the
|
|
5920
|
+
* raw form of this call was always available. What this adds is the GUARDED
|
|
5921
|
+
* form, and the guards are the substance of it — three, each with its own
|
|
5922
|
+
* reverse-control pin, because the raw marker is not a guard and an
|
|
5923
|
+
* unguarded repair verb advances flows nobody decided:
|
|
5924
|
+
*
|
|
5925
|
+
* 1. {@link assertLatestForRun} — this request is still the newest on its
|
|
5926
|
+
* run, so a superseded row cannot drive a later round or a later node;
|
|
5927
|
+
* 2. `hasSuspendedRun` — a pause exists at all (strict: an unreadable store
|
|
5928
|
+
* throws rather than reading as "not suspended");
|
|
5929
|
+
* 3. node identity — that pause is parked where THIS request's recorded
|
|
5930
|
+
* outcome was issued from: its own approval node for `approve`,
|
|
5931
|
+
* `reject`, `revise` and `recall`, and — for a `resubmit`, which is only
|
|
5932
|
+
* reachable from a revise window — the `approval_revise` node its own
|
|
5933
|
+
* `revise` edge leads to. {@link ApprovalService.expectedPauseNode}
|
|
5934
|
+
* derives it, fail-closed.
|
|
5935
|
+
*
|
|
5936
|
+
* Guard 3 is not redundant with guard 2: existence is not identity, and a
|
|
5937
|
+
* boolean cannot tell this request's re-armed pause from any other live
|
|
5938
|
+
* pause on the same run.
|
|
5939
|
+
*
|
|
5940
|
+
* ## Posture, and why it takes no `ExecutionContext`
|
|
5941
|
+
*
|
|
5942
|
+
* Deliberately shaped like the engine verb it completes: an in-process
|
|
5943
|
+
* operator repair, reachable from a host or a console script, with no REST
|
|
5944
|
+
* route and no entry in the spec `ApprovalService` contract — exactly as
|
|
5945
|
+
* `restoreConsumedSuspension` is a class method on `AutomationEngine` and
|
|
5946
|
+
* appears in no contract. It authorizes nothing new: the decision it replays
|
|
5947
|
+
* was authorized and recorded when it was made, and re-authorizing it here
|
|
5948
|
+
* against a present-day actor would be a different and wrong question (the
|
|
5949
|
+
* original approver may be long gone). `requestedBy` / `reason` ride the log
|
|
5950
|
+
* for the same reason they do on the restore.
|
|
5951
|
+
*
|
|
5952
|
+
* @returns what was replayed and whether the run moved — never a silent
|
|
5953
|
+
* `false`. A resume that fails again throws the same `RESUME_FAILED`
|
|
5954
|
+
* envelope the original decision did, `repairable` and all, so a second
|
|
5955
|
+
* restore-and-continue is possible.
|
|
5956
|
+
*/
|
|
5957
|
+
async continueRestoredRun(requestId, options) {
|
|
5958
|
+
if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
|
|
5959
|
+
const rows = await this.engine.find("sys_approval_request", {
|
|
5960
|
+
where: { id: requestId },
|
|
5961
|
+
limit: 1,
|
|
5962
|
+
context: SYSTEM_CTX2
|
|
5963
|
+
});
|
|
5964
|
+
const raw = Array.isArray(rows) ? rows[0] : null;
|
|
5965
|
+
if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
5966
|
+
const runId = raw.flow_run_id ?? null;
|
|
5967
|
+
if (!runId) {
|
|
5968
|
+
throw new Error(
|
|
5969
|
+
`INVALID_STATE: request ${requestId} names no flow run \u2014 there is no continuation to re-issue`
|
|
5970
|
+
);
|
|
5971
|
+
}
|
|
5972
|
+
await this.assertLatestForRun(raw);
|
|
5973
|
+
const { signal, source } = await this.resolveRecordedContinuation(raw, requestId);
|
|
5974
|
+
if (typeof this.automation?.hasSuspendedRun === "function") {
|
|
5975
|
+
const parked = await this.automation.hasSuspendedRun(runId);
|
|
5976
|
+
if (!parked) {
|
|
5977
|
+
throw new Error(
|
|
5978
|
+
`INVALID_STATE: run '${runId}' behind request ${requestId} is not suspended, so there is no re-armed pause to continue \u2014 restore it first with the automation engine's restoreConsumedSuspension('${runId}'), which is what re-arms a consumed approval suspension`
|
|
5979
|
+
);
|
|
5980
|
+
}
|
|
5981
|
+
}
|
|
5982
|
+
const expected = await this.expectedPauseNode(raw, signal, requestId, runId);
|
|
5983
|
+
if (typeof this.automation?.listSuspendedRunsDurable !== "function") {
|
|
5984
|
+
throw new Error(
|
|
5985
|
+
`INVALID_STATE: this automation engine cannot report WHERE run '${runId}' is parked (no listSuspendedRunsDurable), so the pause cannot be proved to be the one ${signal.what} on request ${requestId} was refused on \u2014 refusing, because continuing the wrong pause advances a flow with no decision behind it`
|
|
5986
|
+
);
|
|
5987
|
+
}
|
|
5988
|
+
const parkedAt = (await this.automation.listSuspendedRunsDurable()).find((r) => String(r.runId) === String(runId))?.nodeId;
|
|
5989
|
+
if (parkedAt !== expected.nodeId) {
|
|
5990
|
+
throw new Error(
|
|
5991
|
+
`INVALID_STATE: run '${runId}' is parked at ${parkedAt ? `node '${parkedAt}'` : "no node this engine can see"}, but ${signal.what} on request ${requestId} was issued from ${expected.describe} \u2014 so the pause this verb was asked to continue is not the one that outcome was issued at, and continuing it would advance a step nobody decided`
|
|
5992
|
+
);
|
|
5993
|
+
}
|
|
5994
|
+
this.logger?.warn?.(
|
|
5995
|
+
"[approvals] re-issuing the continuation for a restored approval suspension",
|
|
5996
|
+
{
|
|
5997
|
+
request: requestId,
|
|
5998
|
+
run: runId,
|
|
5999
|
+
decision: signal.decision,
|
|
6000
|
+
branchLabel: signal.branchLabel,
|
|
6001
|
+
source,
|
|
6002
|
+
requestedBy: options?.requestedBy ?? "not recorded",
|
|
6003
|
+
reason: options?.reason ?? "not recorded"
|
|
6004
|
+
}
|
|
6005
|
+
);
|
|
6006
|
+
const outcome = await this.resumeRecordedOutcome(
|
|
6007
|
+
runId,
|
|
6008
|
+
requestId,
|
|
6009
|
+
signal.what,
|
|
6010
|
+
{ branchLabel: signal.branchLabel, output: signal.output },
|
|
6011
|
+
signal.decision
|
|
6012
|
+
);
|
|
6013
|
+
return {
|
|
6014
|
+
resumed: outcome.resumed,
|
|
6015
|
+
runId,
|
|
6016
|
+
decision: signal.decision,
|
|
6017
|
+
branchLabel: signal.branchLabel,
|
|
6018
|
+
source,
|
|
6019
|
+
...outcome.resumeError ? { resumeError: outcome.resumeError } : {}
|
|
6020
|
+
};
|
|
6021
|
+
}
|
|
4979
6022
|
async releaseDeadRunRequests() {
|
|
4980
6023
|
if (typeof this.automation?.getRun !== "function") return { scanned: 0, released: 0 };
|
|
4981
6024
|
let rows = [];
|
|
@@ -5825,9 +6868,28 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5825
6868
|
* flow-driven resume, a service-to-service call), turning a successful write
|
|
5826
6869
|
* into a `null` result. Gating belongs on the read API, not on an
|
|
5827
6870
|
* operation's own return value.
|
|
6871
|
+
*
|
|
6872
|
+
* [#12769] The read still narrows by the CALLER's organization
|
|
6873
|
+
* (`loadRequest` — a deliberate tenancy wall, untouched here), and an
|
|
6874
|
+
* org-less request row is invisible inside an org-scoped caller's narrowing.
|
|
6875
|
+
* Org-less rows arise by construction on every schedule / time-relative /
|
|
6876
|
+
* api trigger run (#10131; #9132 pinned that behaviour rather than repairing
|
|
6877
|
+
* it), so this is a live state, not an edge case: the write IS recorded and
|
|
6878
|
+
* the echo cannot be built. It used to escape as `null` behind the callers'
|
|
6879
|
+
* `fresh!` non-null assertions — a well-formed 200 whose declared-non-null
|
|
6880
|
+
* `request` was `null` on the wire. Now it refuses loudly instead, which
|
|
6881
|
+
* keeps every declared result type true: a result that cannot be built is
|
|
6882
|
+
* never returned. Callers must NOT catch this to fall back to `null` —
|
|
6883
|
+
* that would re-open the type-lie one level up.
|
|
5828
6884
|
*/
|
|
5829
6885
|
async readBackRequest(requestId, context) {
|
|
5830
|
-
|
|
6886
|
+
const fresh = await this.loadRequest(requestId, context, false);
|
|
6887
|
+
if (!fresh) {
|
|
6888
|
+
throw new Error(
|
|
6889
|
+
`READ_BACK_FAILED: the write to approval request '${requestId}' was recorded, but the updated row is not visible inside the caller's organization scope, so the result envelope cannot be built. The write is NOT rolled back \u2014 read the request back with a system or matching-organization context.`
|
|
6890
|
+
);
|
|
6891
|
+
}
|
|
6892
|
+
return fresh;
|
|
5831
6893
|
}
|
|
5832
6894
|
async getRequest(requestId, context) {
|
|
5833
6895
|
return this.loadRequest(requestId, context, true);
|
|
@@ -6319,6 +7381,41 @@ function bindApprovalLockHook(engine, logger) {
|
|
|
6319
7381
|
}, { packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
|
|
6320
7382
|
logger?.info?.("[approvals] record-lock hook bound");
|
|
6321
7383
|
}
|
|
7384
|
+
var APPROVALS_OWN_OBJECTS = [
|
|
7385
|
+
"sys_approval_request",
|
|
7386
|
+
"sys_approval_action",
|
|
7387
|
+
"sys_approval_approver",
|
|
7388
|
+
"sys_approval_token",
|
|
7389
|
+
"sys_approval_delegation"
|
|
7390
|
+
];
|
|
7391
|
+
function bindRecordDeleteCancelHook(engine, service, logger) {
|
|
7392
|
+
engine.registerHook("afterDelete", async (ctx) => {
|
|
7393
|
+
const object = ctx?.object ?? ctx?.objectName;
|
|
7394
|
+
if (!object) return;
|
|
7395
|
+
const raw = ctx?.previous?.id ?? ctx?.input?.id;
|
|
7396
|
+
const recordId = raw == null ? "" : String(raw);
|
|
7397
|
+
if (!recordId) {
|
|
7398
|
+
logger?.warn?.(
|
|
7399
|
+
"[approvals] a delete reached the approval-cancel hook with no record id \u2014 pending approvals for the deleted record (if any) stay in the inbox",
|
|
7400
|
+
{ object }
|
|
7401
|
+
);
|
|
7402
|
+
return;
|
|
7403
|
+
}
|
|
7404
|
+
try {
|
|
7405
|
+
await service.cancelForDeletedRecord(String(object), recordId);
|
|
7406
|
+
} catch (err) {
|
|
7407
|
+
logger?.warn?.(
|
|
7408
|
+
"[approvals] the approval-cancel linkage failed for a deleted record \u2014 its pending approvals stay in the inbox pointing at a record that no longer exists",
|
|
7409
|
+
{ object, record: recordId, error: err?.message ?? String(err) }
|
|
7410
|
+
);
|
|
7411
|
+
}
|
|
7412
|
+
}, {
|
|
7413
|
+
excludeObjects: [...APPROVALS_OWN_OBJECTS],
|
|
7414
|
+
packageId: APPROVALS_HOOK_PACKAGE,
|
|
7415
|
+
priority: 50
|
|
7416
|
+
});
|
|
7417
|
+
logger?.info?.("[approvals] record-delete cancel hook bound");
|
|
7418
|
+
}
|
|
6322
7419
|
var DELEGATION_OBJECT = "sys_approval_delegation";
|
|
6323
7420
|
function bindDelegationWriteGuard(engine, logger) {
|
|
6324
7421
|
const makeGuard = (isInsert) => async (ctx) => {
|
|
@@ -6670,6 +7767,23 @@ var ApprovalsServicePlugin = class {
|
|
|
6670
7767
|
} catch {
|
|
6671
7768
|
return void 0;
|
|
6672
7769
|
}
|
|
7770
|
+
},
|
|
7771
|
+
// [#11993] Deployment override lookup for user-facing refusal copy.
|
|
7772
|
+
// Resolved LAZILY for the reason the two providers above are: the i18n
|
|
7773
|
+
// service is contributed by another plugin (ADR-0029 D8) and may start
|
|
7774
|
+
// after this one. Without it the service still renders the built-in
|
|
7775
|
+
// catalog in the caller's locale; with it, a deployment's own
|
|
7776
|
+
// `translation` for `errors.approval_recall_not_submitter` wins — the
|
|
7777
|
+
// override address the catalog documents, made real for this emitter.
|
|
7778
|
+
messageTranslator: () => {
|
|
7779
|
+
try {
|
|
7780
|
+
const i18n = ctx.getService("i18n");
|
|
7781
|
+
const t = i18n?.t;
|
|
7782
|
+
if (typeof t !== "function") return void 0;
|
|
7783
|
+
return (key, locale, params) => t.call(i18n, key, locale, params);
|
|
7784
|
+
} catch {
|
|
7785
|
+
return void 0;
|
|
7786
|
+
}
|
|
6673
7787
|
}
|
|
6674
7788
|
});
|
|
6675
7789
|
const fieldVisibility = () => {
|
|
@@ -6690,6 +7804,7 @@ var ApprovalsServicePlugin = class {
|
|
|
6690
7804
|
try {
|
|
6691
7805
|
unbindAllHooks(engine);
|
|
6692
7806
|
bindApprovalLockHook(engine, ctx.logger);
|
|
7807
|
+
bindRecordDeleteCancelHook(engine, this.service, ctx.logger);
|
|
6693
7808
|
bindDelegationWriteGuard(engine, ctx.logger);
|
|
6694
7809
|
bindSnapshotRedactionMiddleware(engine, fieldVisibility, ctx.logger);
|
|
6695
7810
|
} catch (err) {
|