@desplega.ai/agent-swarm 2.4.0 → 2.4.1

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 (222) hide show
  1. package/README.md +1 -1
  2. package/dist/{acp-adapter-kedstxx6.js → acp-adapter-zvzr538d.js} +9 -9
  3. package/dist/{actions-vget6r1k.js → actions-jhtqdw69.js} +21 -21
  4. package/dist/{amp-adapter-xeb5mktv.js → amp-adapter-bd84rhs2.js} +4 -4
  5. package/dist/{app-mqap8t8m.js → app-kreq1gwg.js} +3 -3
  6. package/dist/{assistant-h0gv97mv.js → assistant-3k22sbsq.js} +22 -22
  7. package/dist/{boot-reembed-qr8a894z.js → boot-reembed-e53x6b9v.js} +1 -1
  8. package/dist/{boot-reembed-rj85jjhw.js → boot-reembed-f6z923ny.js} +1 -1
  9. package/dist/{boot-scrub-sweep-eyfvn6v9.js → boot-scrub-sweep-0538vsb7.js} +4 -4
  10. package/dist/{boot-seeds-xs9a8dx1.js → boot-seeds-6wxqkfk2.js} +4 -4
  11. package/dist/{claude-adapter-bm23cye7.js → claude-adapter-gfts5044.js} +9 -9
  12. package/dist/{claude-managed-adapter-1mwrsm9d.js → claude-managed-adapter-h2y889ge.js} +2 -2
  13. package/dist/{claude-managed-setup-gkfgks4j.js → claude-managed-setup-h00hsgtz.js} +2 -2
  14. package/dist/{claude-sdk-session-apb4ypz3.js → claude-sdk-session-56besmfz.js} +13 -13
  15. package/dist/{cli-nrrg0fvy.js → cli-0bh477zn.js} +18 -5
  16. package/dist/{cli-7gh7020n.js → cli-1dmcq2eh.js} +1 -1
  17. package/dist/{cli-7ayprxp7.js → cli-2dfbv8qr.js} +8 -0
  18. package/dist/{cli-1cevz46c.js → cli-3kh4kpqy.js} +6 -6
  19. package/dist/{cli-tg9ksbn8.js → cli-3p8fqcnh.js} +2 -2
  20. package/dist/{cli-x1p5dhq2.js → cli-3r448dqn.js} +3 -3
  21. package/dist/{cli-ecshf504.js → cli-3tnpj2bh.js} +7 -7
  22. package/dist/{cli-ffbrkh1r.js → cli-471qk6s2.js} +46 -38
  23. package/dist/{cli-a49vpbf3.js → cli-5kqnemen.js} +1 -1
  24. package/dist/{cli-zvze9rbm.js → cli-6ncjvx55.js} +3 -3
  25. package/dist/{cli-fb6t92v8.js → cli-6x9ma5w5.js} +1 -1
  26. package/dist/{cli-c8fwpgqb.js → cli-7225gwj7.js} +2 -2
  27. package/dist/{cli-bn26xbcs.js → cli-8nyhf7za.js} +1 -1
  28. package/dist/{cli-y83zk5sb.js → cli-a0kcty51.js} +4 -4
  29. package/dist/{cli-8b9wvjps.js → cli-a4gvwttf.js} +4 -4
  30. package/dist/{cli-jhp7w1fy.js → cli-a5ebc95n.js} +2 -2
  31. package/dist/{cli-mz1ezzzh.js → cli-ash6xxk7.js} +2 -2
  32. package/dist/{cli-rsd7ga7v.js → cli-axh29tyr.js} +11 -11
  33. package/dist/{cli-p22zdvnt.js → cli-bn0rewsy.js} +3 -3
  34. package/dist/{cli-6ftrrwm1.js → cli-bn5kevnf.js} +5 -5
  35. package/dist/{cli-p1m2b6gc.js → cli-d2mb5s8a.js} +7 -7
  36. package/dist/{cli-c5sbmw1v.js → cli-d82btycs.js} +94 -15
  37. package/dist/{cli-vjfpshn3.js → cli-ejs3m1sx.js} +1 -1
  38. package/dist/{cli-a1esmmee.js → cli-fpmsqq59.js} +7 -2
  39. package/dist/{cli-8cxkw1f5.js → cli-fz9kewbh.js} +2 -2
  40. package/dist/{cli-8rwvksbc.js → cli-g0hgtz4p.js} +5 -5
  41. package/dist/{cli-kz116kvw.js → cli-gf0kfg81.js} +7 -7
  42. package/dist/{cli-eqgfs202.js → cli-hqhzg1qq.js} +2 -2
  43. package/dist/{cli-3wjs22x3.js → cli-j4rqdf2n.js} +2 -2
  44. package/dist/{cli-ywyrffff.js → cli-jh0qcrga.js} +1 -1
  45. package/dist/{cli-ttaz6c7m.js → cli-k9z4zd4p.js} +6 -5
  46. package/dist/{cli-nme7wery.js → cli-kmqgmcnb.js} +46 -13
  47. package/dist/{cli-x719j3vs.js → cli-kr2pmfqx.js} +392 -168
  48. package/dist/{cli-wrv09v7q.js → cli-m2qgh5sv.js} +1 -1
  49. package/dist/{cli-xfe63zx3.js → cli-mfe7f7b5.js} +2 -2
  50. package/dist/{cli-x3en6ct3.js → cli-nwyw3zhg.js} +1 -1
  51. package/dist/{cli-5s5hx57y.js → cli-nx462e4v.js} +1 -1
  52. package/dist/{cli-bfc3k1x5.js → cli-p0rakbrk.js} +9 -9
  53. package/dist/{cli-avmqhatn.js → cli-p6argxh9.js} +8 -8
  54. package/dist/{cli-3avfh5az.js → cli-pemywhfq.js} +2 -2
  55. package/dist/{cli-sppc0cmw.js → cli-pnp2j5zg.js} +2 -2
  56. package/dist/{cli-wk8ccrz6.js → cli-pw7v69vp.js} +2 -2
  57. package/dist/{cli-bhwa8rzc.js → cli-rdq7tzwm.js} +13 -12
  58. package/dist/{cli-rg7ntypm.js → cli-rqqsfz30.js} +379 -87
  59. package/dist/{cli-kk2r80n5.js → cli-s48yvzb8.js} +4 -4
  60. package/dist/{cli-zv4vy2b0.js → cli-s95dknxh.js} +1 -1
  61. package/dist/{cli-b80a1100.js → cli-sjyetj21.js} +2 -2
  62. package/dist/{cli-rdr166ht.js → cli-smvcw9z5.js} +1 -1
  63. package/dist/{cli-2rwet3r7.js → cli-sv5gb0yb.js} +1 -1
  64. package/dist/{cli-5ff2sjyt.js → cli-syhq378v.js} +2 -2
  65. package/dist/{cli-tj4j2ts4.js → cli-tbhwrxpt.js} +8 -8
  66. package/dist/{cli-4fhyq711.js → cli-vy0fay6k.js} +13 -13
  67. package/dist/{cli-ptkpaqfy.js → cli-wt645v7q.js} +1 -1
  68. package/dist/{cli-f3nx1h8z.js → cli-wxbn3yfx.js} +2 -2
  69. package/dist/{cli-wyrr38xm.js → cli-x5mq1fyq.js} +1 -1
  70. package/dist/{cli-ccn0eybf.js → cli-xbzvet96.js} +6 -6
  71. package/dist/{cli-4mkvwgzr.js → cli-xf234ws8.js} +1 -1
  72. package/dist/{cli-tgvpmnwz.js → cli-xftrvxag.js} +5 -5
  73. package/dist/{cli-02vhhssk.js → cli-z071fb95.js} +5 -5
  74. package/dist/{cli-mvg2hf8p.js → cli-z1ew8vbq.js} +14 -7
  75. package/dist/{cli-dsgj3e7a.js → cli-zfg9enh9.js} +2 -2
  76. package/dist/{cli-yxqag76b.js → cli-zw88zjba.js} +1 -1
  77. package/dist/cli.js +19 -19
  78. package/dist/{codex-adapter-pp2gq8gr.js → codex-adapter-p6rmm7vv.js} +6 -6
  79. package/dist/{codex-hook-qwzccd64.js → codex-hook-x7nejyrr.js} +7 -4
  80. package/dist/{codex-login-35p4sk85.js → codex-login-m819c41r.js} +1 -1
  81. package/dist/{codex-session-runner-0vzn09t8.js → codex-session-runner-8vd5t9t0.js} +7 -7
  82. package/dist/{commands-psqhkttb.js → commands-7ems2p13.js} +2 -2
  83. package/dist/{controller-t20vz2k8.js → controller-m263fg43.js} +4 -4
  84. package/dist/{controllers-connect-gkxmqw9d.js → controllers-connect-bg33ae53.js} +23 -23
  85. package/dist/{cursor-adapter-3v542qe5.js → cursor-adapter-cz6nm6mj.js} +3 -3
  86. package/dist/{db-2xbyenjr.js → db-w505dwa8.js} +2 -2
  87. package/dist/{db-nc1rd869.js → db-ys2rbdft.js} +3 -3
  88. package/dist/{devin-adapter-7egzmt0k.js → devin-adapter-zgqadvnm.js} +1 -1
  89. package/dist/{dispatcher-ewceb8ts.js → dispatcher-0s0mckqr.js} +20 -20
  90. package/dist/{docker-njavd3fb.js → docker-fkgtr75t.js} +2 -2
  91. package/dist/{dsh-adapter-75xwh78t.js → dsh-adapter-3n3net33.js} +3 -3
  92. package/dist/{e2b-5r6a5jeb.js → e2b-7am6tmq6.js} +2 -2
  93. package/dist/{e2b-pw1nvtmh.js → e2b-thdcxtjd.js} +2 -2
  94. package/dist/{easter-egg-3d-xsc47jwf.js → easter-egg-3d-5cgynaja.js} +1 -1
  95. package/dist/{event-bus-101fenkh.js → event-bus-9vpk8gmq.js} +1 -1
  96. package/dist/{execute-gzmw1mnw.js → execute-t4e6m3rw.js} +1 -1
  97. package/dist/{external-channel-a5dsmxn4.js → external-channel-5b23fv8z.js} +2 -2
  98. package/dist/{grok-adapter-vj88xr23.js → grok-adapter-q9fghe5q.js} +10 -10
  99. package/dist/handlers-mqrym347.js +39 -0
  100. package/dist/{heartbeat-t2m9r3wa.js → heartbeat-tww6yx1w.js} +23 -23
  101. package/dist/{hook-xa677f8r.js → hook-dx0pkvar.js} +16 -9
  102. package/dist/{hook-8femx1bg.js → hook-pvmh9b6m.js} +6 -6
  103. package/dist/{http-xpmmq028.js → http-k2ejbt05.js} +199 -131
  104. package/dist/{index-s7ckdajq.js → index-2z710v4x.js} +10 -10
  105. package/dist/{index-dg2tmj99.js → index-dgz61nff.js} +20 -20
  106. package/dist/{index-syztqdt6.js → index-g6tbb6ve.js} +1 -1
  107. package/dist/index-h2ek08d6.js +40 -0
  108. package/dist/index-pqfwh9nn.js +41 -0
  109. package/dist/{index-cdzyfrsp.js → index-x5aktmp8.js} +1 -1
  110. package/dist/{keepalive-3nr4st3r.js → keepalive-nhvpw10z.js} +4 -4
  111. package/dist/lead-jb6sf69z.js +78 -0
  112. package/dist/{maintenance-verwpe2c.js → maintenance-agakc519.js} +1 -1
  113. package/dist/{oauth-refresh-sweep-9q2t0y3q.js → oauth-refresh-sweep-4dcbhtn0.js} +3 -3
  114. package/dist/{onboard-aytqedmq.js → onboard-4n2kwa4v.js} +3 -3
  115. package/dist/{opencode-adapter-f4hryjny.js → opencode-adapter-3s7ax02e.js} +7 -7
  116. package/dist/{otel-impl-hckw4wt1.js → otel-impl-tfa12080.js} +2 -2
  117. package/dist/{pi-mono-adapter-hxby0ce1.js → pi-mono-adapter-ytp7gz8b.js} +22 -11
  118. package/dist/{pricing-refresh-5nvme3sm.js → pricing-refresh-3cqx3f3x.js} +3 -3
  119. package/dist/{rbac-roles-pd8c26m1.js → rbac-roles-9fae5rnv.js} +1 -1
  120. package/dist/{rbac-roles-vbpy21mg.js → rbac-roles-y6zbfzve.js} +2 -2
  121. package/dist/{render-v2-7nqgvk2q.js → render-v2-emawjvc4.js} +3 -3
  122. package/dist/{resume-b6djcj3c.js → resume-zhgts7h7.js} +10 -8
  123. package/dist/{runtime-txrxc9za.js → runtime-xe3aqy4x.js} +1 -1
  124. package/dist/{session-access-fc3f9y12.js → session-access-cm823e06.js} +4 -4
  125. package/dist/{setup-n39szm6b.js → setup-tkp99w1e.js} +1 -1
  126. package/dist/{virtual-modules-wyp2k6z3.js → virtual-modules-4r3zkc20.js} +2 -2
  127. package/dist/{work-objects-jem2fre0.js → work-objects-f722h263.js} +23 -23
  128. package/dist/worker-p7f336rq.js +78 -0
  129. package/dist/{x-8ewt8gxn.js → x-y45mthwn.js} +2 -2
  130. package/openapi.json +99 -52
  131. package/package.json +2 -2
  132. package/src/apps/action-runner.ts +146 -0
  133. package/src/apps/row-store.ts +4 -2
  134. package/src/apps/script-types.ts +1 -1
  135. package/src/be/db.ts +14 -4
  136. package/src/be/fleet-benchmark-stats.ts +10 -5
  137. package/src/be/models-catalog.ts +4 -0
  138. package/src/be/onboarding.ts +6 -0
  139. package/src/be/scripts/typecheck.ts +13 -0
  140. package/src/be/swarm-config-guard.ts +10 -1
  141. package/src/be/telemetry-enable.ts +1 -1
  142. package/src/commands/provider-credentials.ts +12 -2
  143. package/src/commands/runner.ts +3 -0
  144. package/src/heartbeat/heartbeat.ts +2 -2
  145. package/src/hooks/codex-hook.ts +6 -2
  146. package/src/hooks/hook.ts +7 -0
  147. package/src/hooks/pr-body-guard.ts +38 -11
  148. package/src/hooks/pr-room-guard.ts +88 -0
  149. package/src/http/agents.ts +1 -0
  150. package/src/http/apps.ts +84 -181
  151. package/src/http/fleet-benchmark.ts +79 -30
  152. package/src/http/global-config.ts +7 -0
  153. package/src/http/ranking.ts +24 -40
  154. package/src/http/tasks.ts +2 -1
  155. package/src/http/telemetry-ui.ts +1 -1
  156. package/src/http/used-by.ts +55 -19
  157. package/src/http/users.ts +2 -2
  158. package/src/providers/amp-adapter.ts +1 -1
  159. package/src/providers/pi-mono-adapter.ts +15 -0
  160. package/src/providers/reasoning-effort.ts +2 -1
  161. package/src/providers/types.ts +2 -0
  162. package/src/ranking/telemetry-ranking.ts +5 -33
  163. package/src/scripts-runtime/sdk-allowlist.ts +7 -0
  164. package/src/scripts-runtime/types/stdlib.d.ts +31 -0
  165. package/src/scripts-runtime/types/swarm-sdk.d.ts +31 -0
  166. package/src/server.ts +4 -0
  167. package/src/telemetry-context.ts +19 -1
  168. package/src/telemetry-contract/EVENTS.md +42 -8
  169. package/src/telemetry-contract/SOURCE.json +6 -5
  170. package/src/telemetry-contract/catalog.json +225 -5
  171. package/src/telemetry-contract/ranking/fixtures/valid/no-claim.json +10 -0
  172. package/src/telemetry-contract/ranking/listing.v1.schema.json +2 -2
  173. package/src/telemetry-contract/types.gen.ts +225 -27
  174. package/src/telemetry-identity.ts +47 -9
  175. package/src/telemetry-snapshot.ts +9 -1
  176. package/src/telemetry-ui-events.ts +9 -2
  177. package/src/telemetry.ts +14 -3
  178. package/src/tests/app-action-run-tool.test.ts +178 -0
  179. package/src/tests/app-row-tools.test.ts +360 -0
  180. package/src/tests/check-pr-body.test.ts +0 -23
  181. package/src/tests/credential-check.test.ts +38 -0
  182. package/src/tests/fleet-benchmark.test.ts +122 -2
  183. package/src/tests/http-users.test.ts +62 -1
  184. package/src/tests/model-catalog-table.test.ts +63 -0
  185. package/src/tests/model-profiles-api.test.ts +19 -7
  186. package/src/tests/models-catalog.test.ts +50 -0
  187. package/src/tests/onboarding.test.ts +21 -0
  188. package/src/tests/org-website.test.ts +62 -0
  189. package/src/tests/pi-mono-adapter.test.ts +23 -0
  190. package/src/tests/pr-body-leaks.test.ts +4 -0
  191. package/src/tests/pr-room-guard.test.ts +242 -0
  192. package/src/tests/ranking-listing.test.ts +8 -23
  193. package/src/tests/ranking-route.test.ts +49 -47
  194. package/src/tests/reasoning-effort.test.ts +39 -3
  195. package/src/tests/runner-harness-telemetry-env.test.ts +56 -1
  196. package/src/tests/swarm-tool-result-gate.test.ts +2 -4
  197. package/src/tests/telemetry-context.test.ts +128 -2
  198. package/src/tests/telemetry-ui-route.test.ts +123 -0
  199. package/src/tests/tool-annotations.test.ts +3 -2
  200. package/src/tests/used-by-route.test.ts +144 -10
  201. package/src/tests/workflow-agent-task-retry.test.ts +130 -4
  202. package/src/tests/workflow-wait-recovery.test.ts +116 -4
  203. package/src/tools/app-action-run.ts +63 -0
  204. package/src/tools/app-rows.ts +234 -0
  205. package/src/tools/model-catalog/index.ts +1 -1
  206. package/src/tools/tool-config.ts +8 -0
  207. package/src/tools/utils.ts +1 -1
  208. package/src/tools/workflows/retry-workflow-run.ts +1 -1
  209. package/src/types.ts +17 -0
  210. package/src/utils/company-profile.ts +24 -0
  211. package/src/utils/org-website.ts +58 -21
  212. package/src/workflows/recovery.ts +10 -22
  213. package/src/workflows/resume.ts +16 -0
  214. package/src/workflows/task-step-routing.ts +47 -7
  215. package/templates/skills/apps/SKILL.md +41 -2
  216. package/templates/skills/apps/content.md +41 -2
  217. package/dist/handlers-zbs90bny.js +0 -39
  218. package/dist/index-8pwc1jc8.js +0 -40
  219. package/dist/index-nxa6me0q.js +0 -41
  220. package/dist/lead-td8h2sfn.js +0 -78
  221. package/dist/worker-wr6xgfr2.js +0 -78
  222. package/src/tests/check-ranking-entry.test.ts +0 -146
@@ -1,11 +1,13 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
- import { closeDb, createUser, initDb } from "../be/db";
3
+ import { closeDb, createUser, getDbClient, initDb, updateUser, upsertSwarmConfig } from "../be/db";
4
4
  import { attachRole, detachRole } from "../be/rbac-roles";
5
+ import { loadGlobalConfigsIntoEnv } from "../http/core";
5
6
  import {
6
7
  _getOrgIdForTests,
7
8
  _resetTelemetryStateForTests,
8
9
  initTelemetry,
10
+ setTelemetryFunctionResolver,
9
11
  setTelemetryOrgDomain,
10
12
  track,
11
13
  } from "../telemetry";
@@ -19,12 +21,22 @@ import {
19
21
  validOrgDomain,
20
22
  validOrgId,
21
23
  } from "../telemetry-context";
22
- import { _resetTelemetryIdentityForTests, computeOrgDomain } from "../telemetry-identity";
24
+ import {
25
+ _resetTelemetryIdentityForTests,
26
+ cachedUserFunction,
27
+ computeOrgDomain,
28
+ primeUserRole,
29
+ } from "../telemetry-identity";
23
30
 
24
31
  // initTelemetry no-ops when ANONYMIZED_TELEMETRY=false, and the CI env may set it.
25
32
  process.env.ANONYMIZED_TELEMETRY = "true";
26
33
 
27
34
  const TEST_DB_PATH = "./test-telemetry-context.sqlite";
35
+ const PROFILE_KEYS = [
36
+ "SWARM_ORG_TEAM_SIZE",
37
+ "SWARM_ORG_USE_CASE",
38
+ "SWARM_ORG_ACQUISITION_SOURCE",
39
+ ] as const;
28
40
 
29
41
  async function removeTestDb(): Promise<void> {
30
42
  for (const suffix of ["", "-wal", "-shm"]) {
@@ -116,6 +128,30 @@ describe("telemetry context helpers", () => {
116
128
  expect(mapTriggerSurface(null)).toBe("other");
117
129
  });
118
130
 
131
+ test("buildContext omits invalid profile values and a function without an actor", () => {
132
+ const base = {
133
+ orgId: "org_0123456789abcdef",
134
+ installationId: "install_test",
135
+ isCloud: false,
136
+ isE2b: false,
137
+ swarmVersion: "1.2.3",
138
+ };
139
+ const context = buildContext({
140
+ ...base,
141
+ teamSize: "invalid",
142
+ useCase: "invalid",
143
+ acquisitionSource: "invalid",
144
+ userFunction: "founder",
145
+ });
146
+ expect(context).not.toHaveProperty("team_size");
147
+ expect(context).not.toHaveProperty("use_case");
148
+ expect(context).not.toHaveProperty("user_function");
149
+ expect(context.acquisition_source).toBeNull();
150
+ expect(
151
+ buildContext({ ...base, actor: { userId: "u-1" }, userFunction: "administrator" }),
152
+ ).not.toHaveProperty("user_function");
153
+ });
154
+
119
155
  test("normalizeProperties keeps an event alive when one optional value is unsendable", () => {
120
156
  const out = normalizeProperties("task.completed", {
121
157
  taskId: "t-1",
@@ -248,6 +284,19 @@ describe("telemetry envelope", () => {
248
284
  }
249
285
  });
250
286
 
287
+ test("track resolves the actor's job function without changing the RBAC role", async () => {
288
+ const config = memoryConfig({ telemetry_installation_id: "install_function" });
289
+ await initTelemetry("api-server", config.get, config.set, { generateIfMissing: true });
290
+ setTelemetryFunctionResolver(() => "founder");
291
+ track({
292
+ event: "workflow.deleted",
293
+ properties: { workflowId: "wf-1" },
294
+ actor: { userId: "u-1", role: "member" },
295
+ });
296
+ await tick();
297
+ expect(sent[0]?.context).toMatchObject({ user_function: "founder", user_role: "member" });
298
+ });
299
+
251
300
  test("an org with no ID yet sends nothing instead of a half-identified event", async () => {
252
301
  // A worker on an old server: installation ID known, no org ID stored.
253
302
  const config = memoryConfig({ telemetry_installation_id: "install_noorg" });
@@ -270,18 +319,95 @@ describe("telemetry envelope", () => {
270
319
  });
271
320
 
272
321
  describe("org email domain", () => {
322
+ const savedProfile = Object.fromEntries(PROFILE_KEYS.map((key) => [key, process.env[key]]));
323
+
273
324
  beforeEach(async () => {
274
325
  closeDb();
275
326
  await removeTestDb();
276
327
  initDb(TEST_DB_PATH);
277
328
  _resetTelemetryIdentityForTests();
329
+ for (const key of PROFILE_KEYS) delete process.env[key];
278
330
  });
279
331
 
280
332
  afterEach(async () => {
333
+ for (const key of PROFILE_KEYS) {
334
+ if (savedProfile[key] === undefined) delete process.env[key];
335
+ else process.env[key] = savedProfile[key];
336
+ }
281
337
  closeDb();
282
338
  await removeTestDb();
283
339
  });
284
340
 
341
+ test("stored company profile reaches context after reload and clears without restart", async () => {
342
+ const values = ["11-50", "features", "hacker_news"];
343
+ for (const [index, key] of PROFILE_KEYS.entries()) {
344
+ await upsertSwarmConfig({ scope: "global", key, value: values[index] as string });
345
+ }
346
+ await loadGlobalConfigsIntoEnv(true);
347
+ const originalFetch = globalThis.fetch;
348
+ const sent: Array<{ context: Record<string, unknown> }> = [];
349
+ globalThis.fetch = (async (_url: string, init?: { body?: string }) => {
350
+ if (init?.body) sent.push(JSON.parse(init.body));
351
+ return new Response(null, { status: 204 });
352
+ }) as typeof fetch;
353
+ _resetTelemetryStateForTests();
354
+ try {
355
+ const config = memoryConfig({
356
+ telemetry_installation_id: "install_profile",
357
+ telemetry_org_id: "org_0123456789abcdef",
358
+ });
359
+ await initTelemetry("api-server", config.get, config.set);
360
+ track({ event: "server.started", properties: { port: 3013 } });
361
+ expect(sent).toHaveLength(1);
362
+ expect(sent[0]?.context).toMatchObject({
363
+ team_size: "11-50",
364
+ use_case: "features",
365
+ acquisition_source: "hacker_news",
366
+ });
367
+ for (const key of PROFILE_KEYS) {
368
+ await upsertSwarmConfig({ scope: "global", key, value: "" });
369
+ }
370
+ await loadGlobalConfigsIntoEnv(true);
371
+ track({ event: "server.started", properties: { port: 3013 } });
372
+ expect(sent).toHaveLength(2);
373
+ expect(sent[1]?.context).not.toHaveProperty("team_size");
374
+ expect(sent[1]?.context).not.toHaveProperty("use_case");
375
+ expect(sent[1]?.context.acquisition_source).toBeNull();
376
+ } finally {
377
+ globalThis.fetch = originalFetch;
378
+ _resetTelemetryStateForTests();
379
+ }
380
+ });
381
+
382
+ test("job function uses the role cache and invalidates after a committed user update", async () => {
383
+ const user = await createUser({
384
+ name: "Founder",
385
+ role: "admin",
386
+ metadata: { jobFunction: "founder" },
387
+ });
388
+ await primeUserRole(user.id);
389
+ expect(cachedUserFunction(user.id)).toBe("founder");
390
+ await getDbClient().transaction(async () => {
391
+ await updateUser(user.id, { metadata: { jobFunction: "engineer" } });
392
+ expect(cachedUserFunction(user.id)).toBe("founder");
393
+ });
394
+ await primeUserRole(user.id);
395
+ expect(cachedUserFunction(user.id)).toBe("engineer");
396
+ await updateUser(user.id, { metadata: { jobFunction: "a free-text legacy role" } });
397
+ await primeUserRole(user.id);
398
+ expect(cachedUserFunction(user.id)).toBeUndefined();
399
+ const context = buildContext({
400
+ orgId: "org_0123456789abcdef",
401
+ installationId: "install_profile",
402
+ isCloud: false,
403
+ isE2b: false,
404
+ swarmVersion: "1.2.3",
405
+ actor: { userId: user.id },
406
+ userFunction: cachedUserFunction(user.id),
407
+ });
408
+ expect(context).not.toHaveProperty("user_function");
409
+ });
410
+
285
411
  test("is undefined with no users", async () => {
286
412
  expect(await computeOrgDomain()).toBeUndefined();
287
413
  });
@@ -5,6 +5,7 @@ import { isPublicRoute } from "../http/route-def";
5
5
  import { handleTelemetryUi } from "../http/telemetry-ui";
6
6
  import { getPathSegments, parseQueryParams } from "../http/utils";
7
7
  import { _resetTelemetryStateForTests, initTelemetry } from "../telemetry";
8
+ import catalog from "../telemetry-contract/catalog.json";
8
9
  import { UI_EVENT_NAMES, validateUiEvent } from "../telemetry-ui-events";
9
10
  import type { HttpRequestAuth } from "../utils/request-auth-context";
10
11
  import { setRequestAuth } from "../utils/request-auth-context";
@@ -118,6 +119,128 @@ describe("POST /api/telemetry/ui-event", () => {
118
119
  expect(sent).toHaveLength(0);
119
120
  });
120
121
 
122
+ test.each([
123
+ { event: "identity.lookup", properties: { matched: true } },
124
+ ...["identity_modal", "setup"].map((surface) => ({
125
+ event: "identity.user_created",
126
+ properties: { surface, email_kind: "work", has_function: true, has_company: true },
127
+ })),
128
+ {
129
+ event: "fleet_benchmark.unlocked",
130
+ properties: { fields: ["website", "team_size", "use_case"] },
131
+ },
132
+ ...["compose", "docker", "kubernetes"].map((target) => ({
133
+ event: "setup.worker_command_copied",
134
+ properties: { target, harness: "codex" },
135
+ })),
136
+ ...[
137
+ "prompt",
138
+ "setup",
139
+ "settings",
140
+ "org_name_popup",
141
+ "feedback",
142
+ "fleet_benchmark",
143
+ "telemetry_banner",
144
+ "identity_modal",
145
+ ].map((surface) => ({
146
+ event: "org.profile_updated",
147
+ properties: {
148
+ fields: ["name", "website", "team_size", "use_case", "acquisition_source"],
149
+ surface,
150
+ },
151
+ })),
152
+ {
153
+ event: "popup.submitted",
154
+ properties: {
155
+ popup: "feedback",
156
+ has_message: false,
157
+ has_contact: false,
158
+ answered: 3,
159
+ perks: ["used_by", "slack_connect"],
160
+ },
161
+ },
162
+ ...["notification.viewed", "notification.dismissed", "notification.submitted"].flatMap(
163
+ (event) =>
164
+ ["task_failed", "first_workflow", "default"].map((moment) => ({
165
+ event,
166
+ properties: {
167
+ key: "slack_connect_invite",
168
+ moment,
169
+ ...(event === "notification.submitted" ? { user_function: "engineer" } : {}),
170
+ },
171
+ })),
172
+ ),
173
+ ])("accepts the company-profile catalog event $event with $properties", async ({
174
+ event,
175
+ properties,
176
+ }) => {
177
+ const result = await post({ event, properties });
178
+ await flush();
179
+ expect(result.statusCode).toBe(204);
180
+ expect(sent).toHaveLength(1);
181
+ expect(sent[0]).toMatchObject({ event, source: "ui", properties });
182
+ });
183
+
184
+ test.each([
185
+ {
186
+ event: "identity.user_created",
187
+ properties: {
188
+ surface: "invalid",
189
+ email_kind: "work",
190
+ has_function: true,
191
+ has_company: false,
192
+ },
193
+ },
194
+ { event: "setup.worker_command_copied", properties: { target: "invalid", harness: "codex" } },
195
+ { event: "org.profile_updated", properties: { fields: ["team_size"], surface: "invalid" } },
196
+ { event: "org.profile_updated", properties: { fields: ["invalid"], surface: "settings" } },
197
+ { event: "fleet_benchmark.unlocked", properties: { fields: ["invalid"] } },
198
+ {
199
+ event: "popup.submitted",
200
+ properties: { popup: "feedback", has_message: false, has_contact: false, perks: ["invalid"] },
201
+ },
202
+ {
203
+ event: "notification.submitted",
204
+ properties: { key: "slack_connect_invite", moment: "invalid" },
205
+ },
206
+ {
207
+ event: "notification.submitted",
208
+ properties: { key: "slack_connect_invite", user_function: "invalid" },
209
+ },
210
+ ])("rejects a bad enum in $event with $properties", async ({ event, properties }) => {
211
+ const result = await post({ event, properties });
212
+ expect(result.statusCode).toBe(400);
213
+ expect(sent).toHaveLength(0);
214
+ });
215
+
216
+ test("array item references enforce the referenced enum", async () => {
217
+ const entry = catalog.events.find((entry) => entry.event === "fleet_benchmark.unlocked")!;
218
+ const fields = entry.properties.fields!;
219
+ const original = fields.items;
220
+ // Exercise a referenced item definition without changing the vendored catalog.
221
+ Object.assign(fields, { items: { ref: "use_case" } });
222
+ try {
223
+ expect(
224
+ (
225
+ await post({
226
+ event: "fleet_benchmark.unlocked",
227
+ properties: { fields: ["features"] },
228
+ })
229
+ ).statusCode,
230
+ ).toBe(204);
231
+ sent = [];
232
+ const result = await post({
233
+ event: "fleet_benchmark.unlocked",
234
+ properties: { fields: ["invalid"] },
235
+ });
236
+ expect(result.statusCode).toBe(400);
237
+ expect(result.body).toContain("items must be one of");
238
+ expect(sent).toHaveLength(0);
239
+ } finally {
240
+ fields.items = original;
241
+ }
242
+ });
243
+
121
244
  test("an unknown event name gets 400 and sends nothing", async () => {
122
245
  const result = await post({ event: "task.created", properties: {} });
123
246
  await flush();
@@ -376,9 +376,10 @@ describe("Tool Annotations & Classification", () => {
376
376
  // (this suite forces ALL_CAPABILITIES, including default-disabled ones).
377
377
  // Includes 11 skill tools, 7 MCP server tools, reusable script tools, and the
378
378
  // native Kapso/WhatsApp tools (register/unregister number + send/reply message),
379
- // and the six Chat SDK tools (chat-* + register/unregister-chat-channel).
379
+ // the six Chat SDK tools (chat-* + register/unregister-chat-channel),
380
+ // and the seven app row tools plus app-action-run.
380
381
  expect(count).toBeGreaterThanOrEqual(45);
381
- expect(count).toBeLessThanOrEqual(163);
382
+ expect(count).toBeLessThanOrEqual(169);
382
383
  });
383
384
 
384
385
  test("core tools are fewer than deferred tools", () => {
@@ -5,6 +5,7 @@ import { closeDb, createAgent, getSwarmConfigs, initDb, upsertSwarmConfig } from
5
5
  import { fillOrgLogoFromWebsite } from "../be/org-profile";
6
6
  import { handleUsedBy } from "../http/used-by";
7
7
  import { getPathSegments, parseQueryParams } from "../http/utils";
8
+ import { _resetTelemetryStateForTests, initTelemetry } from "../telemetry";
8
9
  import type { HttpRequestAuth } from "../utils/request-auth-context";
9
10
  import { setRequestAuth } from "../utils/request-auth-context";
10
11
 
@@ -94,26 +95,155 @@ describe("POST /api/used-by", () => {
94
95
  });
95
96
 
96
97
  test("a non-lead agent gets 403 and nothing reaches the proxy", async () => {
97
- const result = await post({ granted: true }, null, WORKER_ID);
98
+ const result = await post({ granted: true, contactEmail: "admin@acme.com" }, null, WORKER_ID);
98
99
  expect(result.statusCode).toBe(403);
99
100
  expect(proxied).toEqual([]);
100
101
  });
101
102
 
103
+ test("consent telemetry reports quote and function presence without their contents", async () => {
104
+ const savedFlag = process.env.ANONYMIZED_TELEMETRY;
105
+ try {
106
+ process.env.ANONYMIZED_TELEMETRY = "true";
107
+ _resetTelemetryStateForTests();
108
+ await initTelemetry("api-server", configValue, async () => {}, { generateIfMissing: true });
109
+ await post(
110
+ {
111
+ granted: true,
112
+ contactEmail: "admin@acme.com",
113
+ contactFunction: "founder",
114
+ quote: "Private quote",
115
+ },
116
+ OPERATOR,
117
+ );
118
+ await post({ granted: false }, OPERATOR);
119
+ await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR);
120
+ const events = proxied.filter((request) => request.body.event === "used_by.consent_changed");
121
+ expect(events).toHaveLength(3);
122
+ expect(events.map((event) => event.body.properties)).toMatchObject([
123
+ { granted: true, has_quote: true, has_function: true },
124
+ { granted: false, has_quote: false, has_function: false },
125
+ { granted: true, has_quote: false, has_function: false },
126
+ ]);
127
+ for (const event of events) {
128
+ expect(JSON.stringify(event.body)).not.toContain("Private quote");
129
+ expect(JSON.stringify(event.body)).not.toContain("admin@acme.com");
130
+ }
131
+ } finally {
132
+ if (savedFlag === undefined) delete process.env.ANONYMIZED_TELEMETRY;
133
+ else process.env.ANONYMIZED_TELEMETRY = savedFlag;
134
+ _resetTelemetryStateForTests();
135
+ }
136
+ });
137
+
102
138
  test("grant and revoke both reach the proxy and are stored", async () => {
103
- const grant = await post({ granted: true }, OPERATOR);
139
+ const grant = await post(
140
+ {
141
+ granted: true,
142
+ contactEmail: "admin@acme.com",
143
+ contactFunction: "founder",
144
+ quote: "Our swarm ships features.",
145
+ },
146
+ OPERATOR,
147
+ );
104
148
  expect(grant.statusCode).toBe(200);
105
149
  expect(proxied[0]?.url).toBe("https://proxy.desplega.sh/v1/used-by");
106
150
  expect(proxied[0]?.body).toMatchObject({
107
151
  org_name: "Acme",
108
152
  org_website: "acme.com",
109
153
  granted: true,
154
+ contact_email: "admin@acme.com",
155
+ contact_function: "founder",
156
+ quote: "Our swarm ships features.",
110
157
  });
111
158
  expect(await configValue("SWARM_USED_BY_CONSENT")).toBe("true");
159
+ expect(await configValue("SWARM_USED_BY_CONTACT_EMAIL")).toBe("admin@acme.com");
160
+ expect(await configValue("SWARM_USED_BY_CONTACT_FUNCTION")).toBe("founder");
161
+ expect(await configValue("SWARM_USED_BY_QUOTE")).toBe("Our swarm ships features.");
162
+ // Contact details never reach agent env, and readers see the email masked.
163
+ const rows = await getSwarmConfigs({ scope: "global" });
164
+ const row = (key: string) => rows.find((entry) => entry.key === key);
165
+ for (const key of [
166
+ "SWARM_USED_BY_CONTACT_EMAIL",
167
+ "SWARM_USED_BY_CONTACT_FUNCTION",
168
+ "SWARM_USED_BY_QUOTE",
169
+ ]) {
170
+ expect(row(key)?.exportToEnv).toBe(false);
171
+ }
172
+ expect(row("SWARM_USED_BY_CONTACT_EMAIL")?.isSecret).toBe(true);
112
173
 
113
174
  const revoke = await post({ granted: false }, OPERATOR);
114
175
  expect(revoke.statusCode).toBe(200);
115
176
  expect(proxied[1]?.body).toMatchObject({ granted: false });
116
177
  expect(await configValue("SWARM_USED_BY_CONSENT")).toBe("false");
178
+ expect(await configValue("SWARM_USED_BY_CONTACT_EMAIL")).toBe("");
179
+ expect(await configValue("SWARM_USED_BY_CONTACT_FUNCTION")).toBe("");
180
+ expect(await configValue("SWARM_USED_BY_QUOTE")).toBe("");
181
+ expect(proxied[1]?.body).not.toHaveProperty("contact_email");
182
+ expect(proxied[1]?.body).not.toHaveProperty("contact_function");
183
+ expect(proxied[1]?.body).not.toHaveProperty("quote");
184
+ });
185
+
186
+ test("a grant requires valid contact details and a quote of at most 280 characters", async () => {
187
+ for (const details of [
188
+ {},
189
+ { contactEmail: "" },
190
+ { contactEmail: "invalid" },
191
+ { contactEmail: "admin@acme.com", contactFunction: "CEO" },
192
+ { contactEmail: "admin@acme.com", quote: "x".repeat(281) },
193
+ ]) {
194
+ const result = await post({ granted: true, ...details }, OPERATOR);
195
+ expect(result.statusCode).toBe(400);
196
+ expect(result.body).toContain("Validation error");
197
+ }
198
+ expect(proxied).toEqual([]);
199
+ expect((await post({ granted: false, contactEmail: "invalid" }, OPERATOR)).statusCode).toBe(
200
+ 400,
201
+ );
202
+ expect(proxied).toEqual([]);
203
+ });
204
+
205
+ test("contact email accepts 320 characters and rejects longer values", async () => {
206
+ const contactEmail = `${"a".repeat(64)}@${Array(4).fill("b".repeat(63)).join(".")}`;
207
+ expect(contactEmail).toHaveLength(320);
208
+ expect((await post({ granted: true, contactEmail }, OPERATOR)).statusCode).toBe(200);
209
+ proxied = [];
210
+ const result = await post({ granted: true, contactEmail: `a${contactEmail}` }, OPERATOR);
211
+ expect(result.statusCode).toBe(400);
212
+ expect(result.body).toContain("Contact email must be at most 320 characters");
213
+ expect(proxied).toEqual([]);
214
+ });
215
+
216
+ test("an empty stored name overrides the deployment name", async () => {
217
+ process.env.SWARM_ORG_NAME = "Deployment name";
218
+ try {
219
+ await upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_NAME", value: "" });
220
+ const result = await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR);
221
+ expect(result.statusCode).toBe(400);
222
+ expect(result.body).toContain("Set the organization name and website first");
223
+ expect(proxied).toEqual([]);
224
+ } finally {
225
+ delete process.env.SWARM_ORG_NAME;
226
+ }
227
+ });
228
+
229
+ test("a new grant clears omitted optional fields from the previous grant", async () => {
230
+ const result = await post(
231
+ {
232
+ granted: true,
233
+ contactEmail: "admin@acme.com",
234
+ contactFunction: "engineer",
235
+ quote: "x".repeat(280),
236
+ },
237
+ OPERATOR,
238
+ );
239
+ expect(result.statusCode).toBe(200);
240
+ expect(await configValue("SWARM_USED_BY_QUOTE")).toHaveLength(280);
241
+ expect((await post({ granted: true, contactEmail: "new@acme.com" }, OPERATOR)).statusCode).toBe(
242
+ 200,
243
+ );
244
+ expect(await configValue("SWARM_USED_BY_CONTACT_EMAIL")).toBe("new@acme.com");
245
+ expect(await configValue("SWARM_USED_BY_CONTACT_FUNCTION")).toBe("");
246
+ expect(await configValue("SWARM_USED_BY_QUOTE")).toBe("");
117
247
  });
118
248
 
119
249
  test("with telemetry off the install ID stays home", async () => {
@@ -124,9 +254,9 @@ describe("POST /api/used-by", () => {
124
254
  });
125
255
  const saved = process.env.ANONYMIZED_TELEMETRY;
126
256
  process.env.ANONYMIZED_TELEMETRY = "true";
127
- await post({ granted: true }, OPERATOR);
257
+ await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR);
128
258
  process.env.ANONYMIZED_TELEMETRY = "false";
129
- await post({ granted: true }, OPERATOR);
259
+ await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR);
130
260
  if (saved === undefined) delete process.env.ANONYMIZED_TELEMETRY;
131
261
  else process.env.ANONYMIZED_TELEMETRY = saved;
132
262
  expect(proxied[0]?.body.install_id).toBe("install_0123456789abcdef");
@@ -139,7 +269,7 @@ describe("POST /api/used-by", () => {
139
269
  key: "feedback_endpoint",
140
270
  value: "http://stub.test/v1/feedback",
141
271
  });
142
- await post({ granted: true }, OPERATOR);
272
+ await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR);
143
273
  expect(proxied[0]?.url).toBe("http://stub.test/v1/used-by");
144
274
  await upsertSwarmConfig({
145
275
  scope: "global",
@@ -151,13 +281,15 @@ describe("POST /api/used-by", () => {
151
281
  test("a proxy failure is a 502 and leaves the stored consent alone", async () => {
152
282
  await upsertSwarmConfig({ scope: "global", key: "SWARM_USED_BY_CONSENT", value: "false" });
153
283
  proxyStatus = 500;
154
- const result = await post({ granted: true }, OPERATOR);
284
+ const result = await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR);
155
285
  expect(result.statusCode).toBe(502);
156
286
  expect(await configValue("SWARM_USED_BY_CONSENT")).toBe("false");
157
287
  });
158
288
 
159
289
  test("a revoke after the website is cleared or changed uses the granted identity", async () => {
160
- expect((await post({ granted: true }, OPERATOR)).statusCode).toBe(200);
290
+ expect(
291
+ (await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR)).statusCode,
292
+ ).toBe(200);
161
293
  await upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_WEBSITE", value: "" });
162
294
  const revoke = await post({ granted: false }, OPERATOR);
163
295
  expect(revoke.statusCode).toBe(200);
@@ -173,7 +305,9 @@ describe("POST /api/used-by", () => {
173
305
  expect(proxied[2]?.body).toMatchObject({ org_name: "Acme", org_website: "acme.com" });
174
306
 
175
307
  // A new grant sends, and from then on revokes with, the current identity.
176
- expect((await post({ granted: true }, OPERATOR)).statusCode).toBe(200);
308
+ expect(
309
+ (await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR)).statusCode,
310
+ ).toBe(200);
177
311
  expect(proxied[3]?.body).toMatchObject({ org_name: "Acme Labs", org_website: "acme.io" });
178
312
  await upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_WEBSITE", value: "" });
179
313
  await post({ granted: false }, OPERATOR);
@@ -182,7 +316,7 @@ describe("POST /api/used-by", () => {
182
316
 
183
317
  test("without a website it is a 400", async () => {
184
318
  await upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_WEBSITE", value: "" });
185
- const result = await post({ granted: true }, OPERATOR);
319
+ const result = await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR);
186
320
  expect(result.statusCode).toBe(400);
187
321
  expect(JSON.parse(result.body)).toEqual({
188
322
  error: "Set the organization name and website first",
@@ -192,7 +326,7 @@ describe("POST /api/used-by", () => {
192
326
 
193
327
  test("a placeholder name still grants: the ranking's placeholder rule does not apply", async () => {
194
328
  await upsertSwarmConfig({ scope: "global", key: "SWARM_ORG_NAME", value: "Swarm" });
195
- const result = await post({ granted: true }, OPERATOR);
329
+ const result = await post({ granted: true, contactEmail: "admin@acme.com" }, OPERATOR);
196
330
  expect(result.statusCode).toBe(200);
197
331
  expect(proxied[0]?.body).toMatchObject({ org_name: "Swarm", org_website: "acme.com" });
198
332
  });