@objectstack/plugin-approvals 17.3.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 +253 -0
- package/dist/index.d.mts +890 -348
- package/dist/index.d.ts +890 -348
- package/dist/index.js +582 -16
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +583 -18
- package/dist/index.mjs.map +1 -1
- package/package.json +17 -12
package/dist/index.mjs
CHANGED
|
@@ -2324,11 +2324,10 @@ import {
|
|
|
2324
2324
|
} from "@objectstack/spec/automation";
|
|
2325
2325
|
import { ExpressionEngine, collectCelRootIdentifiers } from "@objectstack/formula";
|
|
2326
2326
|
import { createRecordOrganizationResolver } from "@objectstack/metadata-core";
|
|
2327
|
-
import { keysetWalk } from "@objectstack/types";
|
|
2327
|
+
import { keysetWalk, strandedDecisionFailure } from "@objectstack/types";
|
|
2328
2328
|
import {
|
|
2329
2329
|
ADMIN_FULL_ACCESS,
|
|
2330
2330
|
ORGANIZATION_ADMIN_GRANTS,
|
|
2331
|
-
BUILTIN_IDENTITY_PLATFORM_ADMIN,
|
|
2332
2331
|
BUILTIN_IDENTITY_ORG_OWNER,
|
|
2333
2332
|
BUILTIN_IDENTITY_ORG_ADMIN
|
|
2334
2333
|
} from "@objectstack/spec/identity";
|
|
@@ -2499,10 +2498,22 @@ var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
|
|
|
2499
2498
|
"timed_out"
|
|
2500
2499
|
]);
|
|
2501
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
|
+
};
|
|
2502
2508
|
function classifyStrandedRunState(run) {
|
|
2503
2509
|
if (!run) return "missing";
|
|
2504
2510
|
switch (run.status) {
|
|
2505
|
-
//
|
|
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".
|
|
2506
2517
|
case "failed":
|
|
2507
2518
|
return "failed";
|
|
2508
2519
|
// ── The negatives, each for its own reason ──────────────────────────────
|
|
@@ -2527,6 +2538,28 @@ function classifyStrandedRunState(run) {
|
|
|
2527
2538
|
return void 0;
|
|
2528
2539
|
}
|
|
2529
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
|
+
}
|
|
2530
2563
|
var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
|
|
2531
2564
|
var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
|
|
2532
2565
|
var RECORD_DELETE_CANCEL_LIMIT = 200;
|
|
@@ -2840,7 +2873,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2840
2873
|
const perms = Array.isArray(context.permissions) ? context.permissions : [];
|
|
2841
2874
|
const positions = Array.isArray(context.positions) ? context.positions : [];
|
|
2842
2875
|
const posture = context.posture;
|
|
2843
|
-
const isPlatformAdmin = posture === "PLATFORM_ADMIN" || perms.includes(ADMIN_FULL_ACCESS)
|
|
2876
|
+
const isPlatformAdmin = posture === "PLATFORM_ADMIN" || perms.includes(ADMIN_FULL_ACCESS);
|
|
2844
2877
|
if (isPlatformAdmin) return true;
|
|
2845
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);
|
|
2846
2879
|
if (!isTenantAdmin) return false;
|
|
@@ -3414,7 +3447,56 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3414
3447
|
if (!organizationId) return filter;
|
|
3415
3448
|
return { ...filter, $or: [{ organization_id: organizationId }, { organization_id: null }] };
|
|
3416
3449
|
}
|
|
3417
|
-
/**
|
|
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
|
+
*/
|
|
3418
3500
|
async expandBusinessUnitUsers(businessUnitId, organizationId) {
|
|
3419
3501
|
if (!businessUnitId) return [];
|
|
3420
3502
|
try {
|
|
@@ -3454,7 +3536,11 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3454
3536
|
let rows = [];
|
|
3455
3537
|
try {
|
|
3456
3538
|
rows = await this.engine.find("sys_business_unit_member", {
|
|
3457
|
-
|
|
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),
|
|
3458
3544
|
fields: ["user_id"],
|
|
3459
3545
|
limit: 1e4,
|
|
3460
3546
|
context: SYSTEM_CTX2
|
|
@@ -4167,6 +4253,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4167
4253
|
`resume of run '${runId}' failed${reported.code ? ` [${reported.code}]` : ""}: ${reported.error ?? "unknown error"}`
|
|
4168
4254
|
);
|
|
4169
4255
|
err.resumeCode = reported.code;
|
|
4256
|
+
err.resumeStatus = reported.status;
|
|
4170
4257
|
throw err;
|
|
4171
4258
|
}
|
|
4172
4259
|
}
|
|
@@ -4174,6 +4261,22 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4174
4261
|
static resumeCodeOf(err) {
|
|
4175
4262
|
return err?.resumeCode;
|
|
4176
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
|
+
}
|
|
4177
4280
|
/**
|
|
4178
4281
|
* Refuse an operation whose whole point is to advance a flow run when that
|
|
4179
4282
|
* run no longer exists — BEFORE anything is written down (#4420).
|
|
@@ -4267,10 +4370,33 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4267
4370
|
* which cannot throw without breaking every standalone deployment — it
|
|
4268
4371
|
* reports through `resumeError` instead.
|
|
4269
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
|
+
*
|
|
4270
4391
|
* @param what - how the recorded outcome reads in the error, e.g.
|
|
4271
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.
|
|
4272
4398
|
*/
|
|
4273
|
-
async resumeRecordedOutcome(runId, requestId, what, signal) {
|
|
4399
|
+
async resumeRecordedOutcome(runId, requestId, what, signal, decision) {
|
|
4274
4400
|
const missing = this.missingRunCapability(runId, requestId, what, "resume");
|
|
4275
4401
|
if (missing) return { resumed: false, resumeError: missing };
|
|
4276
4402
|
try {
|
|
@@ -4286,14 +4412,27 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4286
4412
|
});
|
|
4287
4413
|
return { resumed: false, resumeError: reason };
|
|
4288
4414
|
}
|
|
4415
|
+
const status = _ApprovalService.resumeStatusOf(err);
|
|
4416
|
+
const repairable = status === "stranded";
|
|
4289
4417
|
this.logger?.error?.("[approvals] resume failed \u2014 the run is stranded", {
|
|
4290
4418
|
request: requestId,
|
|
4291
4419
|
run: runId,
|
|
4292
4420
|
outcome: what,
|
|
4293
|
-
error: reason
|
|
4421
|
+
error: reason,
|
|
4422
|
+
status,
|
|
4423
|
+
repairable
|
|
4294
4424
|
});
|
|
4295
|
-
|
|
4296
|
-
|
|
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 }
|
|
4297
4436
|
);
|
|
4298
4437
|
}
|
|
4299
4438
|
}
|
|
@@ -4329,7 +4468,8 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4329
4468
|
// Reserved keys are spread LAST so no output can shadow them (the
|
|
4330
4469
|
// whitelist already rejects them; this is defense in depth).
|
|
4331
4470
|
output: { ...result.outputs ?? {}, decision: result.decision, requestId }
|
|
4332
|
-
}
|
|
4471
|
+
},
|
|
4472
|
+
result.decision
|
|
4333
4473
|
);
|
|
4334
4474
|
resumed = outcome.resumed;
|
|
4335
4475
|
resumeError = outcome.resumeError;
|
|
@@ -4344,7 +4484,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4344
4484
|
};
|
|
4345
4485
|
}
|
|
4346
4486
|
/**
|
|
4347
|
-
* Withdraw
|
|
4487
|
+
* Withdraw an undecided request. Finalises the row as
|
|
4348
4488
|
* `recalled`, releases the record lock (keyed on pending status), mirrors
|
|
4349
4489
|
* the status field when configured, and resumes the owning flow run down
|
|
4350
4490
|
* the `reject` branch with `output.decision = 'recall'` — leaving the run
|
|
@@ -4458,6 +4598,14 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4458
4598
|
run: runId,
|
|
4459
4599
|
error: resumeError
|
|
4460
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
|
+
}
|
|
4461
4609
|
}
|
|
4462
4610
|
}
|
|
4463
4611
|
}
|
|
@@ -4673,7 +4821,8 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4673
4821
|
{
|
|
4674
4822
|
branchLabel: APPROVAL_BRANCH_LABELS.reject,
|
|
4675
4823
|
output: { decision: "reject", autoRejected: true, requestId }
|
|
4676
|
-
}
|
|
4824
|
+
},
|
|
4825
|
+
"reject"
|
|
4677
4826
|
);
|
|
4678
4827
|
resumed2 = outcome.resumed;
|
|
4679
4828
|
resumeError2 = outcome.resumeError;
|
|
@@ -4721,7 +4870,8 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4721
4870
|
{
|
|
4722
4871
|
branchLabel: APPROVAL_BRANCH_LABELS.revise,
|
|
4723
4872
|
output: { decision: "revise", requestId }
|
|
4724
|
-
}
|
|
4873
|
+
},
|
|
4874
|
+
"revise"
|
|
4725
4875
|
);
|
|
4726
4876
|
resumed = outcome.resumed;
|
|
4727
4877
|
resumeError = outcome.resumeError;
|
|
@@ -4801,7 +4951,8 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4801
4951
|
{
|
|
4802
4952
|
branchLabel: APPROVAL_BRANCH_LABELS.resubmit,
|
|
4803
4953
|
output: { resubmitted: true, requestId }
|
|
4804
|
-
}
|
|
4954
|
+
},
|
|
4955
|
+
"resubmit"
|
|
4805
4956
|
);
|
|
4806
4957
|
resumed = outcome.resumed;
|
|
4807
4958
|
resumeError = outcome.resumeError;
|
|
@@ -5343,8 +5494,25 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5343
5494
|
* ⚠️ The widening does NOT reverse the conservatism: `completed`, `cancelled`
|
|
5344
5495
|
* and `paused` are each still skipped, for reasons named one at a time in
|
|
5345
5496
|
* `classifyStrandedRunState`, and an unrecognised status is skipped too.
|
|
5346
|
-
* What the widening buys is that a `failed` run is now reported
|
|
5347
|
-
*
|
|
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.
|
|
5348
5516
|
*
|
|
5349
5517
|
* ⚠️ **What this can and cannot size.** It makes the condition *visible* in a
|
|
5350
5518
|
* deployment; it is not itself a census, and it says nothing about this
|
|
@@ -5407,8 +5575,27 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5407
5575
|
});
|
|
5408
5576
|
continue;
|
|
5409
5577
|
}
|
|
5410
|
-
|
|
5578
|
+
let runState = classifyStrandedRunState(terminal);
|
|
5411
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
|
+
}
|
|
5412
5599
|
const config = parseJson(
|
|
5413
5600
|
raw.node_config_json,
|
|
5414
5601
|
{ approvers: [], behavior: "first_response" }
|
|
@@ -5449,11 +5636,389 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5449
5636
|
undetermined,
|
|
5450
5637
|
runMissing: stranded.filter((s) => s.runState === "missing").length,
|
|
5451
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,
|
|
5452
5642
|
requests: stranded.map((s) => `${s.requestId}@${s.nodeId ?? "?"} \u2192 run ${s.runId} (${s.runState})`)
|
|
5453
5643
|
});
|
|
5454
5644
|
}
|
|
5455
5645
|
return { scanned: rows.length, stranded, undetermined };
|
|
5456
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
|
+
}
|
|
5457
6022
|
async releaseDeadRunRequests() {
|
|
5458
6023
|
if (typeof this.automation?.getRun !== "function") return { scanned: 0, released: 0 };
|
|
5459
6024
|
let rows = [];
|