@desplega.ai/agent-swarm 1.161.0 → 1.162.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 (224) hide show
  1. package/README.md +3 -2
  2. package/dist/{acp-adapter-xx489vvb.js → acp-adapter-74m73ae9.js} +1 -1
  3. package/dist/{actions-ykjkn0m9.js → actions-4sr14gnt.js} +34 -31
  4. package/dist/{anthropic-messages-5qx80j9k.js → anthropic-messages-8xxrzky9.js} +22 -26
  5. package/dist/{app-0hdqa4k1.js → app-pt68fbwp.js} +13 -11
  6. package/dist/{artifact-3v5t9jtg.js → artifact-fey7dvj9.js} +3 -2
  7. package/dist/{assistant-am2g5g8a.js → assistant-cv3jytks.js} +37 -34
  8. package/dist/{azure-openai-responses-fszbkp0e.js → azure-openai-responses-f5cqsyj8.js} +15 -10
  9. package/dist/{boot-reembed-xy9t7avg.js → boot-reembed-4ht4gqth.js} +13 -11
  10. package/dist/{boot-reembed-gt5gjkt4.js → boot-reembed-nxpcvpj9.js} +12 -10
  11. package/dist/{boot-scrub-logs-9x0985y5.js → boot-scrub-logs-k93kyw8k.js} +9 -7
  12. package/dist/{boot-seeds-trv0d4vg.js → boot-seeds-1skcrs0e.js} +13 -10
  13. package/dist/{claude-adapter-jb6e1e54.js → claude-adapter-r5g0tzge.js} +23 -20
  14. package/dist/{claude-managed-setup-d1q9hv2b.js → claude-managed-setup-pkyrgshb.js} +3 -2
  15. package/dist/{claude-sdk-session-zga700s2.js → claude-sdk-session-jxyxnrwd.js} +5565 -5512
  16. package/dist/{cli-we0q7t0e.js → cli-2zy95vhm.js} +137 -64
  17. package/dist/{cli-35pj2yva.js → cli-4cwxn7gp.js} +38 -11
  18. package/dist/{cli-ad4qxm6j.js → cli-4dcpaefp.js} +2 -2
  19. package/dist/{cli-82c1pacn.js → cli-4x0csdjq.js} +71 -37
  20. package/dist/{cli-sqm3r5ps.js → cli-5bcd7sfy.js} +14 -18
  21. package/dist/{cli-d56tgd45.js → cli-5k6k8ta3.js} +2 -2
  22. package/dist/{cli-1er0rhz8.js → cli-5xhpb3kv.js} +15 -15
  23. package/dist/{cli-yrg7c73k.js → cli-7af8yhav.js} +10 -183
  24. package/dist/{cli-r6fsxc76.js → cli-7tre70zp.js} +513 -961
  25. package/dist/{cli-bh7jed3k.js → cli-8ev9xqnx.js} +34 -18
  26. package/dist/cli-bsky8g7w.js +449 -0
  27. package/dist/{cli-hn04t9db.js → cli-cebmgwsc.js} +14 -10
  28. package/dist/{cli-cfkrztza.js → cli-cvatsam7.js} +2 -2
  29. package/dist/{cli-2658f6at.js → cli-cvk6yzw5.js} +6 -8
  30. package/dist/{cli-ptr8h7kp.js → cli-eg0b3p0k.js} +5 -218
  31. package/dist/{cli-zb6xbk61.js → cli-g739zqzt.js} +4 -4
  32. package/dist/{cli-dv6cdjs2.js → cli-gd4favx9.js} +28 -7
  33. package/dist/{cli-5p3x4zna.js → cli-gpqpmns7.js} +2 -2
  34. package/dist/{cli-5nbmpy8w.js → cli-hb5c2x5y.js} +1 -1
  35. package/dist/{cli-jpf7e1zq.js → cli-hmsw1gpy.js} +3 -3
  36. package/dist/{cli-1cz0c18q.js → cli-jf7wmv1r.js} +2521 -733
  37. package/dist/{cli-qwsnmjdd.js → cli-jxs2ygaw.js} +168 -61
  38. package/dist/cli-kdkwd3yr.js +615 -0
  39. package/dist/{cli-hgmnf38m.js → cli-kh04eqwe.js} +1 -1
  40. package/dist/{cli-0383bp7f.js → cli-kp42yek0.js} +425 -90
  41. package/dist/{cli-ybgv4kw2.js → cli-mbssg1xv.js} +9 -2
  42. package/dist/{cli-jsp14b6c.js → cli-mzfth0bp.js} +38 -1
  43. package/dist/{cli-8g6mc14k.js → cli-nq7yqw7b.js} +16 -5
  44. package/dist/{cli-ee8bc46j.js → cli-nt8hecg9.js} +33 -32
  45. package/dist/{cli-9s68w752.js → cli-p0r8sxj5.js} +1 -1
  46. package/dist/{cli-58e8yr0d.js → cli-pkd6s2wy.js} +4 -2
  47. package/dist/{cli-17xpf7cm.js → cli-q24re23n.js} +6 -4
  48. package/dist/cli-qjjvzwzd.js +222 -0
  49. package/dist/cli-qrgt5bqf.js +189 -0
  50. package/dist/{cli-et8m9xsv.js → cli-rfjw9jw2.js} +22 -10
  51. package/dist/{cli-798vkbks.js → cli-rj0gqwwp.js} +2 -2
  52. package/dist/{cli-121ph74b.js → cli-rnyzn44p.js} +1 -1
  53. package/dist/{cli-vnnz5ncg.js → cli-s3hwpt9v.js} +95 -16
  54. package/dist/{cli-7119ja3w.js → cli-sptrj5f5.js} +2 -2
  55. package/dist/{cli-a8c82p2t.js → cli-sqvfa090.js} +13 -6
  56. package/dist/{cli-phfg8vy8.js → cli-swfmp1fx.js} +26 -26
  57. package/dist/{cli-nsdxtcbg.js → cli-tcen9p5w.js} +8 -5
  58. package/dist/{cli-9m03pzga.js → cli-vc9bp061.js} +5 -5
  59. package/dist/{cli-rtx9dygp.js → cli-vxe6qa4h.js} +1 -1
  60. package/dist/{cli-ecfxwnyb.js → cli-vy3ed88x.js} +1 -1
  61. package/dist/{cli-0c7ata9h.js → cli-vyqs9ya8.js} +6 -6
  62. package/dist/cli-wbvnn6tk.js +861 -0
  63. package/dist/{cli-kgd3p0bm.js → cli-wjgs5wxe.js} +6 -6
  64. package/dist/cli-wxkkd0e0.js +33 -0
  65. package/dist/{cli-akfj32vj.js → cli-xqpk3rp7.js} +5 -4
  66. package/dist/cli-zp38xvtm.js +9487 -0
  67. package/dist/{cli-3zc55ctx.js → cli-zvhd3f64.js} +2 -2
  68. package/dist/{cli-ze1d3wzv.js → cli-zxqx25nz.js} +5 -5
  69. package/dist/cli.js +32 -25
  70. package/dist/{cloudflare-workers-ai-system-one-k6fbxw9d.js → cloudflare-workers-ai-system-one-k8ddqjkq.js} +8 -6
  71. package/dist/{codex-adapter-04v97vep.js → codex-adapter-esvmnhn7.js} +13 -13
  72. package/dist/{codex-hook-20me6byw.js → codex-hook-m73jmqgx.js} +19 -3
  73. package/dist/{codex-session-runner-ax69mqq3.js → codex-session-runner-ban9excr.js} +13 -13
  74. package/dist/{commands-18gy08n1.js → commands-75413p2n.js} +9 -7
  75. package/dist/{db-d2pvjpyt.js → db-7f5pecwg.js} +11 -7
  76. package/dist/{dispatcher-8wbwyzjg.js → dispatcher-fh9at8nz.js} +33 -30
  77. package/dist/{dsh-adapter-ysqk9snv.js → dsh-adapter-wpze65d4.js} +3 -3
  78. package/dist/{e2b-esmt1r16.js → e2b-gjgdjp91.js} +10 -9
  79. package/dist/{pi-logo-animation-e9a0t68z.js → easter-egg-3d-3w5c2n4c.js} +122 -68
  80. package/dist/{execute-cmagrbrz.js → execute-f50wg9nh.js} +29 -6
  81. package/dist/{google-generative-ai-k6n5nbpp.js → google-generative-ai-jxxpnegy.js} +2 -2
  82. package/dist/{google-vertex-44xxtcvp.js → google-vertex-x7q1angm.js} +5 -5
  83. package/dist/{handlers-6b69m5kp.js → handlers-w13787bb.js} +36 -33
  84. package/dist/{hook-srnkt0hb.js → hook-46g1jrvx.js} +28 -15
  85. package/dist/{hook-k3mydf2n.js → hook-4tpts2ry.js} +20 -17
  86. package/dist/{http-hrf80ca1.js → http-19pzbfrt.js} +964 -176
  87. package/dist/{index-9rxdvj6w.js → index-8fd4dm4h.js} +34 -31
  88. package/dist/{index-1asbaj62.js → index-dqmp15bq.js} +35 -32
  89. package/dist/{index-45evq4ee.js → index-qb4q1y3e.js} +33 -30
  90. package/dist/{index-k4nj18qs.js → index-s75wc4wd.js} +12 -10
  91. package/dist/{index-xmevk2b9.js → index-scptvdxg.js} +27 -25
  92. package/dist/{keepalive-sfdz6ksp.js → keepalive-xa5ts9cd.js} +11 -9
  93. package/dist/{lead-ej91e9hw.js → lead-qp4xkvjq.js} +45 -41
  94. package/dist/{maintenance-80dnd7ke.js → maintenance-rm5885hm.js} +13 -11
  95. package/dist/{mistral-conversations-pc3ntnxt.js → mistral-conversations-rw7fbt22.js} +4 -4
  96. package/dist/{node-a5e00qhp.js → node-8zkdq42h.js} +834 -833
  97. package/dist/{oauth-refresh-sweep-m1etk2mx.js → oauth-refresh-sweep-1csdfebf.js} +10 -8
  98. package/dist/{onboard-0q8sdfqc.js → onboard-r6b78cs4.js} +10 -10
  99. package/dist/{openai-codex-responses-n7n683p2.js → openai-codex-responses-efvrgrq3.js} +4 -4
  100. package/dist/{openai-completions-jpaj5g2b.js → openai-completions-bbbrrz4z.js} +12 -8
  101. package/dist/{openai-responses-9a3nme9j.js → openai-responses-9jjyrcm1.js} +15 -10
  102. package/dist/{opencode-adapter-4wcgmvgf.js → opencode-adapter-kb3g6pkf.js} +2 -2
  103. package/dist/{otel-impl-hnqm9mxq.js → otel-impl-0y9knpky.js} +5 -5
  104. package/dist/{pi-mono-adapter-d6sqm9wr.js → pi-mono-adapter-z6s1bme2.js} +100 -19
  105. package/dist/{pricing-refresh-y60z64pm.js → pricing-refresh-mth26tgp.js} +11 -9
  106. package/dist/{rbac-roles-8pa0754b.js → rbac-roles-kwntb23q.js} +13 -9
  107. package/dist/rbac-roles-vpk4ebm8.js +45 -0
  108. package/dist/{registry-v5wytg0s.js → registry-km6t1jd2.js} +1 -1
  109. package/dist/{render-v2-418s1vj7.js → render-v2-hcm7h00y.js} +13 -11
  110. package/dist/{runtime-3pknc168.js → runtime-vfjsfsc5.js} +4 -4
  111. package/dist/{setup-yghdkj4x.js → setup-hbyzd65h.js} +2 -2
  112. package/dist/{virtual-modules-jcceevt5.js → virtual-modules-9bv7ry91.js} +9 -8
  113. package/dist/{work-objects-j1sz20v9.js → work-objects-m09sxnhe.js} +36 -33
  114. package/dist/{worker-d4gcvkjt.js → worker-0h35rvk4.js} +45 -41
  115. package/openapi.json +491 -12
  116. package/package.json +13 -6
  117. package/src/be/api-drain.ts +184 -0
  118. package/src/be/db/tasks/read.ts +31 -6
  119. package/src/be/db/tasks/write.ts +5 -4
  120. package/src/be/db.ts +42 -18
  121. package/src/be/memory/link-resolver.ts +29 -3
  122. package/src/be/migrations/192_favorites_agent_fs_path.sql +36 -0
  123. package/src/be/onboarding.ts +7 -1
  124. package/src/be/seed-prompt-templates.ts +1 -0
  125. package/src/be/swarm-config-guard.ts +35 -16
  126. package/src/be/task-citations.ts +11 -5
  127. package/src/be/users.ts +3 -0
  128. package/src/cli.tsx +3 -2
  129. package/src/comb/agent-fs.ts +39 -0
  130. package/src/comb/config.ts +37 -0
  131. package/src/comb/markers.ts +37 -0
  132. package/src/comb/review-batch.ts +486 -0
  133. package/src/comb/templates.ts +88 -0
  134. package/src/commands/context-preamble.ts +4 -1
  135. package/src/commands/hook.ts +2 -1
  136. package/src/commands/runner.ts +262 -68
  137. package/src/commands/templates.ts +10 -0
  138. package/src/extensions/contract-types.generated.ts +1 -1
  139. package/src/fs/agent-fs-provider.ts +106 -2
  140. package/src/heartbeat/heartbeat.ts +4 -0
  141. package/src/hooks/codex-hook.ts +25 -1
  142. package/src/hooks/hook.ts +13 -0
  143. package/src/hooks/pr-body-guard.ts +983 -0
  144. package/src/http/all-routes.ts +1 -0
  145. package/src/http/assets.ts +5 -17
  146. package/src/http/comb.ts +131 -0
  147. package/src/http/core.ts +2 -1
  148. package/src/http/fs.ts +9 -17
  149. package/src/http/index.ts +23 -9
  150. package/src/http/onboarding.ts +21 -2
  151. package/src/http/poll.ts +30 -3
  152. package/src/http/realtime.ts +62 -0
  153. package/src/http/request-principal.ts +22 -0
  154. package/src/http/status.ts +34 -2
  155. package/src/kv-reserved-namespaces.ts +10 -5
  156. package/src/providers/dsh-adapter.ts +1 -1
  157. package/src/providers/pi-mono-adapter.ts +123 -0
  158. package/src/rbac/legacy-policy.ts +1 -0
  159. package/src/rbac/permissions.ts +4 -0
  160. package/src/realtime/auth.ts +63 -1
  161. package/src/realtime/tickets.ts +52 -0
  162. package/src/realtime/transport.ts +51 -22
  163. package/src/scripts-runtime/types/swarm-extension.d.ts +3 -1
  164. package/src/telemetry-context.ts +175 -0
  165. package/src/telemetry-contract/EVENTS.md +81 -0
  166. package/src/telemetry-contract/SOURCE.json +9 -0
  167. package/src/telemetry-contract/catalog.json +1335 -0
  168. package/src/telemetry-contract/index.ts +60 -0
  169. package/src/telemetry-contract/types.gen.ts +1971 -0
  170. package/src/telemetry-identity.ts +129 -0
  171. package/src/telemetry-snapshot.ts +211 -0
  172. package/src/telemetry-trigger.ts +125 -0
  173. package/src/telemetry-types.typetest.ts +40 -0
  174. package/src/telemetry.ts +271 -67
  175. package/src/tests/api-drain-e2e.test.ts +249 -0
  176. package/src/tests/api-drain-worker-e2e.test.ts +216 -0
  177. package/src/tests/api-drain.test.ts +351 -0
  178. package/src/tests/comb-config-guard.test.ts +38 -0
  179. package/src/tests/comb-links.test.ts +162 -0
  180. package/src/tests/comb-markers.test.ts +68 -0
  181. package/src/tests/comb-review-batch.test.ts +770 -0
  182. package/src/tests/comb-status.test.ts +152 -0
  183. package/src/tests/db-task-read-contract.test.ts +2 -0
  184. package/src/tests/db-task-write-contract.test.ts +1 -0
  185. package/src/tests/docs-index-footprint.test.js +3 -3
  186. package/src/tests/dsh-adapter.test.ts +1 -1
  187. package/src/tests/favorites-agent-fs-path.test.ts +226 -0
  188. package/src/tests/memory-link-resolver.test.ts +34 -0
  189. package/src/tests/onboarding.test.ts +108 -0
  190. package/src/tests/pi-mono-adapter.test.ts +265 -2
  191. package/src/tests/pr-body-leaks.test.ts +789 -0
  192. package/src/tests/preload.ts +8 -0
  193. package/src/tests/rbac-engine.test.ts +19 -0
  194. package/src/tests/realtime-room-auth.test.ts +104 -1
  195. package/src/tests/realtime-tickets.test.ts +74 -0
  196. package/src/tests/realtime-transport.test.ts +131 -4
  197. package/src/tests/runner-api-drain.test.ts +301 -0
  198. package/src/tests/runner-fallback-output.test.ts +61 -0
  199. package/src/tests/runner-fenced-json-output.test.ts +178 -0
  200. package/src/tests/script-connections.test.ts +2 -0
  201. package/src/tests/send-task-requested-by.test.ts +133 -15
  202. package/src/tests/task-lifecycle-telemetry.test.ts +8 -3
  203. package/src/tests/telemetry-context.test.ts +314 -0
  204. package/src/tests/telemetry-init.test.ts +51 -19
  205. package/src/tests/telemetry-snapshot.test.ts +229 -0
  206. package/src/tests/telemetry-trigger.test.ts +200 -0
  207. package/src/tests/template-registry-helpers.ts +1 -0
  208. package/src/tests/workflow-agent-task-retry.test.ts +363 -0
  209. package/src/tests/workflow-definition-telemetry.test.ts +4 -4
  210. package/src/tools/poll-task.ts +20 -1
  211. package/src/tools/send-task.ts +9 -1
  212. package/src/tools/task-action.ts +11 -0
  213. package/src/types.ts +2 -1
  214. package/src/utils/api-drain.ts +24 -0
  215. package/src/utils/constants.ts +57 -16
  216. package/src/utils/pr-body-leaks.ts +172 -0
  217. package/src/utils/task-attachment-links.ts +14 -8
  218. package/src/workflows/recovery.ts +26 -1
  219. package/src/workflows/resume.ts +31 -4
  220. package/src/workflows/task-step-routing.ts +75 -2
  221. package/tsconfig.json +3 -0
  222. package/dist/cli-tjf4cn8z.js +0 -9119
  223. package/dist/cli-tsk3nz8a.js +0 -582
  224. package/dist/{cli-t6v2ah3r.js → cli-twrh04ap.js} +3 -3
@@ -0,0 +1,363 @@
1
+ import { afterAll, afterEach, beforeAll, describe, expect, spyOn, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { z } from "zod";
4
+ import * as db from "../be/db";
5
+ import {
6
+ cancelTask,
7
+ closeDb,
8
+ completeTask,
9
+ createWorkflow,
10
+ failTask,
11
+ getTaskById,
12
+ getTaskByWorkflowRunStepId,
13
+ getWorkflowRun,
14
+ getWorkflowRunStepsByRunId,
15
+ initDb,
16
+ } from "../be/db";
17
+ import type { AgentTask, WorkflowDefinition, WorkflowRunStep } from "../types";
18
+ import { startWorkflowExecution } from "../workflows/engine";
19
+ import { workflowEventBus } from "../workflows/event-bus";
20
+ import { AgentTaskExecutor } from "../workflows/executors/agent-task";
21
+ import {
22
+ BaseExecutor,
23
+ type ExecutorDependencies,
24
+ type ExecutorResult,
25
+ } from "../workflows/executors/base";
26
+ import { ExecutorRegistry } from "../workflows/executors/registry";
27
+ import { recoverIncompleteRuns } from "../workflows/recovery";
28
+ import { setupWorkflowResumeListener } from "../workflows/resume";
29
+ import { startRetryPoller, stopRetryPoller } from "../workflows/retry-poller";
30
+ import { interpolate } from "../workflows/template";
31
+
32
+ const TEST_DB_PATH = "./test-workflow-agent-task-retry.sqlite";
33
+
34
+ class NotifyStubExecutor extends BaseExecutor<
35
+ typeof NotifyStubExecutor.schema,
36
+ typeof NotifyStubExecutor.outSchema
37
+ > {
38
+ static readonly schema = z.object({ channel: z.string(), template: z.string() });
39
+ static readonly outSchema = z.object({ sent: z.boolean() });
40
+
41
+ readonly type = "notify";
42
+ readonly mode = "instant" as const;
43
+ readonly configSchema = NotifyStubExecutor.schema;
44
+ readonly outputSchema = NotifyStubExecutor.outSchema;
45
+
46
+ protected async execute(): Promise<ExecutorResult<z.infer<typeof NotifyStubExecutor.outSchema>>> {
47
+ return { status: "success", output: { sent: true } };
48
+ }
49
+ }
50
+
51
+ const deps: ExecutorDependencies = {
52
+ db: db as typeof import("../be/db"),
53
+ eventBus: workflowEventBus,
54
+ interpolate: (template, ctx) => interpolate(template, ctx).result,
55
+ };
56
+
57
+ let registry: ExecutorRegistry;
58
+ let teardownResumeListener: (() => void) | undefined;
59
+
60
+ beforeAll(() => {
61
+ initDb(TEST_DB_PATH);
62
+ registry = new ExecutorRegistry();
63
+ registry.register(new NotifyStubExecutor(deps));
64
+ registry.register(new AgentTaskExecutor(deps));
65
+ teardownResumeListener = setupWorkflowResumeListener(workflowEventBus, registry);
66
+ });
67
+
68
+ afterEach(() => {
69
+ stopRetryPoller();
70
+ });
71
+
72
+ afterAll(async () => {
73
+ // Detach from the process-wide singleton bus (see setupWorkflowResumeListener).
74
+ teardownResumeListener?.();
75
+ stopRetryPoller();
76
+ closeDb();
77
+ await unlink(TEST_DB_PATH).catch(() => {});
78
+ await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
79
+ await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
80
+ });
81
+
82
+ let counter = 0;
83
+
84
+ /** synthesize (agent-task) → done (notify); `retry` lands on the node itself. */
85
+ async function startRun(opts: {
86
+ retry?: WorkflowDefinition["nodes"][number]["retry"];
87
+ onNodeFailure?: "fail" | "continue";
88
+ }): Promise<string> {
89
+ counter++;
90
+ const workflow = await createWorkflow({
91
+ name: `agent-task-retry-${counter}-${Date.now()}`,
92
+ definition: {
93
+ ...(opts.onNodeFailure ? { onNodeFailure: opts.onNodeFailure } : {}),
94
+ nodes: [
95
+ {
96
+ id: "synthesize",
97
+ type: "agent-task",
98
+ config: { template: "Synthesize" },
99
+ ...(opts.retry ? { retry: opts.retry } : {}),
100
+ next: "done",
101
+ },
102
+ { id: "done", type: "notify", config: { channel: "swarm", template: "done" } },
103
+ ],
104
+ },
105
+ });
106
+ const runId = await startWorkflowExecution(workflow, {}, registry);
107
+ expect((await getWorkflowRun(runId))!.status).toBe("waiting");
108
+ return runId;
109
+ }
110
+
111
+ async function synthesizeStep(runId: string): Promise<WorkflowRunStep> {
112
+ const steps = await getWorkflowRunStepsByRunId(runId);
113
+ return steps.find((s) => s.nodeId === "synthesize")!;
114
+ }
115
+
116
+ async function waitFor<T>(
117
+ probe: () => Promise<T | null | undefined | false>,
118
+ label: string,
119
+ timeoutMs = 3000,
120
+ ): Promise<T> {
121
+ const deadline = Date.now() + timeoutMs;
122
+ while (Date.now() < deadline) {
123
+ const value = await probe();
124
+ if (value) return value;
125
+ await new Promise((r) => setTimeout(r, 10));
126
+ }
127
+ throw new Error(`Timed out waiting for ${label}`);
128
+ }
129
+
130
+ /** Wait until a task other than `previous` is bound to the step. */
131
+ async function waitForRedrive(stepId: string, previous: AgentTask): Promise<AgentTask> {
132
+ return waitFor(async () => {
133
+ const bound = await getTaskByWorkflowRunStepId(stepId);
134
+ return bound && bound.id !== previous.id ? bound : null;
135
+ }, "redriven task");
136
+ }
137
+
138
+ const RETRY_ONCE = { maxRetries: 1, strategy: "static" as const, baseDelayMs: 0, maxDelayMs: 0 };
139
+
140
+ describe("agent-task node.retry on task failure", () => {
141
+ test("a superseded task with retry {maxRetries:1} is redriven once and the run continues", async () => {
142
+ const runId = await startRun({ retry: RETRY_ONCE });
143
+ const step = await synthesizeStep(runId);
144
+ const first = (await getTaskByWorkflowRunStepId(step.id))!;
145
+
146
+ await failTask(first.id, "superseded_workflow_task");
147
+
148
+ // Queued for the poller: step failed with nextRetryAt, run not failed,
149
+ // failed task detached so the executor creates a fresh one.
150
+ const queued = await waitFor(async () => {
151
+ const s = await synthesizeStep(runId);
152
+ return s.status === "failed" && s.nextRetryAt != null ? s : null;
153
+ }, "step queued for retry");
154
+ expect(queued.retryCount).toBe(1);
155
+ expect(queued.error).toBe("superseded_workflow_task");
156
+ expect((await getWorkflowRun(runId))!.status).toBe("waiting");
157
+ expect((await getTaskById(first.id))!.workflowRunStepId).toBeFalsy();
158
+
159
+ startRetryPoller(registry, 10);
160
+ const second = await waitForRedrive(step.id, first);
161
+ expect(second.status).not.toBe("failed");
162
+ const waiting = await waitFor(async () => {
163
+ const s = await synthesizeStep(runId);
164
+ return s.status === "waiting" ? s : null;
165
+ }, "step waiting on redriven task");
166
+ expect(waiting.id).toBe(step.id);
167
+ stopRetryPoller();
168
+
169
+ await completeTask(second.id, "synthesis done");
170
+ const run = await waitFor(async () => {
171
+ const r = await getWorkflowRun(runId);
172
+ return r?.status === "completed" ? r : null;
173
+ }, "run completed");
174
+ expect(run.status).toBe("completed");
175
+ const steps = await getWorkflowRunStepsByRunId(runId);
176
+ expect(steps.filter((s) => s.nodeId === "synthesize")).toHaveLength(1);
177
+ expect(steps.find((s) => s.nodeId === "done")?.status).toBe("completed");
178
+ });
179
+
180
+ test("a second failure exhausts retries and falls through to onNodeFailure", async () => {
181
+ const runId = await startRun({ retry: RETRY_ONCE, onNodeFailure: "continue" });
182
+ const step = await synthesizeStep(runId);
183
+ const first = (await getTaskByWorkflowRunStepId(step.id))!;
184
+
185
+ await failTask(first.id, "superseded_workflow_task");
186
+ startRetryPoller(registry, 10);
187
+ const second = await waitForRedrive(step.id, first);
188
+ await waitFor(async () => {
189
+ const s = await synthesizeStep(runId);
190
+ return s.status === "waiting" ? s : null;
191
+ }, "step waiting on redriven task");
192
+ stopRetryPoller();
193
+
194
+ // A late duplicate event for the FIRST task must not consume the retry
195
+ // budget or touch the step now bound to the second task.
196
+ workflowEventBus.emit("task.failed", {
197
+ taskId: first.id,
198
+ failureReason: "duplicate",
199
+ workflowRunId: runId,
200
+ workflowRunStepId: step.id,
201
+ });
202
+ await new Promise((r) => setTimeout(r, 20));
203
+ expect((await synthesizeStep(runId)).status).toBe("waiting");
204
+
205
+ await failTask(second.id, "superseded_workflow_task");
206
+ const run = await waitFor(async () => {
207
+ const r = await getWorkflowRun(runId);
208
+ return r?.status === "completed" ? r : null;
209
+ }, "run completed via onNodeFailure:continue");
210
+ expect(run.status).toBe("completed");
211
+ const finalStep = await synthesizeStep(runId);
212
+ expect(finalStep.status).toBe("completed");
213
+ expect(finalStep.retryCount).toBe(1);
214
+ expect(finalStep.nextRetryAt).toBeFalsy();
215
+ expect(JSON.stringify(finalStep.output)).toContain("[FAILED:");
216
+ // No third task was ever created.
217
+ expect((await getTaskByWorkflowRunStepId(step.id))!.id).toBe(second.id);
218
+ });
219
+
220
+ test("a node without retry fails the run exactly as before", async () => {
221
+ const runId = await startRun({});
222
+ const step = await synthesizeStep(runId);
223
+ const first = (await getTaskByWorkflowRunStepId(step.id))!;
224
+
225
+ await failTask(first.id, "superseded_workflow_task");
226
+
227
+ const run = await waitFor(async () => {
228
+ const r = await getWorkflowRun(runId);
229
+ return r?.status === "failed" ? r : null;
230
+ }, "run failed");
231
+ expect(run.error).toContain("superseded_workflow_task");
232
+ const failedStep = await synthesizeStep(runId);
233
+ expect(failedStep.status).toBe("failed");
234
+ expect(failedStep.retryCount).toBe(0);
235
+ expect(failedStep.nextRetryAt).toBeFalsy();
236
+ expect((await getTaskByWorkflowRunStepId(step.id))!.id).toBe(first.id);
237
+ });
238
+
239
+ test("a cancelled task never retries, even with retry set", async () => {
240
+ const runId = await startRun({ retry: RETRY_ONCE });
241
+ const step = await synthesizeStep(runId);
242
+ const first = (await getTaskByWorkflowRunStepId(step.id))!;
243
+
244
+ await cancelTask(first.id, "operator cancel");
245
+
246
+ const run = await waitFor(async () => {
247
+ const r = await getWorkflowRun(runId);
248
+ return r?.status === "failed" ? r : null;
249
+ }, "run failed");
250
+ expect(run.error).toContain("cancelled");
251
+ const failedStep = await synthesizeStep(runId);
252
+ expect(failedStep.retryCount).toBe(0);
253
+ expect(failedStep.nextRetryAt).toBeFalsy();
254
+ expect((await getTaskByWorkflowRunStepId(step.id))!.id).toBe(first.id);
255
+ });
256
+
257
+ test("the recovery sweep applies the same retry when it sees the failed task first", async () => {
258
+ const runId = await startRun({ retry: RETRY_ONCE });
259
+ const step = await synthesizeStep(runId);
260
+ const first = (await getTaskByWorkflowRunStepId(step.id))!;
261
+
262
+ // Detach the live listener so only the heartbeat sweep sees the failure.
263
+ teardownResumeListener?.();
264
+ try {
265
+ await failTask(first.id, "superseded_workflow_task");
266
+ await new Promise((r) => setTimeout(r, 20));
267
+ await recoverIncompleteRuns(registry);
268
+ } finally {
269
+ teardownResumeListener = setupWorkflowResumeListener(workflowEventBus, registry);
270
+ }
271
+
272
+ const queued = await synthesizeStep(runId);
273
+ expect(queued.status).toBe("failed");
274
+ expect(queued.nextRetryAt).toBeTruthy();
275
+ expect(queued.retryCount).toBe(1);
276
+ expect((await getWorkflowRun(runId))!.status).toBe("waiting");
277
+
278
+ startRetryPoller(registry, 10);
279
+ const second = await waitForRedrive(step.id, first);
280
+ expect(second.id).not.toBe(first.id);
281
+ });
282
+ });
283
+
284
+ describe("recovery sweep holding a stale failed-task snapshot", () => {
285
+ /**
286
+ * Recovery snapshots task A as failed, then pauses. Meanwhile the live
287
+ * task.failed handler queues A's retry and the poller binds task B to the
288
+ * same step, back in `waiting`. When recovery resumes on its stale A row it
289
+ * must leave the step bound to B alone.
290
+ */
291
+ async function raceStaleRecovery(maxRetries: number) {
292
+ const runId = await startRun({ retry: { ...RETRY_ONCE, maxRetries } });
293
+ const step = await synthesizeStep(runId);
294
+ const first = (await getTaskByWorkflowRunStepId(step.id))!;
295
+
296
+ let releaseSnapshot!: () => void;
297
+ const snapshotHeld = new Promise<void>((r) => {
298
+ releaseSnapshot = r;
299
+ });
300
+ let snapshotTaken!: () => void;
301
+ const snapshotReady = new Promise<void>((r) => {
302
+ snapshotTaken = r;
303
+ });
304
+ const realGetStuck = db.getStuckWorkflowRuns;
305
+ const spy = spyOn(db, "getStuckWorkflowRuns").mockImplementation(async () => {
306
+ const rows = await realGetStuck();
307
+ snapshotTaken();
308
+ await snapshotHeld;
309
+ return rows;
310
+ });
311
+
312
+ let recovery: Promise<unknown> | undefined;
313
+ try {
314
+ // Fail A with the listener detached so the sweep's snapshot sees it.
315
+ teardownResumeListener?.();
316
+ try {
317
+ await failTask(first.id, "superseded_workflow_task");
318
+ recovery = recoverIncompleteRuns(registry);
319
+ await snapshotReady;
320
+ } finally {
321
+ teardownResumeListener = setupWorkflowResumeListener(workflowEventBus, registry);
322
+ }
323
+
324
+ // The live event for A arrives late: retry queued, B dispatched.
325
+ workflowEventBus.emit("task.failed", {
326
+ taskId: first.id,
327
+ failureReason: "superseded_workflow_task",
328
+ workflowRunId: runId,
329
+ workflowRunStepId: step.id,
330
+ });
331
+ startRetryPoller(registry, 10);
332
+ const second = await waitForRedrive(step.id, first);
333
+ await waitFor(async () => {
334
+ const s = await synthesizeStep(runId);
335
+ return s.status === "waiting" ? s : null;
336
+ }, "step waiting on redriven task");
337
+ stopRetryPoller();
338
+
339
+ releaseSnapshot();
340
+ await recovery;
341
+ recovery = undefined;
342
+
343
+ const after = await synthesizeStep(runId);
344
+ expect(after.status).toBe("waiting");
345
+ expect(after.retryCount).toBe(1);
346
+ expect(after.nextRetryAt).toBeFalsy();
347
+ expect((await getWorkflowRun(runId))!.status).toBe("waiting");
348
+ expect((await getTaskByWorkflowRunStepId(step.id))!.id).toBe(second.id);
349
+ } finally {
350
+ releaseSnapshot();
351
+ await recovery;
352
+ spy.mockRestore();
353
+ }
354
+ }
355
+
356
+ test("with retries left, the stale snapshot does not consume another retry", async () => {
357
+ await raceStaleRecovery(3);
358
+ });
359
+
360
+ test("with retries exhausted, the stale snapshot does not route onNodeFailure", async () => {
361
+ await raceStaleRecovery(1);
362
+ });
363
+ });
@@ -44,7 +44,7 @@ describe("workflow definition telemetry", () => {
44
44
  await removeTestDb();
45
45
  });
46
46
 
47
- test("emits workflow.created with definition size and known source", async () => {
47
+ test("emits workflow.created with definition size and known interface (via)", async () => {
48
48
  const workflow = await createWorkflow(
49
49
  {
50
50
  name: "created telemetry",
@@ -65,7 +65,7 @@ describe("workflow definition telemetry", () => {
65
65
  props: {
66
66
  workflowId: workflow.id,
67
67
  nodeCount: 2,
68
- source: "api",
68
+ via: "api",
69
69
  },
70
70
  },
71
71
  ]);
@@ -91,13 +91,13 @@ describe("workflow definition telemetry", () => {
91
91
  event: "deleted",
92
92
  props: {
93
93
  workflowId: workflow.id,
94
- source: "mcp",
94
+ via: "mcp",
95
95
  },
96
96
  },
97
97
  ]);
98
98
  });
99
99
 
100
- test("omits source for direct internal workflow mutations", async () => {
100
+ test("omits via for direct internal workflow mutations", async () => {
101
101
  const workflow = await createWorkflow({
102
102
  name: "internal telemetry",
103
103
  definition: { nodes: [] },
@@ -1,6 +1,7 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { addMinutes } from "date-fns";
3
3
  import * as z from "zod";
4
+ import { isApiDraining } from "@/be/api-drain";
4
5
  import {
5
6
  getActiveTaskCount,
6
7
  getAgentById,
@@ -132,7 +133,9 @@ export const registerPollTaskTool = (server: McpServer) => {
132
133
  while (new Date() < maxTime) {
133
134
  // Fetch and update in a single transaction to avoid race conditions
134
135
  const outcome = await getDbClient().transaction(
135
- async (): Promise<AgentTask | "at-capacity" | "runtime-unavailable" | null> => {
136
+ async (): Promise<
137
+ AgentTask | "at-capacity" | "runtime-unavailable" | "draining" | null
138
+ > => {
136
139
  // The entry gate only proves liveness when the long poll began;
137
140
  // the runtime must be live at the exact moment work is acquired,
138
141
  // so revalidate in the same transaction that can start the task.
@@ -147,6 +150,9 @@ export const registerPollTaskTool = (server: McpServer) => {
147
150
  return "runtime-unavailable";
148
151
  }
149
152
 
153
+ // A draining API dispatches nothing; see src/be/api-drain.ts.
154
+ if (isApiDraining()) return "draining";
155
+
150
156
  const agentNow = (await getAgentById(agentId))!;
151
157
 
152
158
  if (agentNow.status !== "busy") {
@@ -188,6 +194,19 @@ export const registerPollTaskTool = (server: McpServer) => {
188
194
  });
189
195
  }
190
196
 
197
+ if (outcome === "draining") {
198
+ // Like a runtime refusal, this is not an empty poll: skip the exit counter.
199
+ return toolOk("No task available.", {
200
+ details: "The API is draining for a restart and dispatches no new work. Retry shortly.",
201
+ data: {
202
+ yourAgentId: requestInfo.agentId,
203
+ offeredTasks: [],
204
+ availableCount: 0,
205
+ waitedForSeconds: Math.round((Date.now() - now.getTime()) / 1000),
206
+ },
207
+ });
208
+ }
209
+
191
210
  if (outcome === "at-capacity") {
192
211
  // A capacity refusal is not an empty poll (refused ≠ empty, D-R3):
193
212
  // return without advancing the exit counter.
@@ -15,6 +15,7 @@ import {
15
15
  getUserById,
16
16
  hasCapacity,
17
17
  isExtensionAgent,
18
+ isLinearTrackerContextKey,
18
19
  } from "@/be/db";
19
20
  import { repointTrackerSyncBySwarmId } from "@/be/db-queries/tracker";
20
21
  import { explicitModelErrorForAgent } from "@/be/model-validation";
@@ -467,11 +468,12 @@ export async function sendTaskHandler(
467
468
  } | null> => {
468
469
  const existingTrackerWork = await findExistingLinearTrackerContextWork(
469
470
  effectiveParentTask?.contextKey,
471
+ effectiveParentTask?.id,
470
472
  );
471
473
  if (existingTrackerWork) {
472
474
  const msg = `Skipped: Linear tracker contextKey ${effectiveParentTask?.contextKey} already has ${existingTrackerWork.reason === "active_task" ? "active task" : "linked open PR"} ${existingTrackerWork.task.id.slice(0, 8)}.`;
473
475
  console.log(`[send-task] ${msg}`);
474
- return { ok: true, message: msg, task: existingTrackerWork.task };
476
+ return { ok: false, message: msg, task: existingTrackerWork.task };
475
477
  }
476
478
 
477
479
  // Dedup guard: check for similar recent tasks
@@ -559,6 +561,12 @@ export async function sendTaskHandler(
559
561
  const raced = await evaluateDedupGuards();
560
562
  if (raced) return { success: raced.ok, message: raced.message, task: raced.task };
561
563
 
564
+ // This transaction already checked the tracker key, excluding the caller's
565
+ // lineage. The creation guard would otherwise match work in that lineage.
566
+ if (isLinearTrackerContextKey(effectiveParentTask?.contextKey)) {
567
+ taskOptions.bypassTrackerContextDedup = true;
568
+ }
569
+
562
570
  // If no agentId (and no auto-routed agentId), create an unassigned task for the pool
563
571
  const targetAgentId = taskOptions.offeredTo ?? taskOptions.agentId ?? undefined;
564
572
  if (!targetAgentId) {
@@ -1,5 +1,6 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import * as z from "zod";
3
+ import { isApiDraining } from "@/be/api-drain";
3
4
  import { AssetKeyAuthorizationError, authorizeAssetKeyWrite } from "@/be/asset-key-auth";
4
5
  import { resolveTaskAuditUserId } from "@/be/audit-user";
5
6
  import { canClaim } from "@/be/budget-admission";
@@ -177,6 +178,14 @@ async function staleRuntimeResult(ctx: ToolCtx, agentId: string): Promise<TaskAc
177
178
  };
178
179
  }
179
180
 
181
+ /** A draining API dispatches nothing (src/be/api-drain.ts); claim and accept wait for the next API. */
182
+ function apiDrainingResult(action: "claim" | "accept"): TaskActionResult {
183
+ return {
184
+ success: false,
185
+ message: `The API is draining for a restart and dispatches no new work, so this ${action} was not made. Retry shortly.`,
186
+ };
187
+ }
188
+
180
189
  function taskActionResult(result: TaskActionResult, agentId?: string): SwarmToolResult {
181
190
  const { refusalSideEffects: _omit, success, message, ...rest } = result;
182
191
  const data = {
@@ -353,6 +362,7 @@ export async function taskActionHandler(
353
362
  }
354
363
  const staleClaimRuntime = await staleRuntimeResult(ctx, agentId);
355
364
  if (staleClaimRuntime) return staleClaimRuntime;
365
+ if (isApiDraining()) return apiDrainingResult("claim");
356
366
  // Check capacity before claiming
357
367
  if (!(await hasCapacity(agentId))) {
358
368
  const activeCount = await getActiveTaskCount(agentId);
@@ -462,6 +472,7 @@ export async function taskActionHandler(
462
472
  // budget admission below so a retired process cannot take the offer.
463
473
  const staleAcceptRuntime = await staleRuntimeResult(ctx, agentId);
464
474
  if (staleAcceptRuntime) return staleAcceptRuntime;
475
+ if (isApiDraining()) return apiDrainingResult("accept");
465
476
  const existingTask = await getTaskById(taskId);
466
477
  if (!existingTask) {
467
478
  return { success: false, message: `Task "${taskId}" not found.` };
package/src/types.ts CHANGED
@@ -364,6 +364,7 @@ export const AgentTaskSourceSchema = z.enum([
364
364
  "workflow",
365
365
  "linear",
366
366
  "jira",
367
+ "comb",
367
368
  ]);
368
369
  export type AgentTaskSource = z.infer<typeof AgentTaskSourceSchema>;
369
370
 
@@ -1045,7 +1046,7 @@ export type InboxItemState = z.infer<typeof InboxItemStateSchema>;
1045
1046
  // User Favorites (principal-scoped stars for app navigation)
1046
1047
  // ============================================================================
1047
1048
 
1048
- export const FavoriteItemTypeSchema = z.enum(["page", "workflow", "schedule"]);
1049
+ export const FavoriteItemTypeSchema = z.enum(["page", "workflow", "schedule", "agent-fs-path"]);
1049
1050
  export type FavoriteItemType = z.infer<typeof FavoriteItemTypeSchema>;
1050
1051
 
1051
1052
  export const UserFavoriteSchema = z
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Wire contract for the API drain, shared by the API server and the workers.
3
+ * Keep this file free of DB and server imports: `src/commands/` reads it.
4
+ *
5
+ * While the API is draining (SIGTERM received, still serving) it adds
6
+ * `X-Swarm-Draining: 1` to every response. A worker that sees the header hands
7
+ * off its in-flight tasks and stops taking new ones. An API that never drains
8
+ * never sends the header, so older and newer sides interoperate unchanged.
9
+ */
10
+
11
+ export const API_DRAINING_HEADER = "X-Swarm-Draining";
12
+
13
+ /**
14
+ * Upper bound for `API_DRAIN_MAX_MS`. The config validator and the resolver in
15
+ * `src/be/api-drain.ts` both read it, so the config API cannot accept a value
16
+ * the shutdown path would clamp. Keep the API's `stop_grace_period` above the
17
+ * cap plus the time the API needs to close.
18
+ */
19
+ export const API_DRAIN_MAX_MS_LIMIT = 120_000;
20
+
21
+ /** True when an API response carries the drain header. */
22
+ export function isApiDrainingResponse(response: { headers: Headers }): boolean {
23
+ return response.headers.get(API_DRAINING_HEADER) === "1";
24
+ }
@@ -2,6 +2,8 @@
2
2
  * Shared constants used across worker- and server-side code.
3
3
  */
4
4
 
5
+ import { isEnvFlagEnabled } from "./env-flag";
6
+
5
7
  /** Maximum profile-file length in JavaScript characters (not UTF-8 bytes). */
6
8
  export const MAX_PROFILE_FILE_LENGTH = 256 * 1024;
7
9
 
@@ -106,20 +108,24 @@ export function getAgentFsDefaultDriveId(): string | undefined {
106
108
  return raw || undefined;
107
109
  }
108
110
 
109
- /**
110
- * Resolve a public agent-fs live URL for an attachment when we have enough
111
- * info — `path` plus a matched `orgId`/`driveId` pair. When a row supplies
112
- * neither ID, the pair may come from env-var defaults. A partial row never
113
- * mixes its ID with a default; callers fall back to the raw
114
- * `agent-fs:<path>` display instead.
115
- *
116
- * Shape: ${liveHost}/file/~/<orgId>/<driveId>/<normalized-path>
117
- */
118
- export function buildAgentFsLiveUrl(opts: {
111
+ /** An agent-fs file for a link: its path plus an optional org/drive pair. */
112
+ export interface AgentFsFileRef {
119
113
  path?: string | null;
120
114
  orgId?: string | null;
121
115
  driveId?: string | null;
122
- }): string | null {
116
+ }
117
+
118
+ /** A "." or ".." path segment, raw or encoded (`%2e`). */
119
+ const DOT_SEGMENT_RE = /^(?:\.|%2e){1,2}$/i;
120
+
121
+ /**
122
+ * `<orgId>/<driveId>/<encoded-path>` for an agent-fs file link, when we have
123
+ * enough info: `path` plus a matched `orgId`/`driveId` pair. When a row
124
+ * supplies neither ID, the pair may come from env-var defaults. A partial row
125
+ * never mixes its ID with a default. A "." or ".." segment returns null: the
126
+ * browser would resolve it to a path outside the drive.
127
+ */
128
+ export function agentFsFileRoute(opts: AgentFsFileRef): string | null {
123
129
  const path = opts.path?.trim();
124
130
  if (!path) return null;
125
131
  const rowOrgId = opts.orgId?.trim();
@@ -128,13 +134,48 @@ export function buildAgentFsLiveUrl(opts: {
128
134
  const orgId = hasRowId ? rowOrgId : getAgentFsDefaultOrgId();
129
135
  const driveId = hasRowId ? rowDriveId : getAgentFsDefaultDriveId();
130
136
  if (!orgId || !driveId) return null;
131
- const host = getAgentFsLiveUrl();
132
- const normalizedPath = path.replace(/^\/+/, "");
137
+ const segments = path.replace(/^\/+/, "").split("/");
138
+ if (segments.some((segment) => DOT_SEGMENT_RE.test(segment))) return null;
133
139
  // Never double-encode an already-encoded segment: preserve existing %HH
134
140
  // escapes byte-for-byte, including in segments that also contain raw text.
135
- const encodedPath = normalizedPath
136
- .split("/")
141
+ const encodedPath = segments
137
142
  .map((segment) => encodeURIComponent(segment).replace(/%25([0-9a-f]{2})/gi, "%$1"))
138
143
  .join("/");
139
- return `${host}/file/~/${orgId}/${driveId}/${encodedPath}`;
144
+ return `${orgId}/${driveId}/${encodedPath}`;
145
+ }
146
+
147
+ /**
148
+ * Resolve a public agent-fs live URL for an attachment (see
149
+ * {@link agentFsFileRoute} for the id, dot-segment, and encoding rules). Null
150
+ * when that route is null; callers fall back to the raw `agent-fs:<path>`
151
+ * display instead.
152
+ *
153
+ * Shape: ${liveHost}/file/~/<orgId>/<driveId>/<normalized-path>
154
+ */
155
+ export function buildAgentFsLiveUrl(opts: AgentFsFileRef): string | null {
156
+ const route = agentFsFileRoute(opts);
157
+ return route ? `${getAgentFsLiveUrl()}/file/~/${route}` : null;
158
+ }
159
+
160
+ /**
161
+ * Comb (the agent-fs review space in the dashboard) is on: `COMB_ENABLED` plus
162
+ * an agent-fs API URL. `getCombConfig()` (`src/comb/config.ts`) reports it.
163
+ */
164
+ export function isCombEnabled(): boolean {
165
+ const apiUrl = process.env.AGENT_FS_API_URL?.trim().replace(/\/+$/, "");
166
+ return isEnvFlagEnabled("COMB_ENABLED", false) && Boolean(apiUrl);
167
+ }
168
+
169
+ /**
170
+ * The dashboard (Comb) URL for an agent-fs file, same id and encoding rules as
171
+ * {@link buildAgentFsLiveUrl}. Null unless Comb is on AND an explicit
172
+ * `APP_URL` / `DASHBOARD_URL` is set: the hosted default dashboard cannot open
173
+ * a self-hosted swarm's files.
174
+ *
175
+ * Shape: ${appUrl}/file/~/<orgId>/<driveId>/<normalized-path>
176
+ */
177
+ export function buildCombFileUrl(opts: AgentFsFileRef): string | null {
178
+ if (!isCombEnabled() || getConfiguredAppUrls().length === 0) return null;
179
+ const route = agentFsFileRoute(opts);
180
+ return route ? `${getAppUrl()}/file/~/${route}` : null;
140
181
  }