@desplega.ai/agent-swarm 2.7.2 → 2.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/README.md +6 -0
  2. package/dist/{acp-adapter-624yge7a.js → acp-adapter-pdzw76ja.js} +1 -1
  3. package/dist/{actions-rd9xn281.js → actions-a90cfpca.js} +21 -21
  4. package/dist/{amp-adapter-k27hc00p.js → amp-adapter-4vtywcjk.js} +2 -2
  5. package/dist/{app-sjm3xc7g.js → app-8k68gygk.js} +3 -3
  6. package/dist/{assistant-6jbzs8wa.js → assistant-ck01q2yg.js} +32 -21
  7. package/dist/{boot-reembed-e2348eqc.js → boot-reembed-33spavpd.js} +1 -1
  8. package/dist/{boot-reembed-w53pd5xt.js → boot-reembed-xzge83a2.js} +1 -1
  9. package/dist/{boot-scrub-sweep-cpy4yg97.js → boot-scrub-sweep-mxvb8dr3.js} +3 -3
  10. package/dist/{boot-seeds-f6j4t6k6.js → boot-seeds-rpm8bb5r.js} +4 -4
  11. package/dist/{claude-adapter-v3ac8esd.js → claude-adapter-q12bcfj9.js} +3 -3
  12. package/dist/{claude-managed-setup-yyrkqvtm.js → claude-managed-setup-11vb6q89.js} +1 -1
  13. package/dist/{claude-sdk-session-85j69v6d.js → claude-sdk-session-gkv1107j.js} +10325 -10243
  14. package/dist/{cli-rs59z1qx.js → cli-07pcqwaj.js} +1 -1
  15. package/dist/{cli-6vq3ymqm.js → cli-0tkm519n.js} +1 -1
  16. package/dist/{cli-s7cm1927.js → cli-0xzbk52h.js} +4 -4
  17. package/dist/{cli-g0zc1b40.js → cli-0zae6bs6.js} +2 -2
  18. package/dist/{cli-p8yrsmfn.js → cli-10kjx0dm.js} +1 -1
  19. package/dist/{cli-7xsq63pe.js → cli-157ykn37.js} +10 -9
  20. package/dist/{cli-vjcj9k2p.js → cli-2h0rfmqk.js} +1 -1
  21. package/dist/{cli-9wasax9f.js → cli-35nzvqaf.js} +1 -1
  22. package/dist/{cli-e1p7dqe1.js → cli-4aydmmdy.js} +24 -3
  23. package/dist/{cli-942pp3bc.js → cli-54ax65rn.js} +3 -3
  24. package/dist/{cli-10cp92df.js → cli-5ke7gxwr.js} +6 -6
  25. package/dist/{cli-femw4q81.js → cli-6151a7dr.js} +2 -2
  26. package/dist/{cli-3cywb44q.js → cli-77eq8txs.js} +85 -3
  27. package/dist/{cli-atpfk4yd.js → cli-7cwrwb5x.js} +5 -5
  28. package/dist/{cli-bfbpvccx.js → cli-8a1k6mat.js} +61 -49
  29. package/dist/{cli-c18941tx.js → cli-8ch3b2r7.js} +3 -3
  30. package/dist/{cli-rs2s1t05.js → cli-9fk56y8c.js} +4 -4
  31. package/dist/{cli-0wx63vvw.js → cli-9km88w11.js} +1 -1
  32. package/dist/{cli-qy0z3mfj.js → cli-9vttw9nz.js} +56 -46
  33. package/dist/{cli-mzgppb7q.js → cli-a0w50zw9.js} +4 -4
  34. package/dist/{cli-z8a4mfz1.js → cli-aqp59jmv.js} +4 -4
  35. package/dist/{cli-5d8wd50e.js → cli-bs0fwb23.js} +1 -1
  36. package/dist/{cli-fa4kxxx3.js → cli-ct5920jb.js} +2 -2
  37. package/dist/{cli-0awbtmv2.js → cli-dek8hqjc.js} +10 -10
  38. package/dist/{cli-qp6adcmw.js → cli-e24w2mvk.js} +1 -1
  39. package/dist/{cli-t3npd8hm.js → cli-ghfhn1qc.js} +1 -1
  40. package/dist/{cli-jmcf8cnz.js → cli-ja369zab.js} +1 -1
  41. package/dist/{cli-wtjg6je3.js → cli-jhh7tbx6.js} +1 -1
  42. package/dist/{cli-e19bgatr.js → cli-jmk31131.js} +2 -2
  43. package/dist/{cli-2cj8fqv1.js → cli-kjcdzea8.js} +1 -1
  44. package/dist/{cli-1xpr5992.js → cli-kjha1q9k.js} +3 -3
  45. package/dist/{cli-9jfty8px.js → cli-ktcdczth.js} +538 -30
  46. package/dist/{cli-exd52qn4.js → cli-mcmpbpcc.js} +2 -2
  47. package/dist/{cli-2m2p3snc.js → cli-mmf43vcp.js} +6 -6
  48. package/dist/{cli-b2rhbaty.js → cli-pnm99spe.js} +10 -10
  49. package/dist/{cli-9jn9cj0q.js → cli-v9jk2k63.js} +1 -1
  50. package/dist/{cli-mt7bxjfd.js → cli-vhgcwppn.js} +1 -1
  51. package/dist/{cli-qa4hgda3.js → cli-vvz64zc5.js} +14 -13
  52. package/dist/{cli-txr0z97c.js → cli-wmhajw68.js} +171 -32
  53. package/dist/{cli-hzrmv8pg.js → cli-x38rmhap.js} +3 -3
  54. package/dist/{cli-hq9hd4hp.js → cli-xz6qjdqj.js} +1 -1
  55. package/dist/{cli-g9ypd5en.js → cli-z52gq55b.js} +4 -0
  56. package/dist/{cli-2qbmjfta.js → cli-z6219sd3.js} +4 -4
  57. package/dist/cli.js +19 -19
  58. package/dist/{codex-adapter-zm1bkdde.js → codex-adapter-46kbvvkx.js} +1 -1
  59. package/dist/{codex-hook-tkag7fxq.js → codex-hook-tm9ecrd5.js} +1 -1
  60. package/dist/{codex-session-runner-8k02mqw2.js → codex-session-runner-qy93hdrv.js} +1 -1
  61. package/dist/{commands-nw1dn4av.js → commands-hwdhacfx.js} +2 -2
  62. package/dist/{controller-3kkkmps0.js → controller-hjza5p1f.js} +3 -3
  63. package/dist/{controllers-connect-n2c4167r.js → controllers-connect-7m25cay2.js} +20 -20
  64. package/dist/{cursor-adapter-h5028emw.js → cursor-adapter-1qh7ps8w.js} +1 -1
  65. package/dist/{db-76y2msnf.js → db-w23k9301.js} +2 -2
  66. package/dist/{db-8rk0p8j1.js → db-wm8by383.js} +3 -3
  67. package/dist/{dispatcher-xp4wsx30.js → dispatcher-en25ebgc.js} +18 -18
  68. package/dist/{dsh-adapter-vyrr31bn.js → dsh-adapter-zkme32vd.js} +1 -1
  69. package/dist/{e2b-eesfj689.js → e2b-9z9mvt2p.js} +1 -1
  70. package/dist/{e2b-03nha1wz.js → e2b-yhvn70es.js} +1 -1
  71. package/dist/{embedding-space-ejefyep1.js → embedding-space-rgf4aypt.js} +6 -6
  72. package/dist/{embeddings-w297xaw0.js → embeddings-z9cmxhx1.js} +1 -1
  73. package/dist/{external-channel-mf9vp1fe.js → external-channel-g6vv85zv.js} +2 -2
  74. package/dist/{grok-adapter-mpzyyprz.js → grok-adapter-f6n1tpq3.js} +2 -2
  75. package/dist/handlers-ss5948rq.js +42 -0
  76. package/dist/{heartbeat-qnaycjzs.js → heartbeat-5xcdzz45.js} +21 -21
  77. package/dist/{hook-h6sqfzxr.js → hook-6423rme3.js} +2 -2
  78. package/dist/{hook-4zqdgea4.js → hook-8dk1csdd.js} +4 -4
  79. package/dist/{http-65ts210h.js → http-6mfh2ww9.js} +255 -60
  80. package/dist/{index-sqe7f6mj.js → index-0js6bgff.js} +1 -1
  81. package/dist/{index-farm1zsn.js → index-0ng1dd1t.js} +10937 -9527
  82. package/dist/{index-k3ewf1jg.js → index-5eyn1wc5.js} +18 -18
  83. package/dist/{index-vp4wa3qn.js → index-71gnebsn.js} +18 -18
  84. package/dist/{index-hdfad00b.js → index-q5gzpjdd.js} +12 -10
  85. package/dist/index-z3b1vfjx.js +44 -0
  86. package/dist/{keepalive-52k6skha.js → keepalive-cymckeg6.js} +3 -3
  87. package/dist/{lead-y9rj0gr1.js → lead-baknj2dh.js} +29 -29
  88. package/dist/{maintenance-f86wqfq6.js → maintenance-dfrnsx3n.js} +1 -1
  89. package/dist/{oauth-refresh-sweep-vddv9ky7.js → oauth-refresh-sweep-5dztk8x6.js} +2 -2
  90. package/dist/{onboard-8z2qk6gq.js → onboard-h36vja6y.js} +11 -6
  91. package/dist/{otel-impl-epet9j18.js → otel-impl-jerq8vz8.js} +1 -1
  92. package/dist/{pricing-refresh-6wm42a51.js → pricing-refresh-t3nx5krn.js} +3 -3
  93. package/dist/{rbac-roles-2kfcs1sc.js → rbac-roles-dw1awawd.js} +2 -2
  94. package/dist/{rbac-roles-13a0vj6d.js → rbac-roles-km0fc3e8.js} +1 -1
  95. package/dist/{registry-ajhexe44.js → registry-hzfh268v.js} +1 -1
  96. package/dist/{render-v2-cy8zsfsf.js → render-v2-65x4ayj8.js} +3 -3
  97. package/dist/{resume-c783qe6b.js → resume-61znk3hj.js} +6 -6
  98. package/dist/{session-access-s4tv0az8.js → session-access-4yc9htrg.js} +4 -4
  99. package/dist/{session-source-backfill-qzapz0ne.js → session-source-backfill-kr5bdjtp.js} +2 -2
  100. package/dist/{setup-st7htx1k.js → setup-9h61yr2p.js} +1 -1
  101. package/dist/{work-objects-ftj4zw70.js → work-objects-x7rb53qn.js} +27 -22
  102. package/dist/{worker-859fk25k.js → worker-7f3khgn0.js} +29 -29
  103. package/openapi.json +134 -3
  104. package/package.json +5 -5
  105. package/src/be/db/agents.ts +16 -1
  106. package/src/be/db/runtime.ts +25 -15
  107. package/src/be/db/session-summaries.ts +120 -0
  108. package/src/be/db/tasks/read.ts +7 -4
  109. package/src/be/db/tasks/search-index.ts +159 -0
  110. package/src/be/db/tasks/write.ts +3 -0
  111. package/src/be/db-queries/slack-inbound.ts +77 -0
  112. package/src/be/db.ts +27 -8
  113. package/src/be/mcp-overflow-sweep.ts +39 -0
  114. package/src/be/migrations/253_session_summaries.sql +300 -0
  115. package/src/be/migrations/254_query_audit_indexes.sql +25 -0
  116. package/src/be/migrations/256_agent_slack_icon.sql +2 -0
  117. package/src/be/migrations/257_agent_tasks_fts.sql +55 -0
  118. package/src/comb/config.ts +6 -2
  119. package/src/comb/requester.ts +69 -0
  120. package/src/comb/review-batch.ts +24 -2
  121. package/src/commands/onboard/agent-fs-version.ts +1 -1
  122. package/src/commands/onboard/compose-generator.ts +7 -2
  123. package/src/commands/onboard/env-generator.ts +8 -3
  124. package/src/commands/runner.ts +41 -24
  125. package/src/fs/agent-fs-provider.ts +9 -0
  126. package/src/heartbeat/heartbeat.ts +13 -6
  127. package/src/http/agents.ts +10 -2
  128. package/src/http/index.ts +15 -0
  129. package/src/http/integrations.ts +20 -0
  130. package/src/http/list-total-cache.ts +62 -0
  131. package/src/http/sessions.ts +9 -2
  132. package/src/http/slack-inbound.ts +1 -0
  133. package/src/http/status.ts +4 -1
  134. package/src/http/tasks.ts +12 -2
  135. package/src/providers/amp-adapter.ts +1 -1
  136. package/src/providers/cursor-adapter.ts +1 -1
  137. package/src/providers/dsh-adapter.ts +1 -1
  138. package/src/queue-stall-alarm.ts +4 -1
  139. package/src/slack/actions.ts +2 -2
  140. package/src/slack/app-icon.ts +50 -0
  141. package/src/slack/app.ts +13 -1
  142. package/src/slack/assistant.ts +22 -1
  143. package/src/slack/event-dedup.test.ts +11 -0
  144. package/src/slack/event-dedup.ts +8 -4
  145. package/src/slack/inbound-dispatch.ts +58 -0
  146. package/src/slack/render-v2.ts +7 -8
  147. package/src/slack/responses.ts +21 -8
  148. package/src/slack/socket-ack.ts +237 -0
  149. package/src/slack/thread-buffer.ts +3 -5
  150. package/src/slack/watcher.ts +2 -2
  151. package/src/slack/work-objects.ts +5 -1
  152. package/src/tests/agent-fs-release-notes.test.ts +40 -0
  153. package/src/tests/agent-slack-icon.test.ts +85 -0
  154. package/src/tests/approval-cancellation-migration.test.ts +2 -0
  155. package/src/tests/asset-key-migration.test.ts +26 -0
  156. package/src/tests/comb-default.test.ts +159 -0
  157. package/src/tests/comb-review-batch.test.ts +192 -4
  158. package/src/tests/comb-status.test.ts +65 -2
  159. package/src/tests/db-agents.test.ts +13 -0
  160. package/src/tests/db-tasks-read.test.ts +10 -15
  161. package/src/tests/deploy-one-box.test.ts +161 -0
  162. package/src/tests/deploy-stubs.ts +68 -0
  163. package/src/tests/docs-index-footprint.test.js +3 -3
  164. package/src/tests/dsh-adapter.test.ts +1 -1
  165. package/src/tests/e2e-codex-auth-chain.test.ts +18 -1
  166. package/src/tests/fixtures/deploy-stubs/bun +3 -0
  167. package/src/tests/fixtures/deploy-stubs/bunx +27 -0
  168. package/src/tests/fixtures/deploy-stubs/curl +26 -0
  169. package/src/tests/fixtures/deploy-stubs/docker +122 -0
  170. package/src/tests/fixtures/onboard/content.compose.yml.txt +5 -5
  171. package/src/tests/fixtures/onboard/dev.compose.yml.txt +4 -4
  172. package/src/tests/fixtures/onboard/full-all.compose.yml.txt +12 -12
  173. package/src/tests/fixtures/onboard/full.compose.yml.txt +12 -12
  174. package/src/tests/fixtures/onboard/research.compose.yml.txt +4 -4
  175. package/src/tests/fixtures/onboard/solo.compose.yml.txt +2 -2
  176. package/src/tests/heartbeat-expiry.test.ts +151 -25
  177. package/src/tests/http-api-integration.test.ts +31 -0
  178. package/src/tests/human-free-reclassify.test.ts +6 -0
  179. package/src/tests/install-sh-bind.test.ts +242 -0
  180. package/src/tests/install-sh-tunnel.test.ts +89 -0
  181. package/src/tests/kv-storage.test.ts +28 -0
  182. package/src/tests/list-endpoint-slimming.test.ts +3 -0
  183. package/src/tests/list-total-cache.test.ts +63 -0
  184. package/src/tests/mcp-overflow-sweep.test.ts +84 -0
  185. package/src/tests/onboard-compose.test.ts +17 -3
  186. package/src/tests/onboard-env.test.ts +13 -0
  187. package/src/tests/onboarding.test.ts +1 -1
  188. package/src/tests/preload.ts +1 -0
  189. package/src/tests/query-audit-plans.test.ts +314 -0
  190. package/src/tests/script-connections.test.ts +6 -0
  191. package/src/tests/session-summaries.test.ts +384 -0
  192. package/src/tests/sessions-tree.test.ts +5 -1
  193. package/src/tests/slack-app-icon.test.ts +90 -0
  194. package/src/tests/slack-external-channel-egress.test.ts +1 -1
  195. package/src/tests/slack-inbound-receipts.test.ts +34 -0
  196. package/src/tests/slack-inline-output.test.ts +29 -2
  197. package/src/tests/slack-render-v2-query-plans.test.ts +3 -2
  198. package/src/tests/slack-render-v2.test.ts +35 -0
  199. package/src/tests/slack-socket-ack.test.ts +553 -0
  200. package/src/tests/task-lineage.test.ts +3 -0
  201. package/src/tests/task-search-filter.test.ts +4 -4
  202. package/src/tests/task-search-fts.test.ts +320 -0
  203. package/src/tests/update-profile-auth.test.ts +28 -0
  204. package/src/tools/get-swarm.ts +1 -0
  205. package/src/tools/get-tasks.ts +6 -1
  206. package/src/tools/join-swarm.ts +1 -0
  207. package/src/tools/my-agent-info.ts +1 -0
  208. package/src/tools/slack-post.ts +2 -2
  209. package/src/tools/slack-reply.ts +2 -2
  210. package/src/tools/slack-start-thread.ts +2 -2
  211. package/src/tools/slack-update.ts +1 -1
  212. package/src/tools/update-profile.ts +20 -4
  213. package/src/tools/utils.ts +3 -4
  214. package/src/types.ts +47 -0
  215. package/src/utils/constants.ts +127 -5
  216. package/templates/official/lead/HEARTBEAT.md +4 -2
  217. package/templates/official/lead/config.json +2 -2
  218. package/templates/skills/swarm-docs/content.md +2 -0
  219. package/dist/handlers-7x8sywgr.js +0 -42
  220. package/dist/index-mc30r1hs.js +0 -44
@@ -71,7 +71,9 @@ const cases: {
71
71
  params: [],
72
72
  ids: ["a"],
73
73
  },
74
- { name: "search", filters: { search: "b" }, params: ["%b%", "%b%", "%b%"], ids: ["b"] },
74
+ // Search terms bind in the agent_tasks_fts candidate lookup; the page and
75
+ // count read the few candidates by rowid, so they bind no search params.
76
+ { name: "search", filters: { search: "b" }, params: [], ids: ["b"] },
75
77
  {
76
78
  name: "combined ordering",
77
79
  filters: {
@@ -82,17 +84,7 @@ const cases: {
82
84
  source: ["api"],
83
85
  requestedByUserId: "requester",
84
86
  },
85
- params: [
86
- "pending",
87
- "completed",
88
- "%needle%",
89
- "%needle%",
90
- "%needle%",
91
- "feature",
92
- '%"blue"%',
93
- "api",
94
- "requester",
95
- ],
87
+ params: ["pending", "completed", "feature", '%"blue"%', "api", "requester"],
96
88
  ids: ["c", "b"],
97
89
  },
98
90
  {
@@ -124,8 +116,10 @@ for (const { name, filters, params, ids, total } of cases) {
124
116
  const countSpy = spyOn(client, "get");
125
117
  try {
126
118
  expect((await db.getAllTasks(filters)).map((task) => task.id)).toEqual(ids);
127
- expect(listSpy.mock.calls[0]?.[1]).toEqual(params);
128
- const sql = listSpy.mock.calls[0]?.[0] ?? "";
119
+ // A search runs its candidate lookup first; pick the page query by shape.
120
+ const listCall = listSpy.mock.calls.find(([query]) => query.includes(" OFFSET "));
121
+ expect(listCall?.[1]).toEqual(params);
122
+ const sql = listCall?.[0] ?? "";
129
123
  expect(sql).toContain(`LIMIT ${filters.limit ?? 25} OFFSET ${filters.offset ?? 0}`);
130
124
  expect(sql).toContain(
131
125
  filters.orderBy === "createdAt"
@@ -133,7 +127,8 @@ for (const { name, filters, params, ids, total } of cases) {
133
127
  : "ORDER BY lastUpdatedAt DESC, priority DESC",
134
128
  );
135
129
  expect(await db.getTasksCount(filters)).toBe(total ?? ids.length);
136
- expect(countSpy.mock.calls[0]?.[1]).toEqual(params);
130
+ const countCall = countSpy.mock.calls.find(([query]) => query.includes("COUNT"));
131
+ expect(countCall?.[1]).toEqual(params);
137
132
  expect((await db.getAllTasks({ ...filters, limit: 100, offset: 0 })).length).toBe(
138
133
  await db.getTasksCount(filters),
139
134
  );
@@ -0,0 +1,161 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { makeDeployStubs } from "./deploy-stubs";
6
+ import { CHILD_PROCESS_TEST_BUDGET_MS, runChild } from "./test-proc";
7
+
8
+ // deploy/one-box/bootstrap.sh runs for real here, under a scratch root
9
+ // (AGENT_SWARM_BOOTSTRAP_ROOT) with stand-ins for docker, curl, apt-get, ufw,
10
+ // systemctl and caddy. By default it runs the repo's install.sh, whose onboard
11
+ // step is a stand-in for the CLI release on npm before AGENT_SWARM_BIND_ADDRESS:
12
+ // port mappings without a host address.
13
+
14
+ const ROOT = resolve(import.meta.dir, "../..");
15
+ const BOOTSTRAP = resolve(ROOT, "deploy/one-box/bootstrap.sh");
16
+ const read = (path: string) => Bun.file(resolve(ROOT, path)).text();
17
+ const API_KEY = "one-box-test-key";
18
+ const HOST = "203-0-113-7.sslip.io";
19
+
20
+ let dir: string;
21
+ let root: string;
22
+
23
+ beforeEach(async () => {
24
+ dir = await mkdtemp(join(tmpdir(), "deploy-one-box-"));
25
+ root = join(dir, "root");
26
+ for (const sub of ["etc/agent-swarm", "etc/caddy", "etc/profile.d", "var/log", "root"]) {
27
+ await mkdir(join(root, sub), { recursive: true });
28
+ }
29
+ });
30
+
31
+ afterEach(async () => {
32
+ await rm(dir, { recursive: true, force: true });
33
+ });
34
+
35
+ async function bootstrap(installUrl = `file://${ROOT}/install.sh`) {
36
+ await Bun.write(
37
+ join(root, "etc/agent-swarm/deploy.env"),
38
+ `SWARM_DOMAIN=\nAGENT_SWARM_INSTALL_URL=${installUrl}\n`,
39
+ );
40
+ const stubs = await makeDeployStubs(dir, {
41
+ AGENT_SWARM_BOOTSTRAP_ROOT: root,
42
+ FAKE_API_KEY: API_KEY,
43
+ });
44
+ const result = await runChild(["bash", BOOTSTRAP], { env: stubs.env });
45
+ const status = (await readFile(join(root, "var/lib/agent-swarm/deploy-status"), "utf8")).trim();
46
+ return { ...result, out: result.stdout + result.stderr, status, stubs };
47
+ }
48
+
49
+ describe("one-box bootstrap.sh", () => {
50
+ test(
51
+ "keeps every published port on loopback from the first docker compose up, with a CLI that ignores --bind",
52
+ async () => {
53
+ const r = await bootstrap();
54
+ expect(r.out).toContain(`Agent Swarm is running at https://${HOST}`);
55
+ expect(r.exitCode).toBe(0);
56
+ expect(r.status).toBe("done");
57
+
58
+ // Compose puts the stack on its own network, which daemon.json's "ip" does
59
+ // not cover: the first `up`, inside onboard, must find its default set.
60
+ expect((await r.stubs.ups())[0]).toBe("agent-swarm_default 127.0.0.1");
61
+ const history = await r.stubs.history();
62
+ expect(history.length).toBeGreaterThan(0);
63
+ expect(history.filter((p) => !p.startsWith("127.0.0.1:"))).toEqual([]);
64
+ expect(await r.stubs.ports()).toEqual([
65
+ "127.0.0.1:3013->3013/tcp",
66
+ "127.0.0.1:3201->3000/tcp",
67
+ ]);
68
+
69
+ expect(await readFile(join(root, "etc/caddy/Caddyfile"), "utf8")).toContain(
70
+ `${HOST} {\n\treverse_proxy 127.0.0.1:3013\n}`,
71
+ );
72
+ // The /setup link holds the API key: it goes to a root-only file, never stdout.
73
+ const link = await readFile(join(root, "root/agent-swarm-setup-link.txt"), "utf8");
74
+ expect(link).toContain(`/setup?apiUrl=https%3A%2F%2F${HOST}&apiKey=${API_KEY}`);
75
+ expect(r.out).not.toContain(API_KEY);
76
+ },
77
+ CHILD_PROCESS_TEST_BUDGET_MS,
78
+ );
79
+
80
+ test(
81
+ "stops the stack when install.sh fails after publishing a port on a public interface",
82
+ async () => {
83
+ // A stand-in install.sh: starts a stack bound to 0.0.0.0 explicitly, then fails.
84
+ const script = join(dir, "failing-install.sh");
85
+ await Bun.write(
86
+ script,
87
+ [
88
+ "#!/usr/bin/env bash",
89
+ "set -euo pipefail",
90
+ 'dir=""',
91
+ 'while [ $# -gt 0 ]; do case "$1" in --dir) dir="$2"; shift 2 ;; *) shift ;; esac; done',
92
+ 'mkdir -p "$dir" && cd "$dir"',
93
+ `printf 'services:\\n swarm-api:\\n ports:\\n - "0.0.0.0:3013:3013"\\n' >docker-compose.yml`,
94
+ "touch .env",
95
+ "docker compose --env-file .env up -d",
96
+ "echo 'API did not become healthy' >&2",
97
+ "exit 1",
98
+ "",
99
+ ].join("\n"),
100
+ );
101
+ const r = await bootstrap(`file://${script}`);
102
+ expect(r.exitCode).not.toBe(0);
103
+ expect(r.status).toBe("failed");
104
+ expect(await r.stubs.history()).toEqual(["0.0.0.0:3013->3013/tcp"]);
105
+ expect(await r.stubs.ports()).toEqual([]);
106
+ expect(await r.stubs.calls()).toContain("compose stop");
107
+ expect(r.out).toContain("stopped the stack because it published ports on a public interface");
108
+ },
109
+ CHILD_PROCESS_TEST_BUDGET_MS,
110
+ );
111
+
112
+ test(
113
+ "refuses an existing daemon.json without the loopback default before starting anything",
114
+ async () => {
115
+ await mkdir(join(root, "etc/docker"), { recursive: true });
116
+ await Bun.write(join(root, "etc/docker/daemon.json"), '{ "log-driver": "json-file" }\n');
117
+ const r = await bootstrap();
118
+ expect(r.exitCode).not.toBe(0);
119
+ expect(r.status).toBe("failed");
120
+ expect(r.out).toContain('without "ip": "127.0.0.1"');
121
+ expect(await r.stubs.history()).toEqual([]);
122
+ },
123
+ CHILD_PROCESS_TEST_BUDGET_MS,
124
+ );
125
+ });
126
+
127
+ interface CloudConfig {
128
+ write_files: { path: string; permissions?: string; content: string }[];
129
+ runcmd: string[][];
130
+ }
131
+
132
+ describe("one-box deploy packaging", () => {
133
+ test("cloud-init.yaml embeds bootstrap.sh byte for byte and runs it", async () => {
134
+ const yaml = await read("deploy/cloud-init.yaml");
135
+ expect(yaml.startsWith("#cloud-config\n")).toBe(true);
136
+ const config = Bun.YAML.parse(yaml) as CloudConfig;
137
+ const script = config.write_files.find(
138
+ (f) => f.path === "/usr/local/sbin/agent-swarm-bootstrap",
139
+ );
140
+ expect(script?.permissions).toBe("0700");
141
+ // On a mismatch, copy deploy/one-box/bootstrap.sh into the write_files entry (6-space indent).
142
+ expect(script?.content).toBe(await read("deploy/one-box/bootstrap.sh"));
143
+ expect(config.runcmd).toEqual([["/usr/local/sbin/agent-swarm-bootstrap"]]);
144
+
145
+ const settings = config.write_files.find((f) => f.path === "/etc/agent-swarm/deploy.env");
146
+ expect(settings?.permissions).toBe("0600");
147
+ expect(settings?.content).toContain("\nSWARM_DOMAIN=\n");
148
+ });
149
+
150
+ // Structural only: CI has no packer binary to run `packer validate`.
151
+ test("the DigitalOcean image runs the same bootstrap.sh on first boot", async () => {
152
+ const template = await read("deploy/digitalocean/agent-swarm.pkr.hcl");
153
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: Packer HCL syntax
154
+ expect(template).toContain('source = "${path.root}/../one-box/bootstrap.sh"');
155
+ expect(template).toContain('destination = "/opt/agent-swarm/bootstrap.sh"');
156
+ const onboot = await read(
157
+ "deploy/digitalocean/files/var/lib/cloud/scripts/per-instance/001_onboot",
158
+ );
159
+ expect(onboot).toContain("\n/opt/agent-swarm/bootstrap.sh\n");
160
+ });
161
+ });
@@ -0,0 +1,68 @@
1
+ import { chmod, mkdir, readFile, symlink } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+
4
+ // A PATH of stand-ins (fixtures/deploy-stubs) so install.sh and
5
+ // deploy/one-box/bootstrap.sh run for real without Docker, root or a network.
6
+ // The fake docker records what the stack publishes; see its header.
7
+
8
+ const STUBS = resolve(import.meta.dir, "fixtures/deploy-stubs");
9
+ const NO_OPS = ["apt-get", "caddy", "systemctl", "ufw"];
10
+
11
+ export interface DeployStubs {
12
+ /** Environment for the child: the stub PATH plus the fake docker's settings. */
13
+ env: Record<string, string>;
14
+ /** The fake docker's state directory. */
15
+ state: string;
16
+ /** Published ports of the running stack, in `docker ps` form. */
17
+ ports(): Promise<string[]>;
18
+ /** Every port the stack ever published. */
19
+ history(): Promise<string[]>;
20
+ /** Every docker invocation. */
21
+ calls(): Promise<string[]>;
22
+ /** One "<network> <default host address>" line per `docker compose up`. */
23
+ ups(): Promise<string[]>;
24
+ /** Create a project network as an earlier `up` would, with this default address ("" for none). */
25
+ network(name: string, hostBinding: string): Promise<void>;
26
+ }
27
+
28
+ async function lines(path: string): Promise<string[]> {
29
+ const text = await readFile(path, "utf8").catch(() => "");
30
+ return text.split("\n").filter(Boolean);
31
+ }
32
+
33
+ export async function makeDeployStubs(
34
+ dir: string,
35
+ env: Record<string, string> = {},
36
+ ): Promise<DeployStubs> {
37
+ const bin = join(dir, "bin");
38
+ const state = join(dir, "docker-state");
39
+ await mkdir(bin, { recursive: true });
40
+ await mkdir(state, { recursive: true });
41
+ for (const name of ["docker", "curl", "bun", "bunx"]) {
42
+ await symlink(join(STUBS, name), join(bin, name));
43
+ }
44
+ for (const name of NO_OPS) {
45
+ await Bun.write(join(bin, name), "#!/bin/sh\nexit 0\n");
46
+ await chmod(join(bin, name), 0o755);
47
+ }
48
+ // bootstrap.sh insists on root.
49
+ await Bun.write(join(bin, "id"), "#!/bin/sh\necho 0\n");
50
+ await chmod(join(bin, "id"), 0o755);
51
+
52
+ return {
53
+ env: {
54
+ PATH: `${bin}:/usr/bin:/bin`,
55
+ HOME: dir,
56
+ FAKE_DOCKER_STATE: state,
57
+ ...env,
58
+ },
59
+ state,
60
+ ports: () => lines(join(state, "ports")),
61
+ history: () => lines(join(state, "history")),
62
+ calls: () => lines(join(state, "calls")),
63
+ ups: () => lines(join(state, "ups")),
64
+ network: async (name, hostBinding) => {
65
+ await Bun.write(join(state, "networks", name), `${hostBinding}\n`);
66
+ },
67
+ };
68
+ }
@@ -78,7 +78,7 @@ describe("docs API index footprint", () => {
78
78
  expect(tasks).toContain('"path":"/api/task-templates","method":"get"');
79
79
  });
80
80
 
81
- test("OpenAPI and generated pages keep all 466 operations", async () => {
81
+ test("OpenAPI and generated pages keep all 467 operations", async () => {
82
82
  const spec = await Bun.file(resolve(repoRoot, "openapi.json")).json();
83
83
  const operationKeys = [];
84
84
  const tags = new Set();
@@ -103,8 +103,8 @@ describe("docs API index footprint", () => {
103
103
  expect(spec.paths["/api/agents/{id}/runtime"]?.get).toBeDefined();
104
104
 
105
105
  const generatedKeys = await generatedOperationKeys();
106
- expect(operationKeys).toHaveLength(466);
107
- expect(generatedKeys).toHaveLength(466);
106
+ expect(operationKeys).toHaveLength(467);
107
+ expect(generatedKeys).toHaveLength(467);
108
108
  expect(new Set(generatedKeys)).toEqual(new Set(operationKeys));
109
109
  });
110
110
 
@@ -376,7 +376,7 @@ describe("dsh harness", () => {
376
376
  await rename(join(config.cwd, "dsh"), join(config.cwd, "npx"));
377
377
  config.env = { ...config.env, DSH_BINARY: "", PATH: config.cwd };
378
378
  await expect(new DshAdapter().createSession(config)).rejects.toThrow(
379
- "dsh CLI not found. Install @deepseek-ai/dsh@0.2.1-rc.4",
379
+ "dsh CLI not found. Install @deepseek-ai/dsh@0.2.2-alpha.2",
380
380
  );
381
381
  expect(await Bun.file(join(config.cwd, "invocation.json")).exists()).toBe(false);
382
382
  });
@@ -288,18 +288,35 @@ describe("nightly workflow codex chain", () => {
288
288
  const run = byName(all, "Run harness leg");
289
289
  const save = byName(all, "Save Codex auth chain");
290
290
  const verify = byName(all, "Verify Codex auth chain was saved");
291
+ const check = byName(all, "Check for a Codex auth chain to save");
291
292
  const mark = byName(all, "Fail the codex result when the chain was not saved");
292
293
  const upload = byName(all, "Upload harness results");
293
294
  expect(all.indexOf(mark)).toBeGreaterThan(all.indexOf(verify));
294
295
  expect(all.indexOf(mark)).toBeLessThan(all.indexOf(upload));
295
296
  expect(mark.if).toBe(
296
- `always() && (steps.${save.id}.outcome == 'failure' || steps.${verify.id}.outcome == 'failure')`,
297
+ `always() && (steps.${check.id}.outcome == 'failure' || steps.${save.id}.outcome == 'failure' || steps.${verify.id}.outcome == 'failure')`,
297
298
  );
298
299
  const resultsPath = run.run?.match(/--json (\S+)/)?.[1];
299
300
  expect(resultsPath).toBeDefined();
300
301
  expect(mark.run).toBe(`bun scripts/e2e/codex-chain-lost.ts ${resultsPath}`);
301
302
  });
302
303
 
304
+ // The worker image runs steps as root and the harness writes the chain mode 600. hashFiles()
305
+ // runs on the host as the runner user, cannot read that file, and fails the whole condition.
306
+ test("the save is gated by an in-container file check, never hashFiles()", async () => {
307
+ const all = await steps();
308
+ const check = byName(all, "Check for a Codex auth chain to save");
309
+ const save = byName(all, "Save Codex auth chain");
310
+ expect(all.indexOf(check)).toBeLessThan(all.indexOf(save));
311
+ expect(check.if).toBe(
312
+ "always() && matrix.provider == 'codex' && github.ref == 'refs/heads/main'",
313
+ );
314
+ expect(check.run).toContain(`[ -f ${save.with?.path} ]`);
315
+ expect(check.run).toContain(`echo "present=true" >> "$GITHUB_OUTPUT"`);
316
+ expect(save.if).toBe(`always() && steps.${check.id}.outputs.present == 'true'`);
317
+ for (const step of all) expect(step.if ?? "").not.toContain("hashFiles(");
318
+ });
319
+
303
320
  test("the codex leg and its secret stay on main", async () => {
304
321
  const all = await steps();
305
322
  const run = byName(all, "Run harness leg");
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+ # Fake bun for the deploy tests: install.sh only asks for its version.
3
+ echo 1.3.0
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env bash
2
+ # Fake `bunx --package @desplega.ai/agent-swarm agent-swarm onboard` for the
3
+ # deploy tests. Like the CLI release on npm before AGENT_SWARM_BIND_ADDRESS
4
+ # (2.7.1), it writes port mappings without a host address and starts the stack.
5
+ # After the stack is up, FAKE_ONBOARD_FAIL=1 makes it fail and
6
+ # FAKE_ONBOARD_FAIL=term sends SIGTERM to the installer, like a Ctrl-C.
7
+ # FAKE_ONBOARD_API_PORT replaces the API's "3013:3013" mapping.
8
+ set -euo pipefail
9
+ cat >docker-compose.yml <<YAML
10
+ services:
11
+ swarm-api:
12
+ image: ghcr.io/desplega-ai/agent-swarm:latest
13
+ ports:
14
+ - "${FAKE_ONBOARD_API_PORT:-3013:3013}"
15
+ volumes:
16
+ - swarm_data:/app/data
17
+ coder:
18
+ image: ghcr.io/desplega-ai/agent-swarm-worker:latest
19
+ ports:
20
+ - "3201:3000"
21
+ YAML
22
+ printf 'API_KEY=%s\nMCP_BASE_URL=http://localhost:3013\n' "${FAKE_API_KEY:-test-api-key}" >.env
23
+ docker compose --env-file .env up -d
24
+ case "${FAKE_ONBOARD_FAIL:-}" in
25
+ 1) echo "onboard: health check failed" >&2; exit 1 ;;
26
+ term) kill -TERM "$PPID" ;;
27
+ esac
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env bash
2
+ # Fake curl for the deploy tests: the public IP lookup, file:// downloads and
3
+ # health checks. Anything else fails, so a test never reaches the network.
4
+ set -euo pipefail
5
+ out="" url=""
6
+ while [ $# -gt 0 ]; do
7
+ case "$1" in
8
+ -o | --output) out="$2"; shift 2 ;;
9
+ -m | --max-time) shift 2 ;;
10
+ -*) shift ;;
11
+ *) url="$1"; shift ;;
12
+ esac
13
+ done
14
+ case "$url" in
15
+ https://checkip.amazonaws.com* | https://api.ipify.org* | https://ipv4.icanhazip.com*)
16
+ echo "${FAKE_PUBLIC_IP:-203.0.113.7}"
17
+ ;;
18
+ file://*)
19
+ if [ -n "$out" ] && [ "$out" != /dev/null ]; then cp "${url#file://}" "$out"; else cat "${url#file://}"; fi
20
+ ;;
21
+ */health) exit "${FAKE_HEALTH_EXIT:-0}" ;;
22
+ *)
23
+ echo "fake curl: no network in tests ($url)" >&2
24
+ exit 22
25
+ ;;
26
+ esac
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env bash
2
+ # Fake docker for the deploy tests (install.sh, deploy/one-box/bootstrap.sh).
3
+ # It reads port mappings from ./docker-compose.yml and the override file the way
4
+ # Compose and dockerd do, and keeps its state in $FAKE_DOCKER_STATE:
5
+ # calls every invocation
6
+ # ports published ports of the running stack, in `docker ps` form
7
+ # history every port ever published, so a test can see a short-lived exposure
8
+ # ups one line per `compose up`: the project network and its default address
9
+ # networks/ one file per network, holding its host_binding_ipv4 option
10
+ # Like dockerd, a port without a host address goes to its network's
11
+ # com.docker.network.bridge.host_binding_ipv4, else every interface. daemon.json
12
+ # "ip" covers only the default bridge, never a Compose project network. Like
13
+ # Compose before 2.31 (and any network without a config-hash label), an
14
+ # existing network is reused with its old options.
15
+ set -euo pipefail
16
+ state="${FAKE_DOCKER_STATE:?FAKE_DOCKER_STATE is not set}"
17
+ printf '%s\n' "$*" >>"$state/calls"
18
+ mkdir -p "$state/networks"
19
+ touch "$state/ports" "$state/history" "$state/ups"
20
+
21
+ # AGENT_SWARM_BIND_ADDRESS as Compose sees it: the shell wins over .env.
22
+ bind_address() {
23
+ if [ -n "${AGENT_SWARM_BIND_ADDRESS+x}" ]; then
24
+ printf '%s' "$AGENT_SWARM_BIND_ADDRESS"
25
+ elif [ -f .env ]; then
26
+ grep -E '^AGENT_SWARM_BIND_ADDRESS=' .env | tail -n 1 | cut -d= -f2- || true
27
+ fi
28
+ }
29
+
30
+ # docker-compose.yml plus the first override file Compose finds (compose-go
31
+ # loads only one).
32
+ compose_files() {
33
+ local f
34
+ echo docker-compose.yml
35
+ for f in compose.override.yml compose.override.yaml docker-compose.override.yml docker-compose.override.yaml; do
36
+ if [ -f "$f" ]; then
37
+ echo "$f"
38
+ return 0
39
+ fi
40
+ done
41
+ }
42
+
43
+ project_network() {
44
+ local p
45
+ p="$(printf '%s' "${COMPOSE_PROJECT_NAME:-$(basename "$PWD")}" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9_-')"
46
+ printf '%s_default' "${p#"${p%%[a-z0-9]*}"}"
47
+ }
48
+
49
+ compose_up() {
50
+ # shellcheck disable=SC2016 # the literal prefix the onboard generator writes
51
+ local prefix='${AGENT_SWARM_BIND_ADDRESS:+${AGENT_SWARM_BIND_ADDRESS:-}:}'
52
+ local addr net dip entry a b c f files=()
53
+ addr="$(bind_address)"
54
+ while IFS= read -r f; do files+=("$f"); done < <(compose_files)
55
+ net="$(project_network)"
56
+ if [ ! -f "$state/networks/$net" ]; then
57
+ sed -nE 's/^ *com\.docker\.network\.bridge\.host_binding_ipv4: *"?([^"]*)"? *$/\1/p' "${files[@]}" |
58
+ tail -n 1 >"$state/networks/$net"
59
+ fi
60
+ dip="$(cat "$state/networks/$net")"
61
+ dip="${dip:-0.0.0.0}"
62
+ printf '%s %s\n' "$net" "$dip" >>"$state/ups"
63
+ awk '/^ *ports: *$/ { p = 1; next }
64
+ p && /^ *- / { e = $0; sub(/^ *- */, "", e); gsub(/"/, "", e); print e; next }
65
+ { p = 0 }' "${files[@]}" |
66
+ while IFS= read -r entry; do
67
+ if [ "${entry#"$prefix"}" != "$entry" ]; then
68
+ entry="${entry#"$prefix"}"
69
+ [ -z "$addr" ] || entry="${addr}:${entry}"
70
+ fi
71
+ IFS=: read -r a b c <<<"$entry"
72
+ if [ -n "$c" ]; then
73
+ printf '%s:%s->%s/tcp\n' "$a" "$b" "$c"
74
+ else
75
+ printf '%s:%s->%s/tcp\n' "$dip" "$a" "$b"
76
+ fi
77
+ done >"$state/ports"
78
+ cat "$state/ports" >>"$state/history"
79
+ }
80
+
81
+ if [ "${1:-}" = compose ]; then
82
+ shift
83
+ verb=""
84
+ while [ $# -gt 0 ]; do
85
+ case "$1" in
86
+ --env-file | -f | -p) shift 2 ;;
87
+ -*) shift ;;
88
+ *) verb="$1"; break ;;
89
+ esac
90
+ done
91
+ case "$verb" in
92
+ version) echo "Docker Compose version v2.30.0" ;;
93
+ up) compose_up ;;
94
+ ps) cat "$state/ports" ;;
95
+ stop) : >"$state/ports" ;;
96
+ down) : >"$state/ports"; rm -f "$state/networks/$(project_network)" ;;
97
+ *) ;;
98
+ esac
99
+ exit 0
100
+ fi
101
+
102
+ case "${1:-}" in
103
+ ps) cat "$state/ports" ;;
104
+ network)
105
+ # docker network inspect [--format <template>] <name>
106
+ [ "${2:-}" = inspect ] || exit 0
107
+ format="" name=""
108
+ shift 2
109
+ while [ $# -gt 0 ]; do
110
+ case "$1" in
111
+ --format | -f) format="$2"; shift 2 ;;
112
+ *) name="$1"; shift ;;
113
+ esac
114
+ done
115
+ if [ ! -f "$state/networks/$name" ]; then
116
+ echo "Error response from daemon: network $name not found" >&2
117
+ exit 1
118
+ fi
119
+ if [ -n "$format" ]; then cat "$state/networks/$name"; else echo "[{\"Name\": \"$name\"}]"; fi
120
+ ;;
121
+ *) ;;
122
+ esac
@@ -32,7 +32,7 @@ services:
32
32
  - APP_URL=https://app.agent-swarm.dev
33
33
 
34
34
  ports:
35
- - "3013:3013"
35
+ - "${AGENT_SWARM_BIND_ADDRESS:+${AGENT_SWARM_BIND_ADDRESS:-}:}3013:3013"
36
36
 
37
37
  volumes:
38
38
  - swarm_data:/app/data
@@ -73,7 +73,7 @@ services:
73
73
  - MAX_CONCURRENT_TASKS=2
74
74
 
75
75
  ports:
76
- - "3201:3000"
76
+ - "${AGENT_SWARM_BIND_ADDRESS:+${AGENT_SWARM_BIND_ADDRESS:-}:}3201:3000"
77
77
 
78
78
  volumes:
79
79
  - swarm_logs:/logs
@@ -109,7 +109,7 @@ services:
109
109
  - MAX_CONCURRENT_TASKS=1
110
110
 
111
111
  ports:
112
- - "3202:3000"
112
+ - "${AGENT_SWARM_BIND_ADDRESS:+${AGENT_SWARM_BIND_ADDRESS:-}:}3202:3000"
113
113
 
114
114
  volumes:
115
115
  - swarm_logs:/logs
@@ -145,7 +145,7 @@ services:
145
145
  - MAX_CONCURRENT_TASKS=1
146
146
 
147
147
  ports:
148
- - "3203:3000"
148
+ - "${AGENT_SWARM_BIND_ADDRESS:+${AGENT_SWARM_BIND_ADDRESS:-}:}3203:3000"
149
149
 
150
150
  volumes:
151
151
  - swarm_logs:/logs
@@ -181,7 +181,7 @@ services:
181
181
  - MAX_CONCURRENT_TASKS=1
182
182
 
183
183
  ports:
184
- - "3204:3000"
184
+ - "${AGENT_SWARM_BIND_ADDRESS:+${AGENT_SWARM_BIND_ADDRESS:-}:}3204:3000"
185
185
 
186
186
  volumes:
187
187
  - swarm_logs:/logs
@@ -32,7 +32,7 @@ services:
32
32
  - APP_URL=https://app.agent-swarm.dev
33
33
 
34
34
  ports:
35
- - "3013:3013"
35
+ - "${AGENT_SWARM_BIND_ADDRESS:+${AGENT_SWARM_BIND_ADDRESS:-}:}3013:3013"
36
36
 
37
37
  volumes:
38
38
  - swarm_data:/app/data
@@ -73,7 +73,7 @@ services:
73
73
  - MAX_CONCURRENT_TASKS=2
74
74
 
75
75
  ports:
76
- - "3201:3000"
76
+ - "${AGENT_SWARM_BIND_ADDRESS:+${AGENT_SWARM_BIND_ADDRESS:-}:}3201:3000"
77
77
 
78
78
  volumes:
79
79
  - swarm_logs:/logs
@@ -109,7 +109,7 @@ services:
109
109
  - MAX_CONCURRENT_TASKS=1
110
110
 
111
111
  ports:
112
- - "3202:3000"
112
+ - "${AGENT_SWARM_BIND_ADDRESS:+${AGENT_SWARM_BIND_ADDRESS:-}:}3202:3000"
113
113
 
114
114
  volumes:
115
115
  - swarm_logs:/logs
@@ -145,7 +145,7 @@ services:
145
145
  - MAX_CONCURRENT_TASKS=1
146
146
 
147
147
  ports:
148
- - "3203:3000"
148
+ - "${AGENT_SWARM_BIND_ADDRESS:+${AGENT_SWARM_BIND_ADDRESS:-}:}3203:3000"
149
149
 
150
150
  volumes:
151
151
  - swarm_logs:/logs