@objectstack/plugin-approvals 17.0.0-rc.3 → 17.0.0-rc.5

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/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 && typeof this.automation?.resume === "function") {
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 wait node (no reject edge), so it is
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 && typeof this.automation?.cancelRun === "function") {
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.automation.cancelRun(runId, `approval request ${requestId} recalled during revision`);
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] cancelRun after revise-window recall failed \u2014 the run may be stranded", {
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 a wait point: the record lock (keyed on `pending`)
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 && typeof this.automation?.resume === "function") {
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 && typeof this.automation?.resume === "function") {
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 wait node; traversal walks
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 && typeof this.automation?.resume === "function") {
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 hasRevise = Array.isArray(flow?.edges) && flow.edges.some((e) => e?.source === nodeId && e?.label === APPROVAL_BRANCH_LABELS.revise);
3857
- if (!hasRevise) {
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: defineActionDescriptor({
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