@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
@@ -0,0 +1,197 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
5
+ import {
6
+ closeDb,
7
+ createAgent,
8
+ createPage,
9
+ createPageVersion,
10
+ createUser,
11
+ getDb,
12
+ getPage,
13
+ getPageVersions,
14
+ initDb,
15
+ listUserFavorites,
16
+ setUserFavorite,
17
+ upsertKv,
18
+ } from "../be/db";
19
+ import { registerDeletePageTool } from "../tools/delete-page";
20
+
21
+ const TEST_DB_PATH = "./test-delete-page-tool.sqlite";
22
+
23
+ type RegisteredTool = {
24
+ handler: (args: unknown, extra: unknown) => Promise<CallToolResult>;
25
+ };
26
+
27
+ function buildServer() {
28
+ const server = new McpServer({ name: "delete-page-test", version: "1.0.0" });
29
+ registerDeletePageTool(server);
30
+ const tools = (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
31
+ ._registeredTools;
32
+ const tool = tools["delete-page"];
33
+ if (!tool) throw new Error("delete-page tool not registered");
34
+ return tool;
35
+ }
36
+
37
+ function callDeletePage(args: Record<string, unknown>, callerAgentId?: string) {
38
+ const tool = buildServer();
39
+ return tool.handler(args, {
40
+ sessionId: "test-session",
41
+ requestInfo: callerAgentId ? { headers: { "x-agent-id": callerAgentId } } : { headers: {} },
42
+ });
43
+ }
44
+
45
+ function makePage(agentId: string, slug: string, title = slug) {
46
+ return createPage({
47
+ agentId,
48
+ slug,
49
+ title,
50
+ contentType: "text/html",
51
+ authMode: "public",
52
+ body: `<h1>${title}</h1>`,
53
+ });
54
+ }
55
+
56
+ let ownerId: string;
57
+ let otherAgentId: string;
58
+ let leadId: string;
59
+
60
+ describe("delete-page MCP tool", () => {
61
+ beforeAll(async () => {
62
+ for (const suffix of ["", "-wal", "-shm"]) {
63
+ try {
64
+ await unlink(`${TEST_DB_PATH}${suffix}`);
65
+ } catch {}
66
+ }
67
+ initDb(TEST_DB_PATH);
68
+ ownerId = createAgent({ name: "delete-page-owner", isLead: false, status: "idle" }).id;
69
+ otherAgentId = createAgent({
70
+ name: "delete-page-other",
71
+ isLead: false,
72
+ status: "idle",
73
+ }).id;
74
+ leadId = createAgent({ name: "delete-page-lead", isLead: true, status: "idle" }).id;
75
+ });
76
+
77
+ afterAll(async () => {
78
+ closeDb();
79
+ for (const suffix of ["", "-wal", "-shm"]) {
80
+ try {
81
+ await unlink(`${TEST_DB_PATH}${suffix}`);
82
+ } catch {}
83
+ }
84
+ });
85
+
86
+ test("owner can delete by pageId and receives deleted page metadata", async () => {
87
+ const page = makePage(ownerId, "owner-id-delete", "Owner Delete");
88
+
89
+ const result = await callDeletePage({ pageId: page.id }, ownerId);
90
+ const sc = result.structuredContent as {
91
+ success: boolean;
92
+ deletedPage?: { id: string; slug: string; title: string };
93
+ };
94
+
95
+ expect(sc.success).toBe(true);
96
+ expect(sc.deletedPage).toEqual({
97
+ id: page.id,
98
+ slug: "owner-id-delete",
99
+ title: "Owner Delete",
100
+ });
101
+ expect(getPage(page.id)).toBeNull();
102
+ });
103
+
104
+ test("slug deletes only from the caller's page namespace", async () => {
105
+ const ownerPage = makePage(ownerId, "shared-slug", "Owner Shared Slug");
106
+ const otherPage = makePage(otherAgentId, "shared-slug", "Other Shared Slug");
107
+
108
+ const result = await callDeletePage({ slug: "shared-slug" }, ownerId);
109
+ const sc = result.structuredContent as { success: boolean; deletedPage?: { id: string } };
110
+
111
+ expect(sc.success).toBe(true);
112
+ expect(sc.deletedPage?.id).toBe(ownerPage.id);
113
+ expect(getPage(ownerPage.id)).toBeNull();
114
+ expect(getPage(otherPage.id)).not.toBeNull();
115
+ });
116
+
117
+ test("lead can delete another agent's page by pageId", async () => {
118
+ const page = makePage(ownerId, "lead-delete", "Lead Delete");
119
+
120
+ const result = await callDeletePage({ pageId: page.id }, leadId);
121
+ const sc = result.structuredContent as { success: boolean };
122
+
123
+ expect(sc.success).toBe(true);
124
+ expect(getPage(page.id)).toBeNull();
125
+ });
126
+
127
+ test("non-owner non-lead gets a clear permission error and page remains", async () => {
128
+ const page = makePage(ownerId, "deny-delete", "Deny Delete");
129
+
130
+ const result = await callDeletePage({ pageId: page.id }, otherAgentId);
131
+ const sc = result.structuredContent as { success: boolean; message: string };
132
+
133
+ expect(sc.success).toBe(false);
134
+ expect(sc.message).toBe("Only the lead or page owner can delete pages.");
135
+ expect(getPage(page.id)).not.toBeNull();
136
+ });
137
+
138
+ test("delete cascades versions and removes page-scoped KV and favorites", async () => {
139
+ const page = makePage(ownerId, "cascade-delete", "Cascade Delete");
140
+ createPageVersion({
141
+ pageId: page.id,
142
+ version: 1,
143
+ snapshot: {
144
+ title: page.title,
145
+ description: page.description,
146
+ contentType: page.contentType,
147
+ authMode: page.authMode,
148
+ body: page.body,
149
+ needsCredentials: page.needsCredentials,
150
+ },
151
+ changedByAgentId: ownerId,
152
+ });
153
+ upsertKv({
154
+ namespace: `task:page:${page.id}`,
155
+ key: "state",
156
+ value: { ok: true },
157
+ valueType: "json",
158
+ });
159
+ const user = createUser({ name: "Delete Page Favorite User" });
160
+ setUserFavorite({
161
+ userId: user.id,
162
+ itemType: "page",
163
+ itemId: page.id,
164
+ favorite: true,
165
+ });
166
+
167
+ const result = await callDeletePage({ pageId: page.id }, ownerId);
168
+ const sc = result.structuredContent as { success: boolean };
169
+
170
+ expect(sc.success).toBe(true);
171
+ expect(getPageVersions(page.id)).toHaveLength(0);
172
+ expect(
173
+ getDb()
174
+ .prepare<{ n: number }, [string]>(
175
+ "SELECT COUNT(*) AS n FROM kv_entries WHERE namespace = ?",
176
+ )
177
+ .get(`task:page:${page.id}`)?.n,
178
+ ).toBe(0);
179
+ expect(listUserFavorites({ userId: user.id, itemType: "page" })).toHaveLength(0);
180
+ });
181
+
182
+ test("missing selector or missing agent id returns success false", async () => {
183
+ const noSelector = await callDeletePage({}, ownerId);
184
+ expect((noSelector.structuredContent as { success: boolean; message: string }).success).toBe(
185
+ false,
186
+ );
187
+ expect((noSelector.structuredContent as { message: string }).message).toBe(
188
+ "Either pageId or slug must be provided.",
189
+ );
190
+
191
+ const noAgent = await callDeletePage({ pageId: "missing" });
192
+ expect((noAgent.structuredContent as { success: boolean; message: string }).success).toBe(
193
+ false,
194
+ );
195
+ expect((noAgent.structuredContent as { message: string }).message).toContain("Agent ID");
196
+ });
197
+ });
@@ -119,11 +119,11 @@ function makeCommentEvent(senderLogin: string, body: string): CommentEvent {
119
119
  };
120
120
  }
121
121
 
122
- function makeReviewEvent(senderLogin: string): PullRequestReviewEvent {
122
+ function makeReviewEvent(senderLogin: string, reviewId = 1): PullRequestReviewEvent {
123
123
  return {
124
124
  action: "submitted",
125
125
  review: {
126
- id: 1,
126
+ id: reviewId,
127
127
  body: "Looks good",
128
128
  state: "approved",
129
129
  html_url: "https://github.com/test/repo/pull/99#pullrequestreview-1",
@@ -153,6 +153,13 @@ function getMappedUserTaskCount(userId: string): number {
153
153
  return row?.n ?? 0;
154
154
  }
155
155
 
156
+ function getLatestTaskText(): string | null {
157
+ const row = getDb()
158
+ .prepare<{ task: string }, []>("SELECT task FROM agent_tasks ORDER BY rowid DESC LIMIT 1")
159
+ .get();
160
+ return row?.task ?? null;
161
+ }
162
+
156
163
  // ── Known sender → mapped requestedByUserId, no unmapped writes ──
157
164
 
158
165
  describe("known github sender", () => {
@@ -216,6 +223,17 @@ describe("known github sender", () => {
216
223
  expect(getKv(UNMAPPED_NAMESPACE, "reviewer:meta")).toBeNull();
217
224
  expect(getKv(UNMAPPED_NAMESPACE, "reviewer:count")).toBeNull();
218
225
  });
226
+
227
+ test("review event from mapped user renders the resolved identity pair, never the raw login", async () => {
228
+ const user = createUser({ name: "Pair Reviewer", email: "pair-reviewer@example.com" });
229
+ linkIdentity(user.id, "github", "pair-reviewer", SYSTEM_ACTOR);
230
+
231
+ const result = await handlePullRequestReview(makeReviewEvent("pair-reviewer", 1001));
232
+ expect(result.created).toBe(true);
233
+
234
+ const text = getLatestTaskText();
235
+ expect(text).toContain("Pair Reviewer (github:pair-reviewer)");
236
+ });
219
237
  });
220
238
 
221
239
  // ── Unknown sender → unmapped kv tracker ──
@@ -290,6 +308,14 @@ describe("unknown github sender", () => {
290
308
  const metaValue = meta?.value as { sampleContext: string };
291
309
  expect(metaValue.sampleContext.length).toBeLessThanOrEqual(100);
292
310
  });
311
+
312
+ test("review event from unknown user renders the UNKNOWN sentinel, never a bare login", async () => {
313
+ const result = await handlePullRequestReview(makeReviewEvent("sentinel-ghost", 1002));
314
+ expect(result.created).toBe(true);
315
+
316
+ const text = getLatestTaskText();
317
+ expect(text).toContain("github:sentinel-ghost (unknown user)");
318
+ });
293
319
  });
294
320
 
295
321
  // ── Negative: no email-enrichment helper exists for GitHub ──
@@ -979,5 +979,130 @@ describe("identity resolution — Note handler", () => {
979
979
  expect((meta?.sampleContext as string).length).toBeLessThanOrEqual(100);
980
980
  expect((meta?.sampleContext as string).startsWith(`@${GITLAB_BOT_NAME}`)).toBe(true);
981
981
  expect(getUnmappedCount("noteghost")).toBe(1);
982
+
983
+ // The dead-coded `_requestedByUserId` is now wired: undefined for an
984
+ // unresolvable sender, and the UNKNOWN sentinel (never the raw
985
+ // username) is rendered as the comment author in the task text.
986
+ const task = getTaskById(result.taskId!);
987
+ expect(task?.requestedByUserId).toBeFalsy();
988
+ expect(task?.task).toContain("gitlab:noteghost (unknown user)");
989
+ expect(task?.task).not.toContain("Note Ghost");
990
+ });
991
+
992
+ test("known GitLab user commenting -> requestedByUserId populated + rendered pair, never the raw username", async () => {
993
+ const known = createUser({ name: "Known Commenter" });
994
+ linkIdentity(known.id, "gitlab", "knowncommenter", { kind: "system", id: "test" });
995
+
996
+ const event = makeNoteEvent({
997
+ user: { id: 32, name: "Known Commenter", username: "knowncommenter", avatar_url: "" },
998
+ object_attributes: {
999
+ id: 961,
1000
+ note: `@${GITLAB_BOT_NAME} please take a look`,
1001
+ noteable_type: "MergeRequest",
1002
+ noteable_id: 101,
1003
+ url: "https://gitlab.com/group/project/-/merge_requests/2#note_961",
1004
+ author_id: 32,
1005
+ type: null,
1006
+ },
1007
+ merge_request: {
1008
+ id: 101,
1009
+ iid: 961,
1010
+ title: "Known Commenter MR",
1011
+ description: "",
1012
+ state: "opened",
1013
+ action: "open",
1014
+ source_branch: "feat-knowncommenter",
1015
+ target_branch: "main",
1016
+ url: "https://gitlab.com/group/project/-/merge_requests/961",
1017
+ last_commit: null,
1018
+ author_id: 32,
1019
+ },
1020
+ });
1021
+
1022
+ const result = await handleNote(event);
1023
+ expect(result.created).toBe(true);
1024
+
1025
+ const task = getTaskById(result.taskId!);
1026
+ expect(task?.requestedByUserId).toBe(known.id);
1027
+ expect(task?.task).toContain("Known Commenter (gitlab:knowncommenter)");
1028
+ });
1029
+ });
1030
+
1031
+ describe("identity resolution — Pipeline handler", () => {
1032
+ test("known GitLab user triggers pipeline -> requestedByUserId populated on the task", async () => {
1033
+ const known = createUser({ name: "Pipeline Trigger" });
1034
+ linkIdentity(known.id, "gitlab", "pipelinetrigger", { kind: "system", id: "test" });
1035
+
1036
+ createTaskExtended("[GitLab MR #970] Pipeline identity test", {
1037
+ source: "gitlab",
1038
+ vcsProvider: "gitlab",
1039
+ vcsRepo: "group/project",
1040
+ vcsEventType: "merge_request",
1041
+ vcsNumber: 970,
1042
+ vcsUrl: "https://gitlab.com/group/project/-/merge_requests/970",
1043
+ agentId: "lead-gl-001",
1044
+ });
1045
+
1046
+ const event = makePipelineEvent({
1047
+ user: { id: 41, name: "Pipeline Trigger", username: "pipelinetrigger", avatar_url: "" },
1048
+ object_attributes: {
1049
+ id: 500,
1050
+ ref: "feature",
1051
+ status: "failed",
1052
+ source: "push",
1053
+ detailed_status: "failed",
1054
+ },
1055
+ merge_request: {
1056
+ id: 200,
1057
+ iid: 970,
1058
+ title: "MR with known pipeline trigger",
1059
+ url: "https://gitlab.com/group/project/-/merge_requests/970",
1060
+ source_branch: "feature",
1061
+ target_branch: "main",
1062
+ },
1063
+ });
1064
+
1065
+ const result = await handlePipeline(event);
1066
+ expect(result.created).toBe(true);
1067
+
1068
+ const task = getTaskById(result.taskId!);
1069
+ expect(task?.requestedByUserId).toBe(known.id);
1070
+ });
1071
+
1072
+ test("unknown GitLab user triggers pipeline -> requestedByUserId stays undefined", async () => {
1073
+ createTaskExtended("[GitLab MR #971] Pipeline identity test", {
1074
+ source: "gitlab",
1075
+ vcsProvider: "gitlab",
1076
+ vcsRepo: "group/project",
1077
+ vcsEventType: "merge_request",
1078
+ vcsNumber: 971,
1079
+ vcsUrl: "https://gitlab.com/group/project/-/merge_requests/971",
1080
+ agentId: "lead-gl-001",
1081
+ });
1082
+
1083
+ const event = makePipelineEvent({
1084
+ user: { id: 42, name: "Pipeline Ghost", username: "pipelineghost", avatar_url: "" },
1085
+ object_attributes: {
1086
+ id: 501,
1087
+ ref: "feature",
1088
+ status: "failed",
1089
+ source: "push",
1090
+ detailed_status: "failed",
1091
+ },
1092
+ merge_request: {
1093
+ id: 201,
1094
+ iid: 971,
1095
+ title: "MR with unknown pipeline trigger",
1096
+ url: "https://gitlab.com/group/project/-/merge_requests/971",
1097
+ source_branch: "feature",
1098
+ target_branch: "main",
1099
+ },
1100
+ });
1101
+
1102
+ const result = await handlePipeline(event);
1103
+ expect(result.created).toBe(true);
1104
+
1105
+ const task = getTaskById(result.taskId!);
1106
+ expect(task?.requestedByUserId).toBeFalsy();
982
1107
  });
983
1108
  });
@@ -559,7 +559,7 @@ describe("Heartbeat Triage", () => {
559
559
  expect(affected.length).toBe(0);
560
560
  });
561
561
 
562
- test("auto-fails in_progress task with no session and creates retry", async () => {
562
+ test("auto-fails in_progress task with no session and pins retry to the recovered agent", async () => {
563
563
  const agent = createAgent({ name: "dead-worker", isLead: false, status: "busy" });
564
564
  const task = createTaskExtended("Interrupted task", { agentId: agent.id });
565
565
  startTask(task.id);
@@ -586,8 +586,13 @@ describe("Heartbeat Triage", () => {
586
586
  expect(retryTask).not.toBeNull();
587
587
  expect(retryTask!.parentTaskId).toBe(task.id);
588
588
  expect(retryTask!.task).toBe(task.task);
589
- // No agentId goes to pool as "unassigned", auto-assign will route it
590
- expect(retryTask!.status).toBe("unassigned");
589
+ // Routing affinity (Phase 3): the failed task frees up the agent's only
590
+ // slot, so it looks recoverable (row exists, not offline, has capacity)
591
+ // by retry-creation time — the retry pins back to it instead of the
592
+ // role-blind pool.
593
+ expect(retryTask!.status).toBe("pending");
594
+ expect(retryTask!.agentId).toBe(agent.id);
595
+ expect(retryTask!.routingAffinity?.sourceAgentId).toBe(agent.id);
591
596
 
592
597
  // Verify retry has correct tags
593
598
  const retryRow = getDb()
@@ -596,6 +601,46 @@ describe("Heartbeat Triage", () => {
596
601
  const tags = JSON.parse(retryRow.tags);
597
602
  expect(tags).toContain("reboot-retry");
598
603
  expect(tags).toContain("auto-generated");
604
+ expect(tags).toContain("reboot-retry-pin");
605
+ });
606
+
607
+ test("falls back to an affinity-stamped pool retry when the agent is at capacity", async () => {
608
+ const agent = createAgent({
609
+ name: "full-worker",
610
+ isLead: false,
611
+ status: "busy",
612
+ maxTasks: 1,
613
+ });
614
+ const task = createTaskExtended("Interrupted task", { agentId: agent.id });
615
+ startTask(task.id);
616
+ // A second in-progress task with a LIVE session survives the same sweep
617
+ // (session-exists → skip, never reaped) and keeps the agent at capacity
618
+ // by the time `task`'s retry is evaluated — so `task`'s retry does NOT
619
+ // look recoverable and must fall to the affinity-gated pool.
620
+ const other = createTaskExtended("Other in-progress task", { agentId: agent.id });
621
+ startTask(other.id);
622
+ insertActiveSession({ agentId: agent.id, taskId: other.id, triggerType: "task_assigned" });
623
+
624
+ const past = new Date(Date.now() - 1000).toISOString();
625
+ getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [past, task.id]);
626
+
627
+ await runRebootSweep();
628
+
629
+ const affected = getRebootAffectedTasks();
630
+ expect(affected.length).toBe(1);
631
+ const retryTask = getTaskById(affected[0]!.retryTaskId!);
632
+ expect(retryTask).not.toBeNull();
633
+ // No agentId → falls to the pool as "unassigned", but still carries the
634
+ // routing-affinity snapshot so it's gated (not role-blind).
635
+ expect(retryTask!.status).toBe("unassigned");
636
+ expect(retryTask!.agentId).toBeNull();
637
+ expect(retryTask!.routingAffinity?.sourceAgentId).toBe(agent.id);
638
+
639
+ const retryRow = getDb()
640
+ .query("SELECT tags FROM agent_tasks WHERE id = ?")
641
+ .get(affected[0]!.retryTaskId!) as { tags: string };
642
+ const tags = JSON.parse(retryRow.tags);
643
+ expect(tags).not.toContain("reboot-retry-pin");
599
644
  });
600
645
 
601
646
  test("skips in_progress task that has an active session", async () => {
@@ -0,0 +1,117 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { closeDb, createUser, initDb } from "../be/db";
4
+ import { renderIdentity, resolveIdentity, resolveIdentityByEmail } from "../be/identity";
5
+ import { type IdentityActor, linkIdentity } from "../be/users";
6
+
7
+ const TEST_DB_PATH = "./test-identity.sqlite";
8
+
9
+ const SYSTEM_ACTOR: IdentityActor = { kind: "system", id: "test" };
10
+
11
+ beforeAll(async () => {
12
+ for (const suffix of ["", "-wal", "-shm"]) {
13
+ try {
14
+ await unlink(TEST_DB_PATH + suffix);
15
+ } catch {}
16
+ }
17
+ closeDb();
18
+ initDb(TEST_DB_PATH);
19
+ });
20
+
21
+ afterAll(async () => {
22
+ closeDb();
23
+ for (const suffix of ["", "-wal", "-shm"]) {
24
+ try {
25
+ await unlink(TEST_DB_PATH + suffix);
26
+ } catch {}
27
+ }
28
+ });
29
+
30
+ describe("resolveIdentity", () => {
31
+ test("resolved: linked (kind, externalId) returns the user", () => {
32
+ const user = createUser({ name: "Luis", email: "luis@example.com" });
33
+ linkIdentity(user.id, "slack", "U016H7XKZGS", SYSTEM_ACTOR);
34
+
35
+ const resolution = resolveIdentity("slack", "U016H7XKZGS");
36
+ expect(resolution).toEqual({
37
+ status: "resolved",
38
+ kind: "slack",
39
+ externalId: "U016H7XKZGS",
40
+ userId: user.id,
41
+ name: "Luis",
42
+ email: "luis@example.com",
43
+ });
44
+ });
45
+
46
+ test("unknown: unlinked (kind, externalId) returns the sentinel value, never a name", () => {
47
+ const resolution = resolveIdentity("slack", "U_DOES_NOT_EXIST");
48
+ expect(resolution).toEqual({
49
+ status: "unknown",
50
+ kind: "slack",
51
+ externalId: "U_DOES_NOT_EXIST",
52
+ });
53
+ });
54
+
55
+ test("is provider-agnostic — any kind string works identically", () => {
56
+ const user = createUser({ name: "Jira Reporter", email: "jira-reporter@example.com" });
57
+ linkIdentity(user.id, "jira", "5b10a2844c20165700ede21g", SYSTEM_ACTOR);
58
+
59
+ const resolution = resolveIdentity("jira", "5b10a2844c20165700ede21g");
60
+ expect(resolution.status).toBe("resolved");
61
+ expect(resolution.status === "resolved" && resolution.userId).toBe(user.id);
62
+ });
63
+ });
64
+
65
+ describe("resolveIdentityByEmail", () => {
66
+ test("resolved: known email returns the user with kind 'email'", () => {
67
+ const user = createUser({ name: "Alberto", email: "alberto@example.com" });
68
+
69
+ const resolution = resolveIdentityByEmail("alberto@example.com");
70
+ expect(resolution).toEqual({
71
+ status: "resolved",
72
+ kind: "email",
73
+ externalId: "alberto@example.com",
74
+ userId: user.id,
75
+ name: "Alberto",
76
+ email: "alberto@example.com",
77
+ });
78
+ });
79
+
80
+ test("unknown: unregistered email returns the sentinel value", () => {
81
+ const resolution = resolveIdentityByEmail("nobody@example.com");
82
+ expect(resolution).toEqual({
83
+ status: "unknown",
84
+ kind: "email",
85
+ externalId: "nobody@example.com",
86
+ });
87
+ });
88
+ });
89
+
90
+ describe("renderIdentity", () => {
91
+ test("resolved renders 'Name (kind:externalId)'", () => {
92
+ const rendered = renderIdentity({
93
+ status: "resolved",
94
+ kind: "slack",
95
+ externalId: "U016H7XKZGS",
96
+ userId: "u1",
97
+ name: "Luis",
98
+ });
99
+ expect(rendered).toBe("Luis (slack:U016H7XKZGS)");
100
+ });
101
+
102
+ test("unknown renders 'kind:externalId (unknown user)' — the sentinel never contains a name", () => {
103
+ const rendered = renderIdentity({
104
+ status: "unknown",
105
+ kind: "slack",
106
+ externalId: "U016H7XKZGS",
107
+ });
108
+ expect(rendered).toBe("slack:U016H7XKZGS (unknown user)");
109
+ expect(rendered).not.toContain("Luis");
110
+ });
111
+
112
+ test("the sentinel is deterministic for the same input", () => {
113
+ const resolution = resolveIdentity("github", "octocat-unlinked");
114
+ expect(renderIdentity(resolution)).toBe(renderIdentity(resolution));
115
+ expect(renderIdentity(resolution)).toBe("github:octocat-unlinked (unknown user)");
116
+ });
117
+ });