@agent-native/dispatch 0.14.1 → 0.14.3

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.
@@ -1,6 +1,6 @@
1
1
  import crypto from "node:crypto";
2
2
 
3
- import { and, desc, eq, isNull, or } from "@agent-native/core/db/schema";
3
+ import { and, desc, eq, isNull, or, sql } from "@agent-native/core/db/schema";
4
4
  import {
5
5
  getRequestUserEmail,
6
6
  getRequestOrgId,
@@ -11,6 +11,7 @@ import { getDb, schema } from "../../db/index.js";
11
11
 
12
12
  export const SHARED_DISPATCH_OWNER = "dispatch@shared";
13
13
  const APPROVAL_POLICY_KEY = "dispatch-approval-policy";
14
+ const APPROVAL_APPLY_LEASE_MS = 5 * 60 * 1000;
14
15
 
15
16
  // ─── /link rate limiting ──────────────────────────────────────────────────
16
17
  //
@@ -188,8 +189,8 @@ export async function getApprovalPolicy(): Promise<DispatchApprovalPolicy> {
188
189
  async function applyApprovalPolicy(
189
190
  input: DispatchApprovalPolicy,
190
191
  actor = currentOwnerEmail(),
192
+ orgId = currentOrgId(),
191
193
  ) {
192
- const orgId = currentOrgId();
193
194
  if (!orgId) {
194
195
  throw new Error(
195
196
  "Dispatch approval settings require an active organization",
@@ -209,7 +210,10 @@ async function applyApprovalPolicy(
209
210
  metadata: input,
210
211
  actor,
211
212
  });
212
- return getApprovalPolicy();
213
+ return {
214
+ enabled: input.enabled,
215
+ approverEmails: input.approverEmails,
216
+ };
213
217
  }
214
218
 
215
219
  export async function setApprovalPolicy(input: DispatchApprovalPolicy) {
@@ -564,6 +568,7 @@ async function applyApprovedRequest(request: DispatchApprovalRequest) {
564
568
  return applyApprovalPolicy(
565
569
  payload,
566
570
  request.reviewedBy || currentOwnerEmail(),
571
+ requestCtx.orgId,
567
572
  );
568
573
  }
569
574
  if (request.changeType === "dream-proposal.apply") {
@@ -610,22 +615,77 @@ export async function approveRequest(requestId: string) {
610
615
  const ctx = requireDispatchCtx();
611
616
  const request = await getApprovalRequest(requestId, ctx);
612
617
  if (!request) throw new Error("Approval request not found");
613
- if (request.status !== "pending") {
614
- throw new Error("Only pending approvals can be approved");
615
- }
618
+
616
619
  const timestamp = now();
617
- await db
620
+ const staleApplyingBefore = timestamp - APPROVAL_APPLY_LEASE_MS;
621
+ // Fence the transition on the current status so a concurrent approve can't
622
+ // both win. A crashed worker can leave its lease in "applying", so reclaim
623
+ // only a lease that has been idle for the full lease interval.
624
+ const claimed = await db
625
+ .update(schema.dispatchApprovalRequests)
626
+ .set({
627
+ status: "applying",
628
+ updatedAt: timestamp,
629
+ })
630
+ .where(
631
+ and(
632
+ eq(schema.dispatchApprovalRequests.id, requestId),
633
+ ctxScope(schema.dispatchApprovalRequests, ctx),
634
+ or(
635
+ eq(schema.dispatchApprovalRequests.status, "pending"),
636
+ and(
637
+ eq(schema.dispatchApprovalRequests.status, "applying"),
638
+ sql`${schema.dispatchApprovalRequests.updatedAt} < ${staleApplyingBefore}`,
639
+ ),
640
+ ),
641
+ ),
642
+ )
643
+ .returning();
644
+
645
+ if (claimed.length === 0) {
646
+ const current = (await getApprovalRequest(requestId, ctx)) ?? request;
647
+ if (current.status === "applying") {
648
+ throw new Error("Approval is already being applied");
649
+ }
650
+ return current;
651
+ }
652
+
653
+ const applying = claimed[0];
654
+ try {
655
+ await applyApprovedRequest(applying);
656
+ } catch (error) {
657
+ // Applying a request can have succeeded partially before throwing. Do not
658
+ // return it to pending: that would allow an immediate retry to duplicate a
659
+ // non-idempotent effect. Keep the lease until it becomes stale, which
660
+ // serializes any recovery attempt behind the same fencing rule.
661
+ await db
662
+ .update(schema.dispatchApprovalRequests)
663
+ .set({ updatedAt: now() })
664
+ .where(
665
+ and(
666
+ eq(schema.dispatchApprovalRequests.id, requestId),
667
+ ctxScope(schema.dispatchApprovalRequests, ctx),
668
+ eq(schema.dispatchApprovalRequests.status, "applying"),
669
+ ),
670
+ );
671
+ throw error;
672
+ }
673
+ const [updated] = await db
618
674
  .update(schema.dispatchApprovalRequests)
619
675
  .set({
620
676
  status: "approved",
621
677
  reviewedBy: currentOwnerEmail(),
622
678
  reviewedAt: timestamp,
623
- updatedAt: timestamp,
679
+ updatedAt: now(),
624
680
  })
625
- .where(eq(schema.dispatchApprovalRequests.id, requestId));
626
- const updated = await getApprovalRequest(requestId, ctx);
681
+ .where(
682
+ and(
683
+ eq(schema.dispatchApprovalRequests.id, requestId),
684
+ eq(schema.dispatchApprovalRequests.status, "applying"),
685
+ ),
686
+ )
687
+ .returning();
627
688
  if (!updated) throw new Error("Approval request disappeared");
628
- await applyApprovedRequest(updated);
629
689
  await recordAudit({
630
690
  action: "approval.approved",
631
691
  targetType: updated.targetType,
@@ -638,13 +698,12 @@ export async function approveRequest(requestId: string) {
638
698
 
639
699
  export async function rejectRequest(requestId: string, reason?: string | null) {
640
700
  const db = getDb();
641
- const request = await getApprovalRequest(requestId);
701
+ const ctx = requireDispatchCtx();
702
+ const request = await getApprovalRequest(requestId, ctx);
642
703
  if (!request) throw new Error("Approval request not found");
643
- if (request.status !== "pending") {
644
- throw new Error("Only pending approvals can be rejected");
645
- }
704
+
646
705
  const timestamp = now();
647
- await db
706
+ const claimed = await db
648
707
  .update(schema.dispatchApprovalRequests)
649
708
  .set({
650
709
  status: "rejected",
@@ -652,15 +711,27 @@ export async function rejectRequest(requestId: string, reason?: string | null) {
652
711
  reviewedAt: timestamp,
653
712
  updatedAt: timestamp,
654
713
  })
655
- .where(eq(schema.dispatchApprovalRequests.id, requestId));
714
+ .where(
715
+ and(
716
+ eq(schema.dispatchApprovalRequests.id, requestId),
717
+ eq(schema.dispatchApprovalRequests.status, "pending"),
718
+ ),
719
+ )
720
+ .returning();
721
+
722
+ if (claimed.length === 0) {
723
+ return (await getApprovalRequest(requestId, ctx)) ?? request;
724
+ }
725
+
726
+ const updated = claimed[0];
656
727
  await recordAudit({
657
728
  action: "approval.rejected",
658
- targetType: request.targetType,
729
+ targetType: updated.targetType,
659
730
  targetId: requestId,
660
- summary: `Rejected ${request.summary}`,
661
- metadata: { request, reason: reason || null },
731
+ summary: `Rejected ${updated.summary}`,
732
+ metadata: { request: updated, reason: reason || null },
662
733
  });
663
- return getApprovalRequest(requestId);
734
+ return updated;
664
735
  }
665
736
 
666
737
  export async function createLinkToken(platform: string) {
@@ -3,7 +3,7 @@ import type { EnvKeyConfig } from "@agent-native/core/server";
3
3
  export const envKeys: EnvKeyConfig[] = [
4
4
  {
5
5
  key: "SLACK_BOT_TOKEN",
6
- label: "Slack bot token",
6
+ label: "Slack bot token (legacy)",
7
7
  required: false,
8
8
  },
9
9
  {
@@ -1,6 +1,6 @@
1
1
  import crypto from "node:crypto";
2
2
 
3
- import { and, desc, eq, isNull, or } from "@agent-native/core/db/schema";
3
+ import { and, desc, eq, isNull, or, sql } from "@agent-native/core/db/schema";
4
4
  import { ssrfSafeFetch } from "@agent-native/core/extensions/url-safety";
5
5
  import {
6
6
  deleteAppSecret,
@@ -567,8 +567,59 @@ export async function createGrant(
567
567
  if (!secret) throw new Error("Secret not found");
568
568
 
569
569
  const timestamp = now();
570
- const grantId = id();
571
570
  const actor = ctx.ownerEmail;
571
+ const [existing] = await db
572
+ .select()
573
+ .from(schema.vaultGrants)
574
+ .where(
575
+ and(
576
+ eq(schema.vaultGrants.secretId, secretId),
577
+ eq(schema.vaultGrants.appId, appId),
578
+ ctxScope(schema.vaultGrants, ctx),
579
+ ),
580
+ )
581
+ .orderBy(desc(schema.vaultGrants.updatedAt))
582
+ .limit(1);
583
+
584
+ if (existing?.status === "active") {
585
+ return existing;
586
+ }
587
+
588
+ if (existing) {
589
+ await db
590
+ .update(schema.vaultGrants)
591
+ .set({
592
+ grantedBy: actor,
593
+ status: "active",
594
+ syncedAt: null,
595
+ updatedAt: timestamp,
596
+ })
597
+ .where(
598
+ and(
599
+ eq(schema.vaultGrants.id, existing.id),
600
+ ctxScope(schema.vaultGrants, ctx),
601
+ ),
602
+ );
603
+
604
+ await recordVaultAudit({
605
+ action: "grant.reinstated",
606
+ secretId,
607
+ appId,
608
+ summary: `Reinstated "${secret.name}" (${secret.credentialKey}) for ${appId}`,
609
+ metadata: { grantId: existing.id },
610
+ });
611
+
612
+ await recordAudit({
613
+ action: "vault.grant.reinstated",
614
+ targetType: "vault-grant",
615
+ targetId: existing.id,
616
+ summary: `Reinstated vault secret "${secret.name}" for ${appId}`,
617
+ });
618
+
619
+ return getGrant(existing.id, ctx);
620
+ }
621
+
622
+ const grantId = id();
572
623
 
573
624
  await db.insert(schema.vaultGrants).values({
574
625
  id: grantId,
@@ -956,65 +1007,115 @@ export async function approveRequest(
956
1007
  const db = getDb();
957
1008
  const request = await getRequest(requestId, ctx);
958
1009
  if (!request) throw new Error("Request not found");
959
- if (request.status !== "pending") {
960
- throw new Error("Only pending requests can be approved");
961
- }
962
1010
 
963
1011
  const timestamp = now();
964
1012
  const reviewer = ctx.ownerEmail;
965
-
966
- // Update request status — scoped to caller's tenant.
967
- await db
1013
+ const staleApplyingBefore = timestamp - 5 * 60 * 1000;
1014
+
1015
+ // Fence the transition on the current status — scoped to caller's tenant —
1016
+ // so a concurrent approve can't both win: only the caller that flips
1017
+ // pending -> applying proceeds to create the secret/grant. A crashed worker
1018
+ // leaves an applying row behind, so a later reviewer may reclaim a lease
1019
+ // that has been idle for five minutes. See
1020
+ // claimAgentTeamRun in packages/core/src/server/agent-teams-run-queue.ts
1021
+ // for the same pattern.
1022
+ const claimed = await db
968
1023
  .update(schema.vaultRequests)
969
1024
  .set({
970
- status: "approved",
971
- reviewedBy: reviewer,
972
- reviewedAt: timestamp,
1025
+ status: "applying",
973
1026
  updatedAt: timestamp,
974
1027
  })
975
1028
  .where(
976
1029
  and(
977
1030
  eq(schema.vaultRequests.id, requestId),
978
1031
  ctxScope(schema.vaultRequests, ctx),
1032
+ or(
1033
+ eq(schema.vaultRequests.status, "pending"),
1034
+ and(
1035
+ eq(schema.vaultRequests.status, "applying"),
1036
+ sql`${schema.vaultRequests.updatedAt} < ${staleApplyingBefore}`,
1037
+ ),
1038
+ ),
979
1039
  ),
980
- );
1040
+ )
1041
+ .returning();
1042
+
1043
+ if (claimed.length === 0) {
1044
+ const current = await getRequest(requestId, ctx);
1045
+ if (current?.status === "applying") {
1046
+ throw new Error("Vault request is already being applied");
1047
+ }
1048
+ return current;
1049
+ }
1050
+
1051
+ const claimedRequest = claimed[0];
981
1052
 
982
1053
  // Secret + grant must land in the REQUEST's tenant, not the approver's
983
1054
  // (the approver may be acting on behalf of another user in the same org).
984
- const requestCtx = ctxForRow(request);
1055
+ const requestCtx = ctxForRow(claimedRequest);
985
1056
 
986
- // Check if secret already exists in the request's tenant for this key.
987
- const existingSecrets = await db
988
- .select()
989
- .from(schema.vaultSecrets)
1057
+ try {
1058
+ // Check if secret already exists in the request's tenant for this key.
1059
+ const existingSecrets = await db
1060
+ .select()
1061
+ .from(schema.vaultSecrets)
1062
+ .where(
1063
+ and(
1064
+ eq(schema.vaultSecrets.credentialKey, claimedRequest.credentialKey),
1065
+ ctxScope(schema.vaultSecrets, requestCtx),
1066
+ ),
1067
+ );
1068
+ let secret = existingSecrets[0] ?? null;
1069
+
1070
+ if (!secret) {
1071
+ secret = await createSecret(
1072
+ {
1073
+ credentialKey: claimedRequest.credentialKey,
1074
+ value: secretValue,
1075
+ name: secretName || claimedRequest.credentialKey,
1076
+ },
1077
+ requestCtx,
1078
+ );
1079
+ }
1080
+
1081
+ if (secret) {
1082
+ // Create the grant in the request's tenant as well.
1083
+ await createGrant(secret.id, claimedRequest.appId, requestCtx);
1084
+ }
1085
+ } catch (error) {
1086
+ await db
1087
+ .update(schema.vaultRequests)
1088
+ .set({ status: "pending", updatedAt: now() })
1089
+ .where(
1090
+ and(
1091
+ eq(schema.vaultRequests.id, requestId),
1092
+ ctxScope(schema.vaultRequests, ctx),
1093
+ eq(schema.vaultRequests.status, "applying"),
1094
+ ),
1095
+ );
1096
+ throw error;
1097
+ }
1098
+
1099
+ await db
1100
+ .update(schema.vaultRequests)
1101
+ .set({
1102
+ status: "approved",
1103
+ reviewedBy: reviewer,
1104
+ reviewedAt: timestamp,
1105
+ updatedAt: now(),
1106
+ })
990
1107
  .where(
991
1108
  and(
992
- eq(schema.vaultSecrets.credentialKey, request.credentialKey),
993
- ctxScope(schema.vaultSecrets, requestCtx),
1109
+ eq(schema.vaultRequests.id, requestId),
1110
+ ctxScope(schema.vaultRequests, ctx),
1111
+ eq(schema.vaultRequests.status, "applying"),
994
1112
  ),
995
1113
  );
996
- let secret = existingSecrets[0] ?? null;
997
-
998
- if (!secret) {
999
- secret = await createSecret(
1000
- {
1001
- credentialKey: request.credentialKey,
1002
- value: secretValue,
1003
- name: secretName || request.credentialKey,
1004
- },
1005
- requestCtx,
1006
- );
1007
- }
1008
-
1009
- if (secret) {
1010
- // Create the grant in the request's tenant as well.
1011
- await createGrant(secret.id, request.appId, requestCtx);
1012
- }
1013
1114
 
1014
1115
  await recordVaultAudit({
1015
1116
  action: "request.approved",
1016
- appId: request.appId,
1017
- summary: `Approved ${request.credentialKey} for ${request.appId} (requested by ${request.requestedBy})`,
1117
+ appId: claimedRequest.appId,
1118
+ summary: `Approved ${claimedRequest.credentialKey} for ${claimedRequest.appId} (requested by ${claimedRequest.requestedBy})`,
1018
1119
  metadata: { requestId, reviewer },
1019
1120
  });
1020
1121
 
@@ -1029,14 +1130,11 @@ export async function denyRequest(
1029
1130
  const db = getDb();
1030
1131
  const request = await getRequest(requestId, ctx);
1031
1132
  if (!request) throw new Error("Request not found");
1032
- if (request.status !== "pending") {
1033
- throw new Error("Only pending requests can be denied");
1034
- }
1035
1133
 
1036
1134
  const timestamp = now();
1037
1135
  const reviewer = ctx.ownerEmail;
1038
1136
 
1039
- await db
1137
+ const claimed = await db
1040
1138
  .update(schema.vaultRequests)
1041
1139
  .set({
1042
1140
  status: "denied",
@@ -1048,13 +1146,21 @@ export async function denyRequest(
1048
1146
  and(
1049
1147
  eq(schema.vaultRequests.id, requestId),
1050
1148
  ctxScope(schema.vaultRequests, ctx),
1149
+ eq(schema.vaultRequests.status, "pending"),
1051
1150
  ),
1052
- );
1151
+ )
1152
+ .returning();
1153
+
1154
+ if (claimed.length === 0) {
1155
+ return getRequest(requestId, ctx);
1156
+ }
1157
+
1158
+ const claimedRequest = claimed[0];
1053
1159
 
1054
1160
  await recordVaultAudit({
1055
1161
  action: "request.denied",
1056
- appId: request.appId,
1057
- summary: `Denied ${request.credentialKey} for ${request.appId} (requested by ${request.requestedBy})`,
1162
+ appId: claimedRequest.appId,
1163
+ summary: `Denied ${claimedRequest.credentialKey} for ${claimedRequest.appId} (requested by ${claimedRequest.requestedBy})`,
1058
1164
  metadata: { requestId, reviewer, reason },
1059
1165
  });
1060
1166