@objectstack/plugin-approvals 17.0.0-rc.3 → 17.0.0-rc.4
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 +278 -8
- package/dist/index.d.mts +176 -95
- package/dist/index.d.ts +176 -95
- package/dist/index.js +157 -39
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +155 -35
- package/dist/index.mjs.map +1 -1
- package/package.json +11 -10
package/dist/index.mjs
CHANGED
|
@@ -2013,6 +2013,7 @@ var SysApprovalDelegation = ObjectSchema4.create({
|
|
|
2013
2013
|
import { createHash, randomBytes } from "crypto";
|
|
2014
2014
|
import {
|
|
2015
2015
|
APPROVAL_BRANCH_LABELS,
|
|
2016
|
+
APPROVAL_REVISE_NODE_TYPE,
|
|
2016
2017
|
approverTypeIsOrgScoped,
|
|
2017
2018
|
canonicalApproverType,
|
|
2018
2019
|
normalizeDecisionOutputs
|
|
@@ -3431,6 +3432,47 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3431
3432
|
);
|
|
3432
3433
|
}
|
|
3433
3434
|
}
|
|
3435
|
+
/**
|
|
3436
|
+
* The run named by a recorded outcome cannot be advanced at all, because no
|
|
3437
|
+
* automation engine in THIS process implements the capability it needs
|
|
3438
|
+
* (#4420). Returns the reason, or `undefined` when the capability is there
|
|
3439
|
+
* and the caller should proceed.
|
|
3440
|
+
*
|
|
3441
|
+
* **Why this is not simply "not our problem".** Approvals is legitimately
|
|
3442
|
+
* usable with no engine attached, and {@link assertRunResumable} deliberately
|
|
3443
|
+
* stays out of the way for that reason. But "no engine is attached" and "no
|
|
3444
|
+
* run is waiting" are different facts, and only the second is benign: a
|
|
3445
|
+
* `flow_run_id` on the row is the request's OWN declaration that a run is
|
|
3446
|
+
* parked on this decision. Deciding it in a process that cannot resume it
|
|
3447
|
+
* reproduces #4420's reported half-state exactly — a durable decision, a
|
|
3448
|
+
* mirrored status field frozen mid-workflow, a flow parked forever — while
|
|
3449
|
+
* the caller is answered HTTP 200. The engine-less composition is the one
|
|
3450
|
+
* path the #4420 fix left silent, because every guard it added hangs off an
|
|
3451
|
+
* engine that is not there.
|
|
3452
|
+
*
|
|
3453
|
+
* **So the outcome stands, but it is never silent.** Rolling the decision
|
|
3454
|
+
* back is not on the table (a human really did decide, and the row is
|
|
3455
|
+
* already durable), and refusing every such call would break the standalone
|
|
3456
|
+
* compositions the pre-flight protects. What is owed is the report: `error`
|
|
3457
|
+
* level per AGENTS.md's durability rule — persisted state and runtime state
|
|
3458
|
+
* disagree and nothing looks broken from the outside — plus a `resumeError`
|
|
3459
|
+
* on the response, so `resumed: false` carries its reason instead of leaving
|
|
3460
|
+
* the caller to guess whether a resume was even attempted.
|
|
3461
|
+
*
|
|
3462
|
+
* Reuses the registered `RESUME_FAILED` code (ADR-0112 ledger) and
|
|
3463
|
+
* {@link serviceResume}'s message shape: the fact being reported — an
|
|
3464
|
+
* outcome recorded whose run did not advance — is the same one, and this
|
|
3465
|
+
* needs no new vocabulary of its own.
|
|
3466
|
+
*/
|
|
3467
|
+
missingRunCapability(runId, requestId, what, capability) {
|
|
3468
|
+
const fn = capability === "resume" ? this.automation?.resume : this.automation?.cancelRun;
|
|
3469
|
+
if (typeof fn === "function") return void 0;
|
|
3470
|
+
this.logger?.error?.(
|
|
3471
|
+
"[approvals] no automation engine to advance the recorded outcome \u2014 the run is stranded",
|
|
3472
|
+
{ request: requestId, run: runId, outcome: what, capability }
|
|
3473
|
+
);
|
|
3474
|
+
return `${capability} of run '${runId}' failed [RESUME_FAILED]: ${what} was recorded on request ${requestId}, but no automation engine in this process can ${capability} its flow run \u2014 the run stays parked and the record's mirrored status will not advance. Compose the automation service in this host, or recall the request to release the record.`;
|
|
3475
|
+
}
|
|
3434
3476
|
/**
|
|
3435
3477
|
* Resume the run behind an outcome that has ALREADY been written down, and
|
|
3436
3478
|
* fail loudly when it cannot be (#4420).
|
|
@@ -3442,12 +3484,17 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3442
3484
|
* everything it catches never reaches a write.
|
|
3443
3485
|
*
|
|
3444
3486
|
* `RESUME_IN_PROGRESS` is the exception — a concurrent resume is already
|
|
3445
|
-
* advancing the run, so the outcome stands and only `resumed` is false.
|
|
3487
|
+
* advancing the run, so the outcome stands and only `resumed` is false. So
|
|
3488
|
+
* is a composition with no engine at all ({@link missingRunCapability}),
|
|
3489
|
+
* which cannot throw without breaking every standalone deployment — it
|
|
3490
|
+
* reports through `resumeError` instead.
|
|
3446
3491
|
*
|
|
3447
3492
|
* @param what - how the recorded outcome reads in the error, e.g.
|
|
3448
3493
|
* `"the approve decision"`.
|
|
3449
3494
|
*/
|
|
3450
3495
|
async resumeRecordedOutcome(runId, requestId, what, signal) {
|
|
3496
|
+
const missing = this.missingRunCapability(runId, requestId, what, "resume");
|
|
3497
|
+
if (missing) return { resumed: false, resumeError: missing };
|
|
3451
3498
|
try {
|
|
3452
3499
|
await this.serviceResume(runId, signal);
|
|
3453
3500
|
return { resumed: true };
|
|
@@ -3490,7 +3537,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3490
3537
|
const result = await this.decideNode(requestId, input, context);
|
|
3491
3538
|
let resumed = false;
|
|
3492
3539
|
let resumeError;
|
|
3493
|
-
if (result.finalized && result.runId
|
|
3540
|
+
if (result.finalized && result.runId) {
|
|
3494
3541
|
const branchLabel = result.decision === "approve" ? APPROVAL_BRANCH_LABELS.approve : APPROVAL_BRANCH_LABELS.reject;
|
|
3495
3542
|
const outcome = await this.resumeRecordedOutcome(
|
|
3496
3543
|
result.runId,
|
|
@@ -3527,7 +3574,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3527
3574
|
*
|
|
3528
3575
|
* ADR-0044: also valid on the LATEST `returned` request of its run — the
|
|
3529
3576
|
* submitter abandons the revision window instead of resubmitting. The run
|
|
3530
|
-
* is then paused at the revise
|
|
3577
|
+
* is then paused at the revise-window node (no reject edge), so it is
|
|
3531
3578
|
* terminally cancelled via {@link ApprovalResumeSurface.cancelRun} rather
|
|
3532
3579
|
* than resumed.
|
|
3533
3580
|
*/
|
|
@@ -3585,33 +3632,39 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3585
3632
|
let resumed = false;
|
|
3586
3633
|
let resumeError;
|
|
3587
3634
|
if (inReviseWindow) {
|
|
3588
|
-
if (runId
|
|
3635
|
+
if (runId) {
|
|
3636
|
+
resumeError = this.missingRunCapability(runId, requestId, "the recall", "cancelRun");
|
|
3637
|
+
if (!resumeError) {
|
|
3638
|
+
try {
|
|
3639
|
+
await this.automation.cancelRun(runId, `approval request ${requestId} recalled during revision`);
|
|
3640
|
+
} catch (err) {
|
|
3641
|
+
resumeError = err?.message ?? String(err);
|
|
3642
|
+
this.logger?.error?.("[approvals] cancelRun after revise-window recall failed \u2014 the run may be stranded", {
|
|
3643
|
+
request: requestId,
|
|
3644
|
+
run: runId,
|
|
3645
|
+
error: resumeError
|
|
3646
|
+
});
|
|
3647
|
+
}
|
|
3648
|
+
}
|
|
3649
|
+
}
|
|
3650
|
+
} else if (runId) {
|
|
3651
|
+
resumeError = this.missingRunCapability(runId, requestId, "the recall", "resume");
|
|
3652
|
+
if (!resumeError) {
|
|
3589
3653
|
try {
|
|
3590
|
-
await this.
|
|
3654
|
+
await this.serviceResume(runId, {
|
|
3655
|
+
branchLabel: APPROVAL_BRANCH_LABELS.reject,
|
|
3656
|
+
output: { decision: "recall", requestId }
|
|
3657
|
+
});
|
|
3658
|
+
resumed = true;
|
|
3591
3659
|
} catch (err) {
|
|
3592
3660
|
resumeError = err?.message ?? String(err);
|
|
3593
|
-
this.logger?.error?.("[approvals]
|
|
3661
|
+
this.logger?.error?.("[approvals] resume after recall failed \u2014 the run may be stranded", {
|
|
3594
3662
|
request: requestId,
|
|
3595
3663
|
run: runId,
|
|
3596
3664
|
error: resumeError
|
|
3597
3665
|
});
|
|
3598
3666
|
}
|
|
3599
3667
|
}
|
|
3600
|
-
} else if (runId && typeof this.automation?.resume === "function") {
|
|
3601
|
-
try {
|
|
3602
|
-
await this.serviceResume(runId, {
|
|
3603
|
-
branchLabel: APPROVAL_BRANCH_LABELS.reject,
|
|
3604
|
-
output: { decision: "recall", requestId }
|
|
3605
|
-
});
|
|
3606
|
-
resumed = true;
|
|
3607
|
-
} catch (err) {
|
|
3608
|
-
resumeError = err?.message ?? String(err);
|
|
3609
|
-
this.logger?.error?.("[approvals] resume after recall failed \u2014 the run may be stranded", {
|
|
3610
|
-
request: requestId,
|
|
3611
|
-
run: runId,
|
|
3612
|
-
error: resumeError
|
|
3613
|
-
});
|
|
3614
|
-
}
|
|
3615
3668
|
}
|
|
3616
3669
|
const fresh = await this.readBackRequest(requestId, context);
|
|
3617
3670
|
return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
|
|
@@ -3621,7 +3674,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3621
3674
|
* ADR-0044 send back for revision. Finalises the pending request as
|
|
3622
3675
|
* `returned` (a third terminal state — approver-initiated rework, distinct
|
|
3623
3676
|
* from submitter-initiated `recalled`) and resumes the owning flow run down
|
|
3624
|
-
* its `revise` edge to
|
|
3677
|
+
* its `revise` edge to the revise window: the record lock (keyed on `pending`)
|
|
3625
3678
|
* releases, the submitter reworks the data, then {@link resubmit}s.
|
|
3626
3679
|
*
|
|
3627
3680
|
* Requires the approval node to declare a `revise` out-edge — validated
|
|
@@ -3696,7 +3749,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3696
3749
|
}
|
|
3697
3750
|
let resumed2 = false;
|
|
3698
3751
|
let resumeError2;
|
|
3699
|
-
if (runId
|
|
3752
|
+
if (runId) {
|
|
3700
3753
|
const outcome = await this.resumeRecordedOutcome(
|
|
3701
3754
|
runId,
|
|
3702
3755
|
requestId,
|
|
@@ -3744,7 +3797,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3744
3797
|
}
|
|
3745
3798
|
let resumed = false;
|
|
3746
3799
|
let resumeError;
|
|
3747
|
-
if (runId
|
|
3800
|
+
if (runId) {
|
|
3748
3801
|
const outcome = await this.resumeRecordedOutcome(
|
|
3749
3802
|
runId,
|
|
3750
3803
|
requestId,
|
|
@@ -3776,7 +3829,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3776
3829
|
/**
|
|
3777
3830
|
* ADR-0044 resubmit after rework. Valid on the LATEST `returned` request of
|
|
3778
3831
|
* its run, submitter-only. Audits `resubmit` on the returned (round-N)
|
|
3779
|
-
* request and resumes the run from the revise
|
|
3832
|
+
* request and resumes the run from the revise-window node; traversal walks
|
|
3780
3833
|
* the declared back-edge into the approval node, whose executor opens the
|
|
3781
3834
|
* round-N+1 request — fresh approver slate, record re-locks.
|
|
3782
3835
|
*/
|
|
@@ -3824,7 +3877,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3824
3877
|
}, { context: SYSTEM_CTX2 });
|
|
3825
3878
|
let resumed = false;
|
|
3826
3879
|
let resumeError;
|
|
3827
|
-
if (runId
|
|
3880
|
+
if (runId) {
|
|
3828
3881
|
const outcome = await this.resumeRecordedOutcome(
|
|
3829
3882
|
runId,
|
|
3830
3883
|
requestId,
|
|
@@ -3845,6 +3898,21 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3845
3898
|
* out-edge before send-back is allowed — the engine's branch-label fallback
|
|
3846
3899
|
* (no matching label ⇒ ALL out-edges) must never be reachable from a user
|
|
3847
3900
|
* action.
|
|
3901
|
+
*
|
|
3902
|
+
* Since #3823 it also checks WHAT that edge targets: the revise window must
|
|
3903
|
+
* be an `approval_revise` node, the pause this service owns. ADR-0044 D3
|
|
3904
|
+
* pointed the edge at an ordinary `wait`, which is `resumeAuthority: 'any'`,
|
|
3905
|
+
* so a raw engine resume walked the resubmit back-edge with no submitter
|
|
3906
|
+
* check, no `resubmit` audit row, and — with a pending request colliding on
|
|
3907
|
+
* the record — destroyed the run by consuming the suspension before the
|
|
3908
|
+
* re-entry failed. Refused HERE, before any mutation, for the same reason the
|
|
3909
|
+
* missing-edge check is: a run must never be parked in a revise window that
|
|
3910
|
+
* something other than {@link resubmit} can advance.
|
|
3911
|
+
*
|
|
3912
|
+
* Also refused at authoring time — `flow-approval-revise-target-not-service-owned`
|
|
3913
|
+
* in `@objectstack/lint` gates `os build` / `os validate` / `os lint` and the
|
|
3914
|
+
* runtime metadata publish path — so a flow reaching this check at all is one
|
|
3915
|
+
* published before that gate existed.
|
|
3848
3916
|
*/
|
|
3849
3917
|
async assertReviseEdge(raw, nodeId) {
|
|
3850
3918
|
const processName = String(raw.process_name ?? "");
|
|
@@ -3853,12 +3921,23 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3853
3921
|
throw new Error("VALIDATION_FAILED: send-back requires the owning flow definition (automation engine unavailable)");
|
|
3854
3922
|
}
|
|
3855
3923
|
const flow = await this.automation.getFlow(flowName);
|
|
3856
|
-
const
|
|
3857
|
-
if (
|
|
3924
|
+
const reviseEdges = Array.isArray(flow?.edges) ? flow.edges.filter((e) => e?.source === nodeId && e?.label === APPROVAL_BRANCH_LABELS.revise) : [];
|
|
3925
|
+
if (reviseEdges.length === 0) {
|
|
3858
3926
|
throw new Error(
|
|
3859
3927
|
`VALIDATION_FAILED: approval node '${nodeId}' has no '${APPROVAL_BRANCH_LABELS.revise}' out-edge \u2014 the flow does not support send-back for revision`
|
|
3860
3928
|
);
|
|
3861
3929
|
}
|
|
3930
|
+
const nodeTypeById = new Map(
|
|
3931
|
+
(Array.isArray(flow?.nodes) ? flow.nodes : []).filter((n) => typeof n?.id === "string").map((n) => [n.id, typeof n.type === "string" ? n.type : ""])
|
|
3932
|
+
);
|
|
3933
|
+
for (const edge of reviseEdges) {
|
|
3934
|
+
const target = typeof edge?.target === "string" ? edge.target : "";
|
|
3935
|
+
const targetType = nodeTypeById.get(target);
|
|
3936
|
+
if (targetType === APPROVAL_REVISE_NODE_TYPE) continue;
|
|
3937
|
+
throw new Error(
|
|
3938
|
+
`VALIDATION_FAILED: approval node '${nodeId}' has a '${APPROVAL_BRANCH_LABELS.revise}' out-edge into node '${target || "(unknown)"}'` + (targetType === void 0 ? " which the flow does not declare" : ` of type '${targetType || "(untyped)"}'`) + `, but the revise window must be an '${APPROVAL_REVISE_NODE_TYPE}' node \u2014 that pause continues only through this service (submitter-only, audited, and refusing a colliding pending request), and any other node type there is resumable by anyone with the run id (amended ADR-0044, #3823). Fix the flow: set node '${target || "<revise target>"}' to type '${APPROVAL_REVISE_NODE_TYPE}'.`
|
|
3939
|
+
);
|
|
3940
|
+
}
|
|
3862
3941
|
}
|
|
3863
3942
|
/**
|
|
3864
3943
|
* ADR-0044 guard: a `returned` request is only actionable (resubmit /
|
|
@@ -5590,8 +5669,6 @@ function bindApprovalLockHook(engine, logger) {
|
|
|
5590
5669
|
const changedFields = Object.keys(data).filter((k) => k !== "id" && k !== "updated_at");
|
|
5591
5670
|
if (changedFields.length === 0) return;
|
|
5592
5671
|
if (ctx?.session?.isSystem) return;
|
|
5593
|
-
const roles = ctx?.session?.roles ?? [];
|
|
5594
|
-
if (Array.isArray(roles) && roles.includes("admin")) return;
|
|
5595
5672
|
const gating = await gatingRequests(engine, ctx, object);
|
|
5596
5673
|
if (gating.length === 0) return;
|
|
5597
5674
|
const writerRun = ctx?.provenance?.flowRunId;
|
|
@@ -5613,8 +5690,6 @@ function bindDelegationWriteGuard(engine, logger) {
|
|
|
5613
5690
|
const makeGuard = (isInsert) => async (ctx) => {
|
|
5614
5691
|
const session = ctx?.session ?? {};
|
|
5615
5692
|
if (session.isSystem) return;
|
|
5616
|
-
const roles = session.roles ?? [];
|
|
5617
|
-
if (Array.isArray(roles) && roles.includes("admin")) return;
|
|
5618
5693
|
const userId = session.userId != null ? String(session.userId) : "";
|
|
5619
5694
|
const data = ctx?.input?.data;
|
|
5620
5695
|
const rows = Array.isArray(data) ? data : data && typeof data === "object" ? [data] : [];
|
|
@@ -5648,11 +5723,53 @@ function unbindAllHooks(engine) {
|
|
|
5648
5723
|
|
|
5649
5724
|
// src/approval-node.ts
|
|
5650
5725
|
import {
|
|
5651
|
-
defineActionDescriptor,
|
|
5726
|
+
defineActionDescriptor as defineActionDescriptor2,
|
|
5652
5727
|
ApprovalNodeConfigSchema,
|
|
5653
5728
|
getApprovalNodeConfigJsonSchema,
|
|
5654
5729
|
APPROVAL_NODE_TYPE
|
|
5655
5730
|
} from "@objectstack/spec/automation";
|
|
5731
|
+
|
|
5732
|
+
// src/approval-revise-node.ts
|
|
5733
|
+
import {
|
|
5734
|
+
defineActionDescriptor,
|
|
5735
|
+
APPROVAL_REVISE_NODE_TYPE as APPROVAL_REVISE_NODE_TYPE2
|
|
5736
|
+
} from "@objectstack/spec/automation";
|
|
5737
|
+
var APPROVAL_REVISE_CORRELATION_PREFIX = "approval_revise:";
|
|
5738
|
+
function registerApprovalReviseNode(automation, logger) {
|
|
5739
|
+
automation.registerNodeExecutor({
|
|
5740
|
+
type: APPROVAL_REVISE_NODE_TYPE2,
|
|
5741
|
+
descriptor: defineActionDescriptor({
|
|
5742
|
+
type: APPROVAL_REVISE_NODE_TYPE2,
|
|
5743
|
+
version: "1.0.0",
|
|
5744
|
+
name: "Revise Window",
|
|
5745
|
+
description: "Durable pause an approval send-back parks the run on while the submitter reworks the record. Continues only through the approvals service (resubmit), which re-enters the approval node over the declared back-edge.",
|
|
5746
|
+
icon: "pencil",
|
|
5747
|
+
// Waits on a human — the same category as `approval` and `screen`.
|
|
5748
|
+
category: "human",
|
|
5749
|
+
paradigms: ["flow"],
|
|
5750
|
+
source: "plugin",
|
|
5751
|
+
supportsPause: true,
|
|
5752
|
+
isAsync: true,
|
|
5753
|
+
// #3823 / amended ADR-0044: THE point of this node type. The revise
|
|
5754
|
+
// window is a service-owned continuation, so the #3801 gate must refuse
|
|
5755
|
+
// a raw resume of it — which it does for any node type declaring this.
|
|
5756
|
+
resumeAuthority: "service"
|
|
5757
|
+
// No config: the window is pure position in the graph. Left schemaless
|
|
5758
|
+
// (like `wait` / `subflow` / `decision`) rather than declaring an empty
|
|
5759
|
+
// object, so nothing invents an authorable surface that has no reader.
|
|
5760
|
+
}),
|
|
5761
|
+
async execute(node) {
|
|
5762
|
+
return {
|
|
5763
|
+
success: true,
|
|
5764
|
+
suspend: true,
|
|
5765
|
+
correlation: `${APPROVAL_REVISE_CORRELATION_PREFIX}${node.id}`
|
|
5766
|
+
};
|
|
5767
|
+
}
|
|
5768
|
+
});
|
|
5769
|
+
logger?.info?.("[approvals] approval revise-window node executor registered");
|
|
5770
|
+
}
|
|
5771
|
+
|
|
5772
|
+
// src/approval-node.ts
|
|
5656
5773
|
var SYSTEM_CTX4 = { isSystem: true, positions: [], permissions: [] };
|
|
5657
5774
|
function nestVariables(variables) {
|
|
5658
5775
|
const vars = {};
|
|
@@ -5670,9 +5787,10 @@ function nestVariables(variables) {
|
|
|
5670
5787
|
return vars;
|
|
5671
5788
|
}
|
|
5672
5789
|
function registerApprovalNode(automation, service, logger) {
|
|
5790
|
+
registerApprovalReviseNode(automation, logger);
|
|
5673
5791
|
automation.registerNodeExecutor({
|
|
5674
5792
|
type: APPROVAL_NODE_TYPE,
|
|
5675
|
-
descriptor:
|
|
5793
|
+
descriptor: defineActionDescriptor2({
|
|
5676
5794
|
type: APPROVAL_NODE_TYPE,
|
|
5677
5795
|
version: "1.0.0",
|
|
5678
5796
|
name: "Approval",
|
|
@@ -5990,12 +6108,14 @@ var ApprovalsServicePlugin = class {
|
|
|
5990
6108
|
}
|
|
5991
6109
|
};
|
|
5992
6110
|
export {
|
|
6111
|
+
APPROVAL_REVISE_CORRELATION_PREFIX,
|
|
5993
6112
|
ApprovalService,
|
|
5994
6113
|
ApprovalsServicePlugin,
|
|
5995
6114
|
SysApprovalAction,
|
|
5996
6115
|
SysApprovalApprover,
|
|
5997
6116
|
SysApprovalDelegation,
|
|
5998
6117
|
SysApprovalRequest,
|
|
5999
|
-
registerApprovalNode
|
|
6118
|
+
registerApprovalNode,
|
|
6119
|
+
registerApprovalReviseNode
|
|
6000
6120
|
};
|
|
6001
6121
|
//# sourceMappingURL=index.mjs.map
|