@desplega.ai/agent-swarm 1.113.0 → 1.115.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.
Files changed (237) hide show
  1. package/README.md +2 -2
  2. package/dist/{actions-q4n7cz6e.js → actions-17ya5wde.js} +8 -9
  3. package/dist/{app-es4nzc71.js → app-4f718yk0.js} +6 -6
  4. package/dist/{assistant-0x8ey0vs.js → assistant-ah7csnjz.js} +19 -18
  5. package/dist/{boot-reembed-69agkhv6.js → boot-reembed-4jpxhb25.js} +6 -6
  6. package/dist/{boot-reembed-p0ny05t8.js → boot-reembed-qata34xa.js} +5 -5
  7. package/dist/{boot-scrub-logs-yybrkmvr.js → boot-scrub-logs-tv48nn3z.js} +4 -4
  8. package/dist/{claude-adapter-mma9a1yr.js → claude-adapter-3csn5bks.js} +3 -3
  9. package/dist/{claude-managed-adapter-pv2nbqy4.js → claude-managed-adapter-kysmkz89.js} +2 -2
  10. package/dist/cli-0pygq9r1.js +429 -0
  11. package/dist/{cli-hsetkkd3.js → cli-12vb0xg6.js} +33 -9
  12. package/dist/{cli-ncfgsqbd.js → cli-15y7qhzp.js} +262 -33
  13. package/dist/{cli-7ga43spr.js → cli-1yjrbg1f.js} +1 -1
  14. package/dist/{cli-36ymmpmp.js → cli-3samkyap.js} +2 -2
  15. package/dist/cli-52fg7s7x.js +269 -0
  16. package/dist/{cli-hjhvekf4.js → cli-65ekje01.js} +11 -8
  17. package/dist/{cli-h3k622d0.js → cli-6aq8xhvm.js} +90 -3
  18. package/dist/{cli-f3qa069v.js → cli-7xje5amz.js} +1 -1
  19. package/dist/cli-86fdnerz.js +405 -0
  20. package/dist/{cli-9cy6nc5m.js → cli-8fjwym53.js} +1 -1
  21. package/dist/{cli-c3frqf65.js → cli-b23axgz4.js} +3 -3
  22. package/dist/{cli-a0t7vapf.js → cli-bc89my0z.js} +1 -1
  23. package/dist/{cli-bnzbn8j8.js → cli-beea4a3y.js} +73 -24
  24. package/dist/{cli-q21d49ac.js → cli-bw80ck94.js} +1 -1
  25. package/dist/{cli-pwc5e83c.js → cli-cchksf5q.js} +12 -7
  26. package/dist/{cli-30bbaveh.js → cli-cmw9rzy5.js} +2 -2
  27. package/dist/{cli-y6mrptcn.js → cli-cnwssmsr.js} +1 -1
  28. package/dist/{cli-r5g6ngtn.js → cli-dcca6j8m.js} +46 -5
  29. package/dist/{cli-tkmm5nf9.js → cli-e61fs2hp.js} +59 -2
  30. package/dist/{cli-6wscj9z0.js → cli-e7306swv.js} +14 -14
  31. package/dist/{cli-dcfyyh3t.js → cli-ftyf58xj.js} +2 -2
  32. package/dist/{cli-4xyj9jtq.js → cli-hrhye2rm.js} +1062 -478
  33. package/dist/cli-jkhwmmgg.js +136 -0
  34. package/dist/{cli-y4ycrnjc.js → cli-k3y4z3h5.js} +1 -1
  35. package/dist/{cli-6xd0bvf8.js → cli-ma04faw5.js} +1 -1
  36. package/dist/{cli-2fc97xm5.js → cli-mnbgv3w7.js} +4 -4
  37. package/dist/{cli-trmapn9k.js → cli-pet3aev6.js} +95 -10
  38. package/dist/{cli-z46pnksz.js → cli-q1rx7g0d.js} +175 -28
  39. package/dist/{cli-qwgtacd6.js → cli-r9f6ngrf.js} +1244 -336
  40. package/dist/{cli-gtkqc144.js → cli-shvaxw27.js} +13 -7
  41. package/dist/{cli-ywez6yps.js → cli-sq7f72d6.js} +1 -1
  42. package/dist/cli-t0vqevnm.js +393 -0
  43. package/dist/{cli-tbw13sx8.js → cli-tece6zgm.js} +1 -1
  44. package/dist/{cli-bgvskydy.js → cli-tffvrh6j.js} +1 -1
  45. package/dist/cli-wh91zby6.js +187222 -0
  46. package/dist/{cli-anrj584m.js → cli-xz9aq0rf.js} +1 -1
  47. package/dist/{cli-mq580e06.js → cli-zsrdty74.js} +18 -18
  48. package/dist/cli.js +32 -15
  49. package/dist/{codex-adapter-5cmy858w.js → codex-adapter-de3w0nch.js} +10 -10
  50. package/dist/{codex-session-runner-46q8mrba.js → codex-session-runner-bnw8cysk.js} +10 -10
  51. package/dist/{commands-z5dbwta3.js → commands-hch4e2k1.js} +4 -4
  52. package/dist/{db-bbgahh4z.js → db-p3h2j06x.js} +14 -4
  53. package/dist/{handlers-6b44317z.js → handlers-9er4zx9q.js} +17 -31
  54. package/dist/{hook-sbp5fmps.js → hook-4axs756z.js} +7 -7
  55. package/dist/{http-2xz43gz3.js → http-809fvtz2.js} +1934 -886
  56. package/dist/{index-wz1ea15p.js → index-2dgfj9av.js} +3 -3
  57. package/dist/{index-nhat35em.js → index-47em3d2n.js} +14 -11
  58. package/dist/{index-5ghxn8s6.js → index-9fqaktjr.js} +12 -9
  59. package/dist/{index-0gzmqj4k.js → index-9ht8gr56.js} +13 -10
  60. package/dist/{index-vfedgdw6.js → index-rm85cfdx.js} +368 -40
  61. package/dist/{index-ykvs43z4.js → index-t2snazez.js} +4 -4
  62. package/dist/{keepalive-62gfj91d.js → keepalive-3cps6e3e.js} +7 -6
  63. package/dist/{lead-ag0akn0v.js → lead-fkk95wgq.js} +36 -32
  64. package/dist/{maintenance-xxrrzk5x.js → maintenance-fweqh0yj.js} +6 -6
  65. package/dist/{mistral-conversations-26yrnbf1.js → mistral-conversations-7gqnz2wt.js} +7 -7
  66. package/dist/oauth-refresh-sweep-sycqtbph.js +114 -0
  67. package/dist/{onboard-64zyfcbs.js → onboard-cy0rkgvx.js} +2 -2
  68. package/dist/{opencode-adapter-mqsxr170.js → opencode-adapter-p6bxnbm4.js} +2 -2
  69. package/dist/{otel-zjd9zaxs.js → otel-g299k41b.js} +2 -2
  70. package/dist/{otel-impl-b86qseaa.js → otel-impl-wbns8hf7.js} +1 -1
  71. package/dist/{pi-mono-adapter-kxjxpfgy.js → pi-mono-adapter-05sck1ds.js} +17 -109
  72. package/dist/{pricing-refresh-cgtkffpa.js → pricing-refresh-0hvazmkf.js} +6 -6
  73. package/dist/rbac-roles-kfesjy9y.js +28 -0
  74. package/dist/rbac-roles-mwcwhbxc.js +283 -0
  75. package/dist/{seed-pricing-1r1y1xkq.js → seed-pricing-h2kdq4cq.js} +5 -5
  76. package/dist/{setup-mkcgkjf7.js → setup-8506q97w.js} +2 -2
  77. package/dist/{worker-351sze63.js → worker-4r5c6zyb.js} +36 -32
  78. package/openapi.json +1327 -20
  79. package/package.json +3 -3
  80. package/src/agentmail/handlers.ts +16 -6
  81. package/src/agentmail/templates.ts +25 -5
  82. package/src/be/audit-user.ts +28 -4
  83. package/src/be/db-queries/oauth.ts +42 -0
  84. package/src/be/db.ts +314 -28
  85. package/src/be/identity.ts +79 -0
  86. package/src/be/mcp-proxy.ts +132 -0
  87. package/src/be/migrations/109_rbac_roles.sql +79 -0
  88. package/src/be/migrations/111_oauth_credential_bindings.sql +6 -0
  89. package/src/be/migrations/112_script_connections_graphql.sql +96 -0
  90. package/src/be/migrations/113_task_routing_affinity.sql +9 -0
  91. package/src/be/migrations/114_backfill_gpt_5_6_pricing.sql +15 -0
  92. package/src/be/modelsdev-cache.json +135 -0
  93. package/src/be/oauth-credential-bindings.ts +35 -0
  94. package/src/be/oauth-refresh-sweep.ts +159 -0
  95. package/src/be/rbac-audit.ts +52 -13
  96. package/src/be/rbac-roles.ts +424 -0
  97. package/src/be/script-connections.ts +459 -40
  98. package/src/be/script-credential-broker.ts +29 -3
  99. package/src/be/scripts/typecheck.ts +51 -7
  100. package/src/be/seed-scripts/catalog/catalog-report.inline.ts +325 -24
  101. package/src/be/seed-scripts/catalog/catalog-report.ts +325 -24
  102. package/src/be/users.ts +24 -0
  103. package/src/cli.tsx +17 -0
  104. package/src/github/handlers.ts +10 -9
  105. package/src/github/templates.ts +45 -9
  106. package/src/gitlab/handlers.ts +20 -8
  107. package/src/gitlab/templates.ts +18 -6
  108. package/src/heartbeat/heartbeat.ts +143 -21
  109. package/src/http/all-routes.ts +3 -0
  110. package/src/http/approval-requests.ts +12 -0
  111. package/src/http/config.ts +44 -10
  112. package/src/http/core.ts +30 -3
  113. package/src/http/favorites.ts +1 -5
  114. package/src/http/index.ts +25 -1
  115. package/src/http/mcp-oauth.ts +2 -0
  116. package/src/http/mcp-servers.ts +44 -3
  117. package/src/http/oauth-generic.ts +105 -0
  118. package/src/http/poll.ts +20 -3
  119. package/src/http/script-connection-proxy.ts +116 -0
  120. package/src/http/script-connections.ts +1657 -0
  121. package/src/http/scripts.ts +27 -7
  122. package/src/http/skills.ts +11 -0
  123. package/src/http/tasks.ts +27 -13
  124. package/src/http/x.ts +6 -2
  125. package/src/integrations/kapso/inbound.ts +23 -6
  126. package/src/jira/sync.ts +111 -17
  127. package/src/jira/templates.ts +10 -2
  128. package/src/mcp-client/http-client.ts +194 -0
  129. package/src/oauth/app-validation.ts +16 -0
  130. package/src/oauth/ensure-token.ts +69 -14
  131. package/src/oauth/mcp-wrapper.ts +15 -0
  132. package/src/oauth/wrapper.ts +48 -20
  133. package/src/prompts/session-templates.ts +3 -3
  134. package/src/providers/codex-models.ts +100 -30
  135. package/src/providers/modelsdev-reasoning.json +51 -0
  136. package/src/providers/pi-mono-mcp-client.ts +2 -133
  137. package/src/providers/reasoning-effort.ts +11 -5
  138. package/src/rbac/admission.ts +68 -0
  139. package/src/rbac/index.ts +2 -0
  140. package/src/rbac/legacy-policy.ts +12 -0
  141. package/src/rbac/permissions.ts +48 -0
  142. package/src/scheduler/scheduler.ts +9 -1
  143. package/src/scripts-runtime/api-client.ts +151 -19
  144. package/src/scripts-runtime/api-types.ts +38 -6
  145. package/src/scripts-runtime/credential-broker/default-bindings.ts +1 -0
  146. package/src/scripts-runtime/credential-broker/types.ts +8 -0
  147. package/src/scripts-runtime/ctx.ts +11 -1
  148. package/src/scripts-runtime/eval-harness.ts +5 -1
  149. package/src/scripts-runtime/executors/types.ts +2 -1
  150. package/src/scripts-runtime/loader.ts +3 -1
  151. package/src/scripts-runtime/mcp-client.ts +87 -0
  152. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  153. package/src/scripts-runtime/types/stdlib.d.ts +6 -0
  154. package/src/scripts-runtime/types/swarm-sdk.d.ts +19 -0
  155. package/src/server-user.ts +114 -59
  156. package/src/server.ts +12 -0
  157. package/src/slack/assistant.ts +14 -6
  158. package/src/slack/enrich.ts +17 -0
  159. package/src/slack/handlers.ts +8 -22
  160. package/src/slack/responses.ts +91 -1
  161. package/src/slack/thread-buffer.ts +6 -3
  162. package/src/slack/watcher.ts +9 -6
  163. package/src/tasks/worker-follow-up.ts +100 -3
  164. package/src/tests/agentmail-handlers.test.ts +32 -3
  165. package/src/tests/approval-requests.test.ts +61 -0
  166. package/src/tests/audit-user.test.ts +47 -1
  167. package/src/tests/codex-adapter.test.ts +46 -23
  168. package/src/tests/credential-broker.test.ts +5 -2
  169. package/src/tests/delete-page-tool.test.ts +197 -0
  170. package/src/tests/github-handlers.test.ts +28 -2
  171. package/src/tests/gitlab-handlers.test.ts +125 -0
  172. package/src/tests/heartbeat.test.ts +48 -3
  173. package/src/tests/identity.test.ts +117 -0
  174. package/src/tests/jira-sync.test.ts +99 -1
  175. package/src/tests/kapso-inbound.test.ts +7 -0
  176. package/src/tests/mcp-tools-user.test.ts +60 -6
  177. package/src/tests/model-control.test.ts +8 -2
  178. package/src/tests/oauth-credential-bindings.test.ts +490 -0
  179. package/src/tests/oauth-refresh-sweep.test.ts +160 -0
  180. package/src/tests/pool-affinity.test.ts +401 -0
  181. package/src/tests/prompt-template-remaining.test.ts +2 -2
  182. package/src/tests/rbac-admission-e2e.test.ts +386 -0
  183. package/src/tests/rbac-admission.test.ts +492 -0
  184. package/src/tests/rbac-e2e-helpers.ts +3 -0
  185. package/src/tests/rbac-engine.test.ts +50 -0
  186. package/src/tests/rbac-mcp-admission-e2e.test.ts +362 -0
  187. package/src/tests/rbac-roles.test.ts +328 -0
  188. package/src/tests/reasoning-effort.test.ts +15 -2
  189. package/src/tests/schedule-target-type.test.ts +50 -0
  190. package/src/tests/scheduled-tasks.test.ts +40 -0
  191. package/src/tests/script-apis-mcp.test.ts +37 -13
  192. package/src/tests/script-connections-http.test.ts +1158 -0
  193. package/src/tests/script-connections-mcp.test.ts +894 -0
  194. package/src/tests/script-connections.test.ts +754 -6
  195. package/src/tests/scripts-external-api.test.ts +49 -1
  196. package/src/tests/scripts-runtime-secret-egress.test.ts +1 -0
  197. package/src/tests/scripts-runtime.test.ts +291 -0
  198. package/src/tests/seed-scripts.test.ts +36 -0
  199. package/src/tests/slack-identity-resolution.test.ts +37 -3
  200. package/src/tests/slack-inline-output.test.ts +65 -0
  201. package/src/tests/slack-thread-buffer.test.ts +28 -0
  202. package/src/tests/tool-annotations.test.ts +1 -0
  203. package/src/tests/user-token-rest-auth.test.ts +140 -1
  204. package/src/tests/workflow-swarm-script.test.ts +44 -0
  205. package/src/tests/workflow-triggers-v2.test.ts +300 -1
  206. package/src/tools/credential-bindings/tool.ts +277 -18
  207. package/src/tools/delete-page.ts +145 -0
  208. package/src/tools/mcp-servers/mcp-server-delete.ts +7 -4
  209. package/src/tools/request-human-input.ts +2 -0
  210. package/src/tools/resolve-user.ts +77 -27
  211. package/src/tools/script-apis.ts +62 -3
  212. package/src/tools/script-connections/tool.ts +275 -31
  213. package/src/tools/send-task.ts +15 -1
  214. package/src/tools/task-action.ts +34 -2
  215. package/src/tools/templates.ts +53 -1
  216. package/src/tools/tool-config.ts +2 -1
  217. package/src/tools/utils.ts +2 -0
  218. package/src/tools/workflows/create-workflow.ts +8 -1
  219. package/src/tools/workflows/update-workflow.ts +11 -2
  220. package/src/types.ts +90 -11
  221. package/src/utils/scoped-resource.ts +25 -0
  222. package/src/workflows/executors/human-in-the-loop.ts +1 -0
  223. package/src/workflows/executors/swarm-script.ts +7 -1
  224. package/src/workflows/triggers.ts +146 -18
  225. package/templates/skills/swarm-scripts/content.md +33 -8
  226. package/dist/cli-bgef578c.js +0 -771
  227. package/dist/cli-c9mtm09b.js +0 -43
  228. package/dist/cli-ceewqajw.js +0 -1698
  229. package/dist/{anthropic-messages-8nc6r2dw.js → anthropic-messages-kq3pjhbp.js} +6 -6
  230. package/dist/{azure-openai-responses-6yfzw3qw.js → azure-openai-responses-gm1ejtmn.js} +7 -7
  231. package/dist/{cli-vbraamxr.js → cli-6b4wwmm8.js} +9 -9
  232. package/dist/{cli-2r7s8jhp.js → cli-sznberje.js} +3 -3
  233. package/dist/{google-generative-ai-vfq6ymz9.js → google-generative-ai-hyrra2jm.js} +3 -3
  234. package/dist/{google-vertex-mj5y5rfj.js → google-vertex-8bs2pzgn.js} +3 -3
  235. package/dist/{openai-codex-responses-8h6kvfv8.js → openai-codex-responses-3nkvje79.js} +4 -4
  236. package/dist/{openai-completions-x6kjnqjd.js → openai-completions-0asftkwg.js} +11 -11
  237. package/dist/{openai-responses-7jwyz3ds.js → openai-responses-pmatek45.js} +10 -10
package/src/be/db.ts CHANGED
@@ -62,6 +62,7 @@ import type {
62
62
  ProviderName,
63
63
  ReasoningEffort,
64
64
  RepoGuidelines,
65
+ RoutingAffinity,
65
66
  ScheduledTask,
66
67
  ScheduledTaskSummary,
67
68
  ScriptRun,
@@ -108,6 +109,7 @@ import {
108
109
  type ModelTier,
109
110
  parseModelTier,
110
111
  ReasoningEffortSchema,
112
+ RoutingAffinitySchema,
111
113
  } from "../types";
112
114
  import { deriveProviderFromKeyType } from "../utils/credentials";
113
115
  import type { RateLimitWindowTelemetry } from "../utils/error-tracker";
@@ -1028,6 +1030,75 @@ export function updateAgentStatusFromCapacity(agentId: string): void {
1028
1030
  }
1029
1031
  }
1030
1032
 
1033
+ // ============================================================================
1034
+ // Routing Affinity (interrupted/pooled task role & capability gating)
1035
+ // ============================================================================
1036
+
1037
+ /**
1038
+ * Kill-switch for the pool eligibility gate (`isAgentEligibleForTask` and its
1039
+ * callers: `claimTask`, `assignUnassignedTaskPending`,
1040
+ * `getUnassignedTaskIdsForAgent`). ON by default. Set to `0` to restore
1041
+ * pre-affinity behavior verbatim — mirrors the `HEARTBEAT_PIN_*_RESUME`
1042
+ * rollback convention. A function (read dynamically), not a module-load-time
1043
+ * const, so it can be toggled mid-test (see `isGracefulResumePinEnabled` in
1044
+ * src/tasks/worker-follow-up.ts for the same pattern).
1045
+ */
1046
+ export function isPoolAffinityEnforcementEnabled(): boolean {
1047
+ return process.env.POOL_AFFINITY_ENFORCEMENT !== "0";
1048
+ }
1049
+
1050
+ /**
1051
+ * Snapshot an agent's role/harness/capabilities into a `RoutingAffinity`
1052
+ * blob, for stamping onto a continuation task (resume, retry) at the moment
1053
+ * of interruption. Returns `null` when the agent row is already gone —
1054
+ * callers fall back to the parent's own (inherited) `routingAffinity` via
1055
+ * `createTaskExtended`'s parentTaskId inheritance block.
1056
+ */
1057
+ export function buildRoutingAffinityFromAgent(agentId: string): RoutingAffinity | null {
1058
+ const agent = getAgentById(agentId);
1059
+ if (!agent) return null;
1060
+ return {
1061
+ sourceAgentId: agent.id,
1062
+ role: agent.role,
1063
+ harnessProvider: agent.harnessProvider ?? agent.provider ?? undefined,
1064
+ capabilities: agent.capabilities ?? [],
1065
+ };
1066
+ }
1067
+
1068
+ /**
1069
+ * The single eligibility gate every pool consumer (poll auto-claim,
1070
+ * `task-action claim`, `autoAssignPoolTasks`) MUST use before handing a task
1071
+ * to an agent. Exact-match on the snapshotted role string (no keyword
1072
+ * taxonomy in v1); `harnessProvider` is informational only and never
1073
+ * enforced (native session resume is deprecated). Missing role data on
1074
+ * either side is treated as INELIGIBLE — never fail-open to "anyone" — so a
1075
+ * capability-only requirement (no `role` set) can only ever be claimed by
1076
+ * its `sourceAgentId`, and otherwise queues until the starvation escalation
1077
+ * hands it to the Lead.
1078
+ */
1079
+ export function isAgentEligibleForTask(
1080
+ agent: Pick<Agent, "id" | "role" | "capabilities">,
1081
+ task: Pick<AgentTask, "routingAffinity">,
1082
+ ): boolean {
1083
+ if (!isPoolAffinityEnforcementEnabled()) return true;
1084
+
1085
+ const affinity = task.routingAffinity;
1086
+ if (!affinity) return true; // Untagged task — unchanged behavior.
1087
+
1088
+ if (affinity.sourceAgentId && affinity.sourceAgentId === agent.id) return true; // Own work.
1089
+
1090
+ if (!agent.role || !affinity.role) return false; // Missing role data — no fail-open.
1091
+ if (agent.role !== affinity.role) return false;
1092
+
1093
+ const requiredCapabilities = affinity.capabilities ?? [];
1094
+ if (requiredCapabilities.length > 0) {
1095
+ const agentCapabilities = new Set(agent.capabilities ?? []);
1096
+ if (!requiredCapabilities.every((cap) => agentCapabilities.has(cap))) return false;
1097
+ }
1098
+
1099
+ return true;
1100
+ }
1101
+
1031
1102
  // ============================================================================
1032
1103
  // AgentTask Queries
1033
1104
  // ============================================================================
@@ -1100,6 +1171,7 @@ type AgentTaskRow = {
1100
1171
  harnessVariant: string | null;
1101
1172
  harnessVariantMeta: string | null;
1102
1173
  totalCostUsd?: number | null;
1174
+ routingAffinity: string | null;
1103
1175
  };
1104
1176
 
1105
1177
  function rowToAgentTask(row: AgentTaskRow): AgentTask {
@@ -1124,6 +1196,26 @@ function rowToAgentTask(row: AgentTaskRow): AgentTask {
1124
1196
  }
1125
1197
  }
1126
1198
 
1199
+ let routingAffinity: RoutingAffinity | undefined;
1200
+ if (row.routingAffinity) {
1201
+ try {
1202
+ const parsed = RoutingAffinitySchema.safeParse(JSON.parse(row.routingAffinity));
1203
+ if (parsed.success) {
1204
+ routingAffinity = parsed.data;
1205
+ } else {
1206
+ console.warn(
1207
+ `[db] Ignoring invalid agent_tasks.routingAffinity for task ${row.id}:`,
1208
+ parsed.error.message,
1209
+ );
1210
+ }
1211
+ } catch (error) {
1212
+ console.warn(
1213
+ `[db] Ignoring malformed agent_tasks.routingAffinity for task ${row.id}:`,
1214
+ error instanceof Error ? error.message : String(error),
1215
+ );
1216
+ }
1217
+ }
1218
+
1127
1219
  return {
1128
1220
  id: row.id,
1129
1221
  agentId: row.agentId,
@@ -1194,6 +1286,7 @@ function rowToAgentTask(row: AgentTaskRow): AgentTask {
1194
1286
  harnessVariant: row.harnessVariant ?? undefined,
1195
1287
  harnessVariantMeta: row.harnessVariantMeta ? JSON.parse(row.harnessVariantMeta) : undefined,
1196
1288
  totalCostUsd: row.totalCostUsd ?? undefined,
1289
+ routingAffinity,
1197
1290
  };
1198
1291
  }
1199
1292
 
@@ -1359,6 +1452,29 @@ export function getPendingTaskForAgent(agentId: string): AgentTask | null {
1359
1452
  }
1360
1453
 
1361
1454
  export function assignUnassignedTaskPending(taskId: string, agentId: string): AgentTask | null {
1455
+ // Eligibility pre-check (routing affinity) — defense in depth for the
1456
+ // heartbeat's `autoAssignPoolTasks`, which already filters candidates via
1457
+ // `isAgentEligibleForTask` before calling this, but any other caller gets
1458
+ // the same guard for free.
1459
+ if (isPoolAffinityEnforcementEnabled()) {
1460
+ const task = getTaskById(taskId);
1461
+ const agent = getAgentById(agentId);
1462
+ if (task && agent && !isAgentEligibleForTask(agent, task)) {
1463
+ try {
1464
+ createLogEntry({
1465
+ eventType: "task_claim_rejected_affinity",
1466
+ agentId,
1467
+ taskId,
1468
+ metadata: {
1469
+ agentRole: agent.role ?? null,
1470
+ requiredRole: task.routingAffinity?.role ?? null,
1471
+ },
1472
+ });
1473
+ } catch {}
1474
+ return null;
1475
+ }
1476
+ }
1477
+
1362
1478
  const now = new Date().toISOString();
1363
1479
  const row = getDb()
1364
1480
  .prepare<AgentTaskRow, [string, string, string]>(
@@ -1636,8 +1752,12 @@ export interface TaskFilters {
1636
1752
  source?: AgentTaskSource[];
1637
1753
  /** ISO 8601 timestamp; only return tasks where createdAt >= this. */
1638
1754
  createdAfter?: string;
1755
+ /** ISO 8601 timestamp; only return tasks where createdAt < this. */
1756
+ createdBefore?: string;
1639
1757
  /** Only return tasks requested by this canonical user. NULL rows are excluded. */
1640
1758
  requestedByUserId?: string;
1759
+ /** Sort list rows for either table freshness or timeline paging. */
1760
+ orderBy?: "lastUpdatedAt" | "createdAt";
1641
1761
  limit?: number;
1642
1762
  offset?: number;
1643
1763
  includeHeartbeat?: boolean;
@@ -1721,6 +1841,11 @@ export function getAllTasks(
1721
1841
  params.push(filters.createdAfter);
1722
1842
  }
1723
1843
 
1844
+ if (filters?.createdBefore) {
1845
+ conditions.push("createdAt < ?");
1846
+ params.push(filters.createdBefore);
1847
+ }
1848
+
1724
1849
  if (filters?.requestedByUserId) {
1725
1850
  conditions.push("requestedByUserId = ?");
1726
1851
  params.push(filters.requestedByUserId);
@@ -1739,10 +1864,14 @@ export function getAllTasks(
1739
1864
  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
1740
1865
  const limit = filters?.limit ?? 25;
1741
1866
  const offset = filters?.offset ?? 0;
1867
+ const orderBy =
1868
+ filters?.orderBy === "createdAt"
1869
+ ? "createdAt DESC, rowid DESC"
1870
+ : "lastUpdatedAt DESC, priority DESC";
1742
1871
  const query = `SELECT agent_tasks.*,
1743
1872
  (SELECT SUM(totalCostUsd) FROM session_costs WHERE session_costs.taskId = agent_tasks.id) AS totalCostUsd
1744
1873
  FROM agent_tasks ${whereClause}
1745
- ORDER BY lastUpdatedAt DESC, priority DESC LIMIT ${limit} OFFSET ${offset}`;
1874
+ ORDER BY ${orderBy} LIMIT ${limit} OFFSET ${offset}`;
1746
1875
 
1747
1876
  const rows = getDb()
1748
1877
  .prepare<AgentTaskRow, (string | AgentTaskStatus)[]>(query)
@@ -1838,6 +1967,16 @@ export function getTasksCount(filters?: Omit<TaskFilters, "limit" | "readyOnly">
1838
1967
  params.push(filters.createdAfter);
1839
1968
  }
1840
1969
 
1970
+ if (filters?.createdBefore) {
1971
+ conditions.push("createdAt < ?");
1972
+ params.push(filters.createdBefore);
1973
+ }
1974
+
1975
+ if (filters?.requestedByUserId) {
1976
+ conditions.push("requestedByUserId = ?");
1977
+ params.push(filters.requestedByUserId);
1978
+ }
1979
+
1841
1980
  // Exclude system/heartbeat tasks by default. The flag is still called
1842
1981
  // `includeHeartbeat` for backward compat with existing API callers, but we
1843
1982
  // also gate boot-triage + heartbeat-checklist behind it since those are
@@ -3243,6 +3382,12 @@ export interface CreateTaskOptions {
3243
3382
  followUpConfig?: FollowUpConfig;
3244
3383
  requestedByUserId?: string;
3245
3384
  contextKey?: string;
3385
+ /**
3386
+ * Routing-affinity snapshot gating pool eligibility (see
3387
+ * `isAgentEligibleForTask`). Inherited from the parent (via `parentTaskId`)
3388
+ * when not explicitly set — same treatment as `vcsRepo`/`contextKey`.
3389
+ */
3390
+ routingAffinity?: RoutingAffinity;
3246
3391
  }
3247
3392
 
3248
3393
  /**
@@ -3414,6 +3559,9 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
3414
3559
  if (parent.followUpConfig && !options.followUpConfig) {
3415
3560
  options.followUpConfig = parent.followUpConfig;
3416
3561
  }
3562
+ if (parent.routingAffinity && !options.routingAffinity) {
3563
+ options.routingAffinity = parent.routingAffinity;
3564
+ }
3417
3565
  }
3418
3566
  }
3419
3567
 
@@ -3448,8 +3596,8 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
3448
3596
  vcsInstallationId, vcsNodeId,
3449
3597
  agentmailInboxId, agentmailMessageId, agentmailThreadId,
3450
3598
  mentionMessageId, mentionChannelId, dir, parentTaskId, model, modelTier, effort, scheduleId,
3451
- workflowRunId, workflowRunStepId, outputSchema, followUpConfig, requestedByUserId, contextKey, swarmVersion, createdAt, lastUpdatedAt, created_by, updated_by
3452
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
3599
+ workflowRunId, workflowRunStepId, outputSchema, followUpConfig, requestedByUserId, contextKey, routingAffinity, swarmVersion, createdAt, lastUpdatedAt, created_by, updated_by
3600
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
3453
3601
  )
3454
3602
  .get(
3455
3603
  id,
@@ -3493,6 +3641,7 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
3493
3641
  options?.followUpConfig ? JSON.stringify(options.followUpConfig) : null,
3494
3642
  options?.requestedByUserId ?? null,
3495
3643
  options?.contextKey ?? null,
3644
+ options?.routingAffinity ? JSON.stringify(options.routingAffinity) : null,
3496
3645
  pkg.version,
3497
3646
  now,
3498
3647
  now,
@@ -3542,6 +3691,28 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
3542
3691
  }
3543
3692
 
3544
3693
  export function claimTask(taskId: string, agentId: string): AgentTask | null {
3694
+ // Eligibility pre-check (routing affinity): static per (agent, task), so
3695
+ // pre-filtering here does NOT reopen the claim race — the atomic UPDATE
3696
+ // below still arbitrates concurrent claims by eligible agents.
3697
+ if (isPoolAffinityEnforcementEnabled()) {
3698
+ const task = getTaskById(taskId);
3699
+ const agent = getAgentById(agentId);
3700
+ if (task && agent && !isAgentEligibleForTask(agent, task)) {
3701
+ try {
3702
+ createLogEntry({
3703
+ eventType: "task_claim_rejected_affinity",
3704
+ agentId,
3705
+ taskId,
3706
+ metadata: {
3707
+ agentRole: agent.role ?? null,
3708
+ requiredRole: task.routingAffinity?.role ?? null,
3709
+ },
3710
+ });
3711
+ } catch {}
3712
+ return null;
3713
+ }
3714
+ }
3715
+
3545
3716
  // Atomic claim: single UPDATE with WHERE guard ensures exactly-once claiming.
3546
3717
  // No pre-read needed — the WHERE clause handles the race condition.
3547
3718
  // Status goes directly to 'in_progress' because the claiming session is
@@ -3806,6 +3977,61 @@ export function getUnassignedTaskIds(limit = 10): string[] {
3806
3977
  return rows.map((r) => r.id);
3807
3978
  }
3808
3979
 
3980
+ /**
3981
+ * Batch size and hard cap for the paginated eligibility scans in
3982
+ * `getUnassignedTaskIdsForAgent` (and `autoAssignPoolTasks` in
3983
+ * src/heartbeat/heartbeat.ts, which mirrors this pattern). A fixed single
3984
+ * window used to mean N ineligible affinity-tagged tasks at the head of the
3985
+ * priority order could hide all eligible work behind them forever — see
3986
+ * PR #954 review. Scanning continues page-by-page until `limit` eligible
3987
+ * candidates are found or the pool is exhausted; the cap bounds worst-case
3988
+ * DB load when eligible work is buried deep or genuinely absent.
3989
+ */
3990
+ const ELIGIBILITY_SCAN_BATCH_SIZE = Number(process.env.ELIGIBILITY_SCAN_BATCH_SIZE) || 25;
3991
+ const ELIGIBILITY_SCAN_CAP = Number(process.env.ELIGIBILITY_SCAN_CAP) || 500;
3992
+
3993
+ /**
3994
+ * Same ordering as `getUnassignedTaskIds`, filtered through
3995
+ * `isAgentEligibleForTask` for the requesting agent. Used by the poll
3996
+ * auto-claim path so an ineligible candidate is never even offered to the
3997
+ * budget gate / claim loop. Paginates through the unassigned pool in
3998
+ * `ELIGIBILITY_SCAN_BATCH_SIZE`-row windows (filtering in JS to avoid
3999
+ * JSON-parsing `routingAffinity` in SQL) until `limit` eligible tasks are
4000
+ * found or the pool is exhausted, capped at `ELIGIBILITY_SCAN_CAP` rows
4001
+ * scanned so a pool full of ineligible tasks can't turn every poll into an
4002
+ * unbounded scan.
4003
+ */
4004
+ export function getUnassignedTaskIdsForAgent(agentId: string, limit = 10): string[] {
4005
+ const agent = getAgentById(agentId);
4006
+ if (!agent) return [];
4007
+
4008
+ const batchSize = Math.max(limit * 5, ELIGIBILITY_SCAN_BATCH_SIZE);
4009
+ const eligible: string[] = [];
4010
+ let offset = 0;
4011
+
4012
+ while (eligible.length < limit && offset < ELIGIBILITY_SCAN_CAP) {
4013
+ const rows = getDb()
4014
+ .prepare<AgentTaskRow, [number, number]>(
4015
+ "SELECT * FROM agent_tasks WHERE status = 'unassigned' ORDER BY priority DESC, createdAt ASC, rowid ASC LIMIT ? OFFSET ?",
4016
+ )
4017
+ .all(batchSize, offset);
4018
+ if (rows.length === 0) break;
4019
+
4020
+ for (const row of rows) {
4021
+ const task = rowToAgentTask(row);
4022
+ if (isAgentEligibleForTask(agent, task)) {
4023
+ eligible.push(task.id);
4024
+ if (eligible.length >= limit) break;
4025
+ }
4026
+ }
4027
+
4028
+ offset += rows.length;
4029
+ if (rows.length < batchSize) break; // Exhausted the pool.
4030
+ }
4031
+
4032
+ return eligible;
4033
+ }
4034
+
3809
4035
  // ============================================================================
3810
4036
  // Dependency Checking
3811
4037
  // ============================================================================
@@ -6936,16 +7162,22 @@ export function getStalledInProgressTasks(thresholdMinutes: number = 30): AgentT
6936
7162
  }
6937
7163
 
6938
7164
  /**
6939
- * Genuine same-agent protected resume PINS (tagged `crash-recovery-pin` or
6940
- * `graceful-shutdown-pin`) that are still `pending` `graceMin` minutes after
6941
- * creation the heartbeat reaper escalates these to a Lead reroute-decision.
7165
+ * Genuine same-agent protected pins resumes tagged `crash-recovery-pin` /
7166
+ * `graceful-shutdown-pin`, OR a reboot-retry child tagged `reboot-retry-pin`
7167
+ * (routing-affinity Phase 3) that are still `pending` `graceMin` minutes
7168
+ * after creation. The heartbeat reaper escalates these to a Lead
7169
+ * reroute-decision.
6942
7170
  *
6943
- * Three scoping clauses, each load-bearing:
6944
- * - pin tags restricts to resumes actually pinned to their original agent on
6945
- * protected paths. Without this, a *pooled* resume that `autoAssignPoolTasks`
6946
- * flips to `pending` earlier in the SAME sweep (keeping its old `createdAt`)
6947
- * would be reaped and cancelled before the assigned worker polls; it also
6948
- * keeps `context_limits` / `manual_supersede` pins from being escalated under
7171
+ * Scoping clauses, each load-bearing:
7172
+ * - `taskType = 'resume' AND (crash/graceful pin tags)` OR `reboot-retry-pin`
7173
+ * tag alone restricts to work actually pinned to its original agent on a
7174
+ * protected path. A reboot-retry-pin task is a FRESH task (`taskType`
7175
+ * mirrors the original work, not `'resume'`), so it needs its own
7176
+ * disjunct rather than reusing the `taskType = 'resume'` gate. Without
7177
+ * this, a *pooled* resume that `autoAssignPoolTasks` flips to `pending`
7178
+ * earlier in the SAME sweep (keeping its old `createdAt`) would be reaped
7179
+ * and cancelled before the assigned worker polls; it also keeps
7180
+ * `context_limits` / `manual_supersede` pins from being escalated under
6949
7181
  * the protected-pin label. (Literals must match the pin tag constants in
6950
7182
  * src/tasks/worker-follow-up.ts.)
6951
7183
  * - `status = 'pending'` — the "currently unreclaimed" discriminator: when the
@@ -6966,8 +7198,11 @@ export function getStalePinnedResumes(graceMin: number): AgentTask[] {
6966
7198
  return getDb()
6967
7199
  .prepare<AgentTaskRow, [string]>(
6968
7200
  `SELECT * FROM agent_tasks
6969
- WHERE taskType = 'resume' AND status = 'pending'
6970
- AND (tags LIKE '%"crash-recovery-pin"%' OR tags LIKE '%"graceful-shutdown-pin"%')
7201
+ WHERE status = 'pending'
7202
+ AND (
7203
+ (taskType = 'resume' AND (tags LIKE '%"crash-recovery-pin"%' OR tags LIKE '%"graceful-shutdown-pin"%'))
7204
+ OR tags LIKE '%"reboot-retry-pin"%'
7205
+ )
6971
7206
  AND createdAt < ?
6972
7207
  ORDER BY createdAt ASC`,
6973
7208
  )
@@ -7037,18 +7272,39 @@ export function getIdleWorkersWithCapacity(): Agent[] {
7037
7272
  }
7038
7273
 
7039
7274
  /**
7040
- * Get unassigned pool tasks ordered by priority (DESC) then creation time (ASC).
7041
- * Used by the heartbeat for auto-assignment.
7275
+ * Get unassigned pool tasks ordered by priority (DESC), creation time (ASC),
7276
+ * then `rowid` (ASC) as a stable tiebreaker. The `rowid` tiebreaker matters
7277
+ * once `offset` is used for pagination (`autoAssignPoolTasks` in
7278
+ * src/heartbeat/heartbeat.ts) — without it, rows sharing a `createdAt` could
7279
+ * be skipped or repeated across pages. Used by the heartbeat for
7280
+ * auto-assignment and status reporting.
7042
7281
  */
7043
- export function getUnassignedPoolTasks(limit: number = 10): AgentTask[] {
7282
+ export function getUnassignedPoolTasks(limit: number = 10, offset: number = 0): AgentTask[] {
7044
7283
  return getDb()
7045
- .prepare<AgentTaskRow, [number]>(
7284
+ .prepare<AgentTaskRow, [number, number]>(
7046
7285
  `SELECT * FROM agent_tasks
7047
7286
  WHERE status = 'unassigned'
7048
- ORDER BY priority DESC, createdAt ASC
7049
- LIMIT ?`,
7287
+ ORDER BY priority DESC, createdAt ASC, rowid ASC
7288
+ LIMIT ? OFFSET ?`,
7050
7289
  )
7051
- .all(limit)
7290
+ .all(limit, offset)
7291
+ .map(rowToAgentTask);
7292
+ }
7293
+
7294
+ /**
7295
+ * Affinity-tagged pool tasks that have sat `unassigned` past `cutoffIso` —
7296
+ * the starvation-escalation candidate set (routing-affinity Phase 3). Callers
7297
+ * MUST separately confirm zero registered agents satisfy
7298
+ * `isAgentEligibleForTask` before escalating; this only narrows by tag age.
7299
+ */
7300
+ export function getStaleUnassignedAffinityTasks(cutoffIso: string): AgentTask[] {
7301
+ return getDb()
7302
+ .prepare<AgentTaskRow, [string]>(
7303
+ `SELECT * FROM agent_tasks
7304
+ WHERE status = 'unassigned' AND routingAffinity IS NOT NULL AND createdAt < ?
7305
+ ORDER BY createdAt ASC`,
7306
+ )
7307
+ .all(cutoffIso)
7052
7308
  .map(rowToAgentTask);
7053
7309
  }
7054
7310
 
@@ -8081,8 +8337,12 @@ export function updatePage(
8081
8337
  }
8082
8338
 
8083
8339
  export function deletePage(id: string): boolean {
8084
- // ON DELETE CASCADE on page_versions.pageId handles history cleanup.
8085
- const result = getDb().run("DELETE FROM pages WHERE id = ?", [id]);
8340
+ const result = getDb().transaction(() => {
8341
+ getDb().run("DELETE FROM user_favorites WHERE itemType = 'page' AND itemId = ?", [id]);
8342
+ getDb().run("DELETE FROM kv_entries WHERE namespace = ?", [`task:page:${id}`]);
8343
+ // ON DELETE CASCADE on page_versions.pageId handles history cleanup.
8344
+ return getDb().run("DELETE FROM pages WHERE id = ?", [id]);
8345
+ })();
8086
8346
  return result.changes > 0;
8087
8347
  }
8088
8348
 
@@ -8894,6 +9154,7 @@ export interface ApprovalRequest {
8894
9154
  timeoutSeconds: number | null;
8895
9155
  expiresAt: string | null;
8896
9156
  notificationChannels: unknown[] | null;
9157
+ createdBy?: string;
8897
9158
  createdAt: string;
8898
9159
  updatedAt: string;
8899
9160
  }
@@ -8913,6 +9174,7 @@ interface ApprovalRequestRow {
8913
9174
  timeoutSeconds: number | null;
8914
9175
  expiresAt: string | null;
8915
9176
  notificationChannels: string | null;
9177
+ created_by: string | null;
8916
9178
  createdAt: string;
8917
9179
  updatedAt: string;
8918
9180
  }
@@ -8933,6 +9195,7 @@ function rowToApprovalRequest(row: ApprovalRequestRow): ApprovalRequest {
8933
9195
  timeoutSeconds: row.timeoutSeconds,
8934
9196
  expiresAt: normalizeDate(row.expiresAt),
8935
9197
  notificationChannels: row.notificationChannels ? JSON.parse(row.notificationChannels) : null,
9198
+ createdBy: row.created_by ?? undefined,
8936
9199
  createdAt: normalizeDateRequired(row.createdAt),
8937
9200
  updatedAt: normalizeDateRequired(row.updatedAt),
8938
9201
  };
@@ -8948,6 +9211,7 @@ export function createApprovalRequest(data: {
8948
9211
  sourceTaskId?: string;
8949
9212
  timeoutSeconds?: number;
8950
9213
  notificationChannels?: unknown[];
9214
+ createdBy?: string;
8951
9215
  }): ApprovalRequest {
8952
9216
  const now = new Date().toISOString();
8953
9217
  const expiresAt = data.timeoutSeconds
@@ -8968,12 +9232,13 @@ export function createApprovalRequest(data: {
8968
9232
  number | null,
8969
9233
  string | null,
8970
9234
  string | null,
9235
+ string | null,
8971
9236
  string,
8972
9237
  string,
8973
9238
  ]
8974
9239
  >(
8975
- `INSERT INTO approval_requests (id, title, questions, workflowRunId, workflowRunStepId, sourceTaskId, approvers, timeoutSeconds, expiresAt, notificationChannels, createdAt, updatedAt)
8976
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
9240
+ `INSERT INTO approval_requests (id, title, questions, workflowRunId, workflowRunStepId, sourceTaskId, approvers, timeoutSeconds, expiresAt, notificationChannels, created_by, createdAt, updatedAt)
9241
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
8977
9242
  RETURNING *`,
8978
9243
  )
8979
9244
  .get(
@@ -8987,6 +9252,7 @@ export function createApprovalRequest(data: {
8987
9252
  data.timeoutSeconds ?? null,
8988
9253
  expiresAt,
8989
9254
  data.notificationChannels ? JSON.stringify(data.notificationChannels) : null,
9255
+ data.createdBy ?? null,
8990
9256
  now,
8991
9257
  now,
8992
9258
  );
@@ -10288,9 +10554,29 @@ export function updateMcpServer(
10288
10554
  return row ? rowToMcpServer(row) : null;
10289
10555
  }
10290
10556
 
10291
- export function deleteMcpServer(id: string): boolean {
10292
- const result = getDb().prepare("DELETE FROM mcp_servers WHERE id = ?").run(id);
10293
- return result.changes > 0;
10557
+ export type DeleteMcpServerResult = {
10558
+ deleted: boolean;
10559
+ deletedScriptConnectionCount: number;
10560
+ };
10561
+
10562
+ export function deleteMcpServer(id: string): DeleteMcpServerResult {
10563
+ const db = getDb();
10564
+ const existing = db
10565
+ .prepare<{ id: string }, [string]>("SELECT id FROM mcp_servers WHERE id = ?")
10566
+ .get(id);
10567
+ if (!existing) return { deleted: false, deletedScriptConnectionCount: 0 };
10568
+
10569
+ const tx = db.transaction(() => {
10570
+ const deletedConnections = db
10571
+ .prepare("DELETE FROM script_connections WHERE mcp_server_id = ?")
10572
+ .run(id);
10573
+ const deletedServer = db.prepare("DELETE FROM mcp_servers WHERE id = ?").run(id);
10574
+ return {
10575
+ deleted: deletedServer.changes > 0,
10576
+ deletedScriptConnectionCount: deletedConnections.changes,
10577
+ };
10578
+ });
10579
+ return tx();
10294
10580
  }
10295
10581
 
10296
10582
  export function getMcpServerById(id: string): McpServer | null {
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The framework's single identity-resolution primitive.
3
+ *
4
+ * Invariant (Rule 33 / provenance-or-silence): the swarm's sole responsibility
5
+ * for identity is the reverse lookup `(kind, externalId)` (or email) → linked
6
+ * user → canonical name, via `findUserByExternalId` / `findUserByEmail`. If
7
+ * the lookup points nowhere, callers render the explicit UNKNOWN sentinel —
8
+ * NEVER a provider display name, NEVER a guess. Every provider adapter that
9
+ * renders a human identity into agent-visible text MUST go through
10
+ * `renderIdentity(resolveIdentity(...))` (or `resolveIdentityByEmail`).
11
+ *
12
+ * Pure DB reads — zero provider API calls. `unknown` is a value, not an
13
+ * error: callers branch on `status`, never throw or fall back to a
14
+ * provider-supplied label.
15
+ *
16
+ * This module is API-side ONLY (see the DB-boundary note in `./users.ts`).
17
+ */
18
+
19
+ import { findUserByEmail, findUserByExternalId } from "./users";
20
+
21
+ export type IdentityResolution =
22
+ | {
23
+ status: "resolved";
24
+ kind: string;
25
+ externalId: string;
26
+ userId: string;
27
+ name: string;
28
+ email?: string;
29
+ }
30
+ | { status: "unknown"; kind: string; externalId: string };
31
+
32
+ /** Reverse lookup by `(kind, externalId)` — e.g. `resolveIdentity('slack', 'U016H7XKZGS')`. */
33
+ export function resolveIdentity(kind: string, externalId: string): IdentityResolution {
34
+ const user = findUserByExternalId(kind, externalId);
35
+ if (!user) {
36
+ return { status: "unknown", kind, externalId };
37
+ }
38
+ return {
39
+ status: "resolved",
40
+ kind,
41
+ externalId,
42
+ userId: user.id,
43
+ name: user.name,
44
+ email: user.email,
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Reverse lookup by email (primary or alias). Email is a `users.email`
50
+ * attribute, not an external-id kind — rendered with `kind: "email"` so
51
+ * `renderIdentity` produces a consistent pair form regardless of provider.
52
+ */
53
+ export function resolveIdentityByEmail(email: string): IdentityResolution {
54
+ const user = findUserByEmail(email);
55
+ if (!user) {
56
+ return { status: "unknown", kind: "email", externalId: email };
57
+ }
58
+ return {
59
+ status: "resolved",
60
+ kind: "email",
61
+ externalId: email,
62
+ userId: user.id,
63
+ name: user.name,
64
+ email: user.email,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Render an `IdentityResolution` into agent-visible text — the only two
70
+ * shapes an identity may ever take. The sentinel keeps the raw id but never
71
+ * a name: an unresolved identity must never be confused for a real one.
72
+ */
73
+ export function renderIdentity(resolution: IdentityResolution): string {
74
+ const pair = `${resolution.kind}:${resolution.externalId}`;
75
+ if (resolution.status === "resolved") {
76
+ return `${resolution.name} (${pair})`;
77
+ }
78
+ return `${pair} (unknown user)`;
79
+ }