@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.js CHANGED
@@ -1334,13 +1334,15 @@ var init_translations = __esm({
1334
1334
  // src/index.ts
1335
1335
  var index_exports = {};
1336
1336
  __export(index_exports, {
1337
+ APPROVAL_REVISE_CORRELATION_PREFIX: () => APPROVAL_REVISE_CORRELATION_PREFIX,
1337
1338
  ApprovalService: () => ApprovalService,
1338
1339
  ApprovalsServicePlugin: () => ApprovalsServicePlugin,
1339
1340
  SysApprovalAction: () => SysApprovalAction,
1340
1341
  SysApprovalApprover: () => SysApprovalApprover,
1341
1342
  SysApprovalDelegation: () => SysApprovalDelegation,
1342
1343
  SysApprovalRequest: () => SysApprovalRequest,
1343
- registerApprovalNode: () => registerApprovalNode
1344
+ registerApprovalNode: () => registerApprovalNode,
1345
+ registerApprovalReviseNode: () => registerApprovalReviseNode
1344
1346
  });
1345
1347
  module.exports = __toCommonJS(index_exports);
1346
1348
 
@@ -3445,6 +3447,47 @@ var _ApprovalService = class _ApprovalService {
3445
3447
  );
3446
3448
  }
3447
3449
  }
3450
+ /**
3451
+ * The run named by a recorded outcome cannot be advanced at all, because no
3452
+ * automation engine in THIS process implements the capability it needs
3453
+ * (#4420). Returns the reason, or `undefined` when the capability is there
3454
+ * and the caller should proceed.
3455
+ *
3456
+ * **Why this is not simply "not our problem".** Approvals is legitimately
3457
+ * usable with no engine attached, and {@link assertRunResumable} deliberately
3458
+ * stays out of the way for that reason. But "no engine is attached" and "no
3459
+ * run is waiting" are different facts, and only the second is benign: a
3460
+ * `flow_run_id` on the row is the request's OWN declaration that a run is
3461
+ * parked on this decision. Deciding it in a process that cannot resume it
3462
+ * reproduces #4420's reported half-state exactly — a durable decision, a
3463
+ * mirrored status field frozen mid-workflow, a flow parked forever — while
3464
+ * the caller is answered HTTP 200. The engine-less composition is the one
3465
+ * path the #4420 fix left silent, because every guard it added hangs off an
3466
+ * engine that is not there.
3467
+ *
3468
+ * **So the outcome stands, but it is never silent.** Rolling the decision
3469
+ * back is not on the table (a human really did decide, and the row is
3470
+ * already durable), and refusing every such call would break the standalone
3471
+ * compositions the pre-flight protects. What is owed is the report: `error`
3472
+ * level per AGENTS.md's durability rule — persisted state and runtime state
3473
+ * disagree and nothing looks broken from the outside — plus a `resumeError`
3474
+ * on the response, so `resumed: false` carries its reason instead of leaving
3475
+ * the caller to guess whether a resume was even attempted.
3476
+ *
3477
+ * Reuses the registered `RESUME_FAILED` code (ADR-0112 ledger) and
3478
+ * {@link serviceResume}'s message shape: the fact being reported — an
3479
+ * outcome recorded whose run did not advance — is the same one, and this
3480
+ * needs no new vocabulary of its own.
3481
+ */
3482
+ missingRunCapability(runId, requestId, what, capability) {
3483
+ const fn = capability === "resume" ? this.automation?.resume : this.automation?.cancelRun;
3484
+ if (typeof fn === "function") return void 0;
3485
+ this.logger?.error?.(
3486
+ "[approvals] no automation engine to advance the recorded outcome \u2014 the run is stranded",
3487
+ { request: requestId, run: runId, outcome: what, capability }
3488
+ );
3489
+ 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.`;
3490
+ }
3448
3491
  /**
3449
3492
  * Resume the run behind an outcome that has ALREADY been written down, and
3450
3493
  * fail loudly when it cannot be (#4420).
@@ -3456,12 +3499,17 @@ var _ApprovalService = class _ApprovalService {
3456
3499
  * everything it catches never reaches a write.
3457
3500
  *
3458
3501
  * `RESUME_IN_PROGRESS` is the exception — a concurrent resume is already
3459
- * advancing the run, so the outcome stands and only `resumed` is false.
3502
+ * advancing the run, so the outcome stands and only `resumed` is false. So
3503
+ * is a composition with no engine at all ({@link missingRunCapability}),
3504
+ * which cannot throw without breaking every standalone deployment — it
3505
+ * reports through `resumeError` instead.
3460
3506
  *
3461
3507
  * @param what - how the recorded outcome reads in the error, e.g.
3462
3508
  * `"the approve decision"`.
3463
3509
  */
3464
3510
  async resumeRecordedOutcome(runId, requestId, what, signal) {
3511
+ const missing = this.missingRunCapability(runId, requestId, what, "resume");
3512
+ if (missing) return { resumed: false, resumeError: missing };
3465
3513
  try {
3466
3514
  await this.serviceResume(runId, signal);
3467
3515
  return { resumed: true };
@@ -3504,7 +3552,7 @@ var _ApprovalService = class _ApprovalService {
3504
3552
  const result = await this.decideNode(requestId, input, context);
3505
3553
  let resumed = false;
3506
3554
  let resumeError;
3507
- if (result.finalized && result.runId && typeof this.automation?.resume === "function") {
3555
+ if (result.finalized && result.runId) {
3508
3556
  const branchLabel = result.decision === "approve" ? import_automation.APPROVAL_BRANCH_LABELS.approve : import_automation.APPROVAL_BRANCH_LABELS.reject;
3509
3557
  const outcome = await this.resumeRecordedOutcome(
3510
3558
  result.runId,
@@ -3541,7 +3589,7 @@ var _ApprovalService = class _ApprovalService {
3541
3589
  *
3542
3590
  * ADR-0044: also valid on the LATEST `returned` request of its run — the
3543
3591
  * submitter abandons the revision window instead of resubmitting. The run
3544
- * is then paused at the revise wait node (no reject edge), so it is
3592
+ * is then paused at the revise-window node (no reject edge), so it is
3545
3593
  * terminally cancelled via {@link ApprovalResumeSurface.cancelRun} rather
3546
3594
  * than resumed.
3547
3595
  */
@@ -3599,33 +3647,39 @@ var _ApprovalService = class _ApprovalService {
3599
3647
  let resumed = false;
3600
3648
  let resumeError;
3601
3649
  if (inReviseWindow) {
3602
- if (runId && typeof this.automation?.cancelRun === "function") {
3650
+ if (runId) {
3651
+ resumeError = this.missingRunCapability(runId, requestId, "the recall", "cancelRun");
3652
+ if (!resumeError) {
3653
+ try {
3654
+ await this.automation.cancelRun(runId, `approval request ${requestId} recalled during revision`);
3655
+ } catch (err) {
3656
+ resumeError = err?.message ?? String(err);
3657
+ this.logger?.error?.("[approvals] cancelRun after revise-window recall failed \u2014 the run may be stranded", {
3658
+ request: requestId,
3659
+ run: runId,
3660
+ error: resumeError
3661
+ });
3662
+ }
3663
+ }
3664
+ }
3665
+ } else if (runId) {
3666
+ resumeError = this.missingRunCapability(runId, requestId, "the recall", "resume");
3667
+ if (!resumeError) {
3603
3668
  try {
3604
- await this.automation.cancelRun(runId, `approval request ${requestId} recalled during revision`);
3669
+ await this.serviceResume(runId, {
3670
+ branchLabel: import_automation.APPROVAL_BRANCH_LABELS.reject,
3671
+ output: { decision: "recall", requestId }
3672
+ });
3673
+ resumed = true;
3605
3674
  } catch (err) {
3606
3675
  resumeError = err?.message ?? String(err);
3607
- this.logger?.error?.("[approvals] cancelRun after revise-window recall failed \u2014 the run may be stranded", {
3676
+ this.logger?.error?.("[approvals] resume after recall failed \u2014 the run may be stranded", {
3608
3677
  request: requestId,
3609
3678
  run: runId,
3610
3679
  error: resumeError
3611
3680
  });
3612
3681
  }
3613
3682
  }
3614
- } else if (runId && typeof this.automation?.resume === "function") {
3615
- try {
3616
- await this.serviceResume(runId, {
3617
- branchLabel: import_automation.APPROVAL_BRANCH_LABELS.reject,
3618
- output: { decision: "recall", requestId }
3619
- });
3620
- resumed = true;
3621
- } catch (err) {
3622
- resumeError = err?.message ?? String(err);
3623
- this.logger?.error?.("[approvals] resume after recall failed \u2014 the run may be stranded", {
3624
- request: requestId,
3625
- run: runId,
3626
- error: resumeError
3627
- });
3628
- }
3629
3683
  }
3630
3684
  const fresh = await this.readBackRequest(requestId, context);
3631
3685
  return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
@@ -3635,7 +3689,7 @@ var _ApprovalService = class _ApprovalService {
3635
3689
  * ADR-0044 send back for revision. Finalises the pending request as
3636
3690
  * `returned` (a third terminal state — approver-initiated rework, distinct
3637
3691
  * from submitter-initiated `recalled`) and resumes the owning flow run down
3638
- * its `revise` edge to a wait point: the record lock (keyed on `pending`)
3692
+ * its `revise` edge to the revise window: the record lock (keyed on `pending`)
3639
3693
  * releases, the submitter reworks the data, then {@link resubmit}s.
3640
3694
  *
3641
3695
  * Requires the approval node to declare a `revise` out-edge — validated
@@ -3710,7 +3764,7 @@ var _ApprovalService = class _ApprovalService {
3710
3764
  }
3711
3765
  let resumed2 = false;
3712
3766
  let resumeError2;
3713
- if (runId && typeof this.automation?.resume === "function") {
3767
+ if (runId) {
3714
3768
  const outcome = await this.resumeRecordedOutcome(
3715
3769
  runId,
3716
3770
  requestId,
@@ -3758,7 +3812,7 @@ var _ApprovalService = class _ApprovalService {
3758
3812
  }
3759
3813
  let resumed = false;
3760
3814
  let resumeError;
3761
- if (runId && typeof this.automation?.resume === "function") {
3815
+ if (runId) {
3762
3816
  const outcome = await this.resumeRecordedOutcome(
3763
3817
  runId,
3764
3818
  requestId,
@@ -3790,7 +3844,7 @@ var _ApprovalService = class _ApprovalService {
3790
3844
  /**
3791
3845
  * ADR-0044 resubmit after rework. Valid on the LATEST `returned` request of
3792
3846
  * its run, submitter-only. Audits `resubmit` on the returned (round-N)
3793
- * request and resumes the run from the revise wait node; traversal walks
3847
+ * request and resumes the run from the revise-window node; traversal walks
3794
3848
  * the declared back-edge into the approval node, whose executor opens the
3795
3849
  * round-N+1 request — fresh approver slate, record re-locks.
3796
3850
  */
@@ -3838,7 +3892,7 @@ var _ApprovalService = class _ApprovalService {
3838
3892
  }, { context: SYSTEM_CTX2 });
3839
3893
  let resumed = false;
3840
3894
  let resumeError;
3841
- if (runId && typeof this.automation?.resume === "function") {
3895
+ if (runId) {
3842
3896
  const outcome = await this.resumeRecordedOutcome(
3843
3897
  runId,
3844
3898
  requestId,
@@ -3859,6 +3913,21 @@ var _ApprovalService = class _ApprovalService {
3859
3913
  * out-edge before send-back is allowed — the engine's branch-label fallback
3860
3914
  * (no matching label ⇒ ALL out-edges) must never be reachable from a user
3861
3915
  * action.
3916
+ *
3917
+ * Since #3823 it also checks WHAT that edge targets: the revise window must
3918
+ * be an `approval_revise` node, the pause this service owns. ADR-0044 D3
3919
+ * pointed the edge at an ordinary `wait`, which is `resumeAuthority: 'any'`,
3920
+ * so a raw engine resume walked the resubmit back-edge with no submitter
3921
+ * check, no `resubmit` audit row, and — with a pending request colliding on
3922
+ * the record — destroyed the run by consuming the suspension before the
3923
+ * re-entry failed. Refused HERE, before any mutation, for the same reason the
3924
+ * missing-edge check is: a run must never be parked in a revise window that
3925
+ * something other than {@link resubmit} can advance.
3926
+ *
3927
+ * Also refused at authoring time — `flow-approval-revise-target-not-service-owned`
3928
+ * in `@objectstack/lint` gates `os build` / `os validate` / `os lint` and the
3929
+ * runtime metadata publish path — so a flow reaching this check at all is one
3930
+ * published before that gate existed.
3862
3931
  */
3863
3932
  async assertReviseEdge(raw, nodeId) {
3864
3933
  const processName = String(raw.process_name ?? "");
@@ -3867,12 +3936,23 @@ var _ApprovalService = class _ApprovalService {
3867
3936
  throw new Error("VALIDATION_FAILED: send-back requires the owning flow definition (automation engine unavailable)");
3868
3937
  }
3869
3938
  const flow = await this.automation.getFlow(flowName);
3870
- const hasRevise = Array.isArray(flow?.edges) && flow.edges.some((e) => e?.source === nodeId && e?.label === import_automation.APPROVAL_BRANCH_LABELS.revise);
3871
- if (!hasRevise) {
3939
+ const reviseEdges = Array.isArray(flow?.edges) ? flow.edges.filter((e) => e?.source === nodeId && e?.label === import_automation.APPROVAL_BRANCH_LABELS.revise) : [];
3940
+ if (reviseEdges.length === 0) {
3872
3941
  throw new Error(
3873
3942
  `VALIDATION_FAILED: approval node '${nodeId}' has no '${import_automation.APPROVAL_BRANCH_LABELS.revise}' out-edge \u2014 the flow does not support send-back for revision`
3874
3943
  );
3875
3944
  }
3945
+ const nodeTypeById = new Map(
3946
+ (Array.isArray(flow?.nodes) ? flow.nodes : []).filter((n) => typeof n?.id === "string").map((n) => [n.id, typeof n.type === "string" ? n.type : ""])
3947
+ );
3948
+ for (const edge of reviseEdges) {
3949
+ const target = typeof edge?.target === "string" ? edge.target : "";
3950
+ const targetType = nodeTypeById.get(target);
3951
+ if (targetType === import_automation.APPROVAL_REVISE_NODE_TYPE) continue;
3952
+ throw new Error(
3953
+ `VALIDATION_FAILED: approval node '${nodeId}' has a '${import_automation.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 '${import_automation.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 '${import_automation.APPROVAL_REVISE_NODE_TYPE}'.`
3954
+ );
3955
+ }
3876
3956
  }
3877
3957
  /**
3878
3958
  * ADR-0044 guard: a `returned` request is only actionable (resubmit /
@@ -5604,8 +5684,6 @@ function bindApprovalLockHook(engine, logger) {
5604
5684
  const changedFields = Object.keys(data).filter((k) => k !== "id" && k !== "updated_at");
5605
5685
  if (changedFields.length === 0) return;
5606
5686
  if (ctx?.session?.isSystem) return;
5607
- const roles = ctx?.session?.roles ?? [];
5608
- if (Array.isArray(roles) && roles.includes("admin")) return;
5609
5687
  const gating = await gatingRequests(engine, ctx, object);
5610
5688
  if (gating.length === 0) return;
5611
5689
  const writerRun = ctx?.provenance?.flowRunId;
@@ -5627,8 +5705,6 @@ function bindDelegationWriteGuard(engine, logger) {
5627
5705
  const makeGuard = (isInsert) => async (ctx) => {
5628
5706
  const session = ctx?.session ?? {};
5629
5707
  if (session.isSystem) return;
5630
- const roles = session.roles ?? [];
5631
- if (Array.isArray(roles) && roles.includes("admin")) return;
5632
5708
  const userId = session.userId != null ? String(session.userId) : "";
5633
5709
  const data = ctx?.input?.data;
5634
5710
  const rows = Array.isArray(data) ? data : data && typeof data === "object" ? [data] : [];
@@ -5661,7 +5737,46 @@ function unbindAllHooks(engine) {
5661
5737
  }
5662
5738
 
5663
5739
  // src/approval-node.ts
5740
+ var import_automation3 = require("@objectstack/spec/automation");
5741
+
5742
+ // src/approval-revise-node.ts
5664
5743
  var import_automation2 = require("@objectstack/spec/automation");
5744
+ var APPROVAL_REVISE_CORRELATION_PREFIX = "approval_revise:";
5745
+ function registerApprovalReviseNode(automation, logger) {
5746
+ automation.registerNodeExecutor({
5747
+ type: import_automation2.APPROVAL_REVISE_NODE_TYPE,
5748
+ descriptor: (0, import_automation2.defineActionDescriptor)({
5749
+ type: import_automation2.APPROVAL_REVISE_NODE_TYPE,
5750
+ version: "1.0.0",
5751
+ name: "Revise Window",
5752
+ 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.",
5753
+ icon: "pencil",
5754
+ // Waits on a human — the same category as `approval` and `screen`.
5755
+ category: "human",
5756
+ paradigms: ["flow"],
5757
+ source: "plugin",
5758
+ supportsPause: true,
5759
+ isAsync: true,
5760
+ // #3823 / amended ADR-0044: THE point of this node type. The revise
5761
+ // window is a service-owned continuation, so the #3801 gate must refuse
5762
+ // a raw resume of it — which it does for any node type declaring this.
5763
+ resumeAuthority: "service"
5764
+ // No config: the window is pure position in the graph. Left schemaless
5765
+ // (like `wait` / `subflow` / `decision`) rather than declaring an empty
5766
+ // object, so nothing invents an authorable surface that has no reader.
5767
+ }),
5768
+ async execute(node) {
5769
+ return {
5770
+ success: true,
5771
+ suspend: true,
5772
+ correlation: `${APPROVAL_REVISE_CORRELATION_PREFIX}${node.id}`
5773
+ };
5774
+ }
5775
+ });
5776
+ logger?.info?.("[approvals] approval revise-window node executor registered");
5777
+ }
5778
+
5779
+ // src/approval-node.ts
5665
5780
  var SYSTEM_CTX4 = { isSystem: true, positions: [], permissions: [] };
5666
5781
  function nestVariables(variables) {
5667
5782
  const vars = {};
@@ -5679,10 +5794,11 @@ function nestVariables(variables) {
5679
5794
  return vars;
5680
5795
  }
5681
5796
  function registerApprovalNode(automation, service, logger) {
5797
+ registerApprovalReviseNode(automation, logger);
5682
5798
  automation.registerNodeExecutor({
5683
- type: import_automation2.APPROVAL_NODE_TYPE,
5684
- descriptor: (0, import_automation2.defineActionDescriptor)({
5685
- type: import_automation2.APPROVAL_NODE_TYPE,
5799
+ type: import_automation3.APPROVAL_NODE_TYPE,
5800
+ descriptor: (0, import_automation3.defineActionDescriptor)({
5801
+ type: import_automation3.APPROVAL_NODE_TYPE,
5686
5802
  version: "1.0.0",
5687
5803
  name: "Approval",
5688
5804
  description: "Route a record for human approval; suspends the flow until a decision, then continues down the approve / reject branch.",
@@ -5705,10 +5821,10 @@ function registerApprovalNode(automation, service, logger) {
5705
5821
  // Publish the node's config contract (ADR-0018 §configSchema) so the
5706
5822
  // Studio flow designer renders the Approval property form from the engine
5707
5823
  // rather than a hardcoded client form — the engine owns the shape.
5708
- configSchema: (0, import_automation2.getApprovalNodeConfigJsonSchema)()
5824
+ configSchema: (0, import_automation3.getApprovalNodeConfigJsonSchema)()
5709
5825
  }),
5710
5826
  async execute(node, variables, context) {
5711
- const parsed = import_automation2.ApprovalNodeConfigSchema.safeParse(node.config ?? {});
5827
+ const parsed = import_automation3.ApprovalNodeConfigSchema.safeParse(node.config ?? {});
5712
5828
  if (!parsed.success) {
5713
5829
  const msg = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
5714
5830
  return { success: false, error: `Approval node '${node.id}' has invalid config: ${msg}` };
@@ -6000,12 +6116,14 @@ var ApprovalsServicePlugin = class {
6000
6116
  };
6001
6117
  // Annotate the CommonJS export names for ESM import in node:
6002
6118
  0 && (module.exports = {
6119
+ APPROVAL_REVISE_CORRELATION_PREFIX,
6003
6120
  ApprovalService,
6004
6121
  ApprovalsServicePlugin,
6005
6122
  SysApprovalAction,
6006
6123
  SysApprovalApprover,
6007
6124
  SysApprovalDelegation,
6008
6125
  SysApprovalRequest,
6009
- registerApprovalNode
6126
+ registerApprovalNode,
6127
+ registerApprovalReviseNode
6010
6128
  });
6011
6129
  //# sourceMappingURL=index.js.map