@desplega.ai/agent-swarm 1.145.0 → 1.147.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 (221) hide show
  1. package/README.md +2 -1
  2. package/dist/{acp-adapter-580t41c0.js → acp-adapter-9n319wqc.js} +61 -6
  3. package/dist/{actions-m662tjng.js → actions-2vxqvpr9.js} +9 -8
  4. package/dist/{app-zb3rndm8.js → app-w066xfy0.js} +6 -5
  5. package/dist/assistant-kt31dj2h.js +236 -0
  6. package/dist/{boot-reembed-072x00vn.js → boot-reembed-az5rassp.js} +7 -6
  7. package/dist/{boot-reembed-65zz2smt.js → boot-reembed-j3mm3rfz.js} +8 -7
  8. package/dist/{boot-scrub-logs-yz0ams04.js → boot-scrub-logs-b6h54817.js} +4 -4
  9. package/dist/{claude-adapter-paj270y2.js → claude-adapter-w1z04ann.js} +8 -6
  10. package/dist/{claude-managed-setup-w619jncn.js → claude-managed-setup-f5pmsyrp.js} +24 -3
  11. package/dist/{claude-sdk-session-swt80j9z.js → claude-sdk-session-f0cc1kec.js} +8 -6
  12. package/dist/{cli-dt553ktb.js → cli-0b6y7kxm.js} +1 -1
  13. package/dist/{cli-bxdhw615.js → cli-12fz972k.js} +4 -4
  14. package/dist/{cli-s2f20r5w.js → cli-1th1728g.js} +2 -2
  15. package/dist/{cli-rs6ec3b9.js → cli-3tgwnf43.js} +3 -3
  16. package/dist/{cli-gxnzc6wh.js → cli-4nr988h6.js} +1 -1
  17. package/dist/{cli-axs8e6d4.js → cli-53s590z8.js} +1 -1
  18. package/dist/{cli-ygbjp3z8.js → cli-54wwve05.js} +5 -4
  19. package/dist/{cli-v0asr5yv.js → cli-719k9j2c.js} +2 -2
  20. package/dist/{cli-anktweqr.js → cli-89xfd7f9.js} +21 -47
  21. package/dist/{cli-q34a0e10.js → cli-8v0g1yc8.js} +1 -1
  22. package/dist/cli-9d8ntetj.js +42 -0
  23. package/dist/{cli-z9fprcrf.js → cli-atcve9y8.js} +881 -738
  24. package/dist/{cli-jmb7g47h.js → cli-b5bq9crk.js} +14 -31
  25. package/dist/cli-d8brzxjb.js +190 -0
  26. package/dist/{cli-gzky0gkr.js → cli-de14znh8.js} +2 -2
  27. package/dist/{cli-xytwff7f.js → cli-dttph1bw.js} +2 -2
  28. package/dist/cli-ep19k3mb.js +19 -0
  29. package/dist/{cli-m1xa8be7.js → cli-f146mn5s.js} +7 -50
  30. package/dist/{cli-1fj0chdk.js → cli-f7dsy61y.js} +5 -3
  31. package/dist/{cli-f567d5c1.js → cli-ftht3zzj.js} +2782 -2589
  32. package/dist/{cli-a0g1gbt5.js → cli-hbv0kq6w.js} +2 -2
  33. package/dist/{cli-fhsz3twr.js → cli-jsp14b6c.js} +171 -1
  34. package/dist/{cli-7fnkj1hj.js → cli-kpv03zhj.js} +21 -17
  35. package/dist/{cli-m4j6trn4.js → cli-m8yrsg97.js} +2 -2
  36. package/dist/cli-m9sxbkhm.js +779 -0
  37. package/dist/cli-mmemxxdc.js +823 -0
  38. package/dist/{cli-tqtvj5pt.js → cli-n8508nre.js} +1 -1
  39. package/dist/{cli-z96t0thj.js → cli-p1d5b073.js} +37 -2
  40. package/dist/{cli-gh8y8f36.js → cli-rw51bq3j.js} +13 -10
  41. package/dist/{cli-59ej1c07.js → cli-smby5m17.js} +7 -6
  42. package/dist/{cli-65vqzz3v.js → cli-tzvk9haz.js} +94 -372
  43. package/dist/{cli-dc9k2ba9.js → cli-w9kb3bk1.js} +1 -1
  44. package/dist/{cli-kpev657q.js → cli-y6b6fb76.js} +25 -3
  45. package/dist/{cli-123q2get.js → cli-yz3djrm0.js} +9 -9
  46. package/dist/{cli-wvp6bvy4.js → cli-zhjmqxzh.js} +1 -1
  47. package/dist/cli.js +13 -13
  48. package/dist/{codex-adapter-k98vaw2r.js → codex-adapter-zw4dwsx7.js} +2 -2
  49. package/dist/{codex-hook-9pf19cks.js → codex-hook-bw8p5nh4.js} +2 -2
  50. package/dist/{codex-session-runner-tngtphx7.js → codex-session-runner-k981s65v.js} +2 -2
  51. package/dist/{commands-y8nbr3fv.js → commands-sykrxx7e.js} +4 -4
  52. package/dist/{db-30p4h2t1.js → db-sfbsc8wt.js} +6 -4
  53. package/dist/handlers-0aspcejm.js +603 -0
  54. package/dist/{hook-dcw4jfbj.js → hook-4nvqj2sb.js} +7 -5
  55. package/dist/{hook-21jexvfx.js → hook-hxccs7m5.js} +24 -49
  56. package/dist/{http-r4kq5g8d.js → http-y06z517d.js} +230 -94
  57. package/dist/{index-q3qja6ta.js → index-hssww7kj.js} +15 -14
  58. package/dist/{index-gay66b9w.js → index-k8znfx0z.js} +13 -12
  59. package/dist/{index-53zyht4z.js → index-tce1rss4.js} +371 -110
  60. package/dist/{index-nqvf26za.js → index-x5j894zf.js} +14 -13
  61. package/dist/{keepalive-xs8fa55y.js → keepalive-w733ax66.js} +6 -6
  62. package/dist/{lead-d9m2t4s0.js → lead-75bjqa9d.js} +29 -27
  63. package/dist/{maintenance-3zh82g6z.js → maintenance-w4f1zjck.js} +8 -7
  64. package/dist/{oauth-refresh-sweep-03snsye5.js → oauth-refresh-sweep-wdetky26.js} +5 -5
  65. package/dist/{onboard-0cg1925x.js → onboard-bb5net8s.js} +2 -2
  66. package/dist/{otel-impl-0ab95z27.js → otel-impl-0w7c14ap.js} +1 -1
  67. package/dist/{pi-mono-adapter-dtkz4e7j.js → pi-mono-adapter-2has4gnp.js} +12 -2
  68. package/dist/{pricing-refresh-e5jhzz4j.js → pricing-refresh-9nzsrgcq.js} +6 -6
  69. package/dist/{rbac-roles-p1mf40z3.js → rbac-roles-r3hqjp09.js} +5 -5
  70. package/dist/{rbac-roles-2w3cmrkc.js → rbac-roles-z1jrffbt.js} +4 -4
  71. package/dist/{registry-9kdck5ye.js → registry-v5wytg0s.js} +1 -2
  72. package/dist/{render-v2-xhawry3k.js → render-v2-vbr89fnb.js} +6 -5
  73. package/dist/{seed-pricing-t223sw91.js → seed-pricing-g62hy1pk.js} +7 -5
  74. package/dist/{setup-zcs0rce7.js → setup-gnfqnptp.js} +2 -2
  75. package/dist/{worker-qnp2ka62.js → worker-kznxp4qm.js} +29 -27
  76. package/openapi.json +263 -12
  77. package/package.json +1 -1
  78. package/plugin/commands/start-worker.md +4 -9
  79. package/plugin/pi-skills/start-worker/SKILL.md +4 -9
  80. package/src/be/db/agents.ts +556 -0
  81. package/src/be/db/context-versions.ts +117 -0
  82. package/src/be/db/runtime.ts +406 -0
  83. package/src/be/db/tasks/read.ts +1167 -0
  84. package/src/be/db/tasks/write.ts +953 -0
  85. package/src/be/db.ts +584 -3429
  86. package/src/be/migrations/147_scheduled_tasks_parent_task.sql +7 -0
  87. package/src/be/migrations/148_pages_svg.sql +123 -0
  88. package/src/be/migrations/149_session_tokens.sql +24 -0
  89. package/src/be/scripts/typecheck.ts +1 -0
  90. package/src/be/seed-pricing.ts +13 -8
  91. package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +1 -1
  92. package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +1 -1
  93. package/src/be/seed-scripts/catalog/star-history-refresh.ts +101 -0
  94. package/src/be/seed-scripts/catalog/star-history-renderer.ts +119 -0
  95. package/src/be/seed-scripts/index.ts +17 -0
  96. package/src/be/seed-skills/index.ts +14 -0
  97. package/src/be/swarm-config-guard.ts +5 -2
  98. package/src/be/task-attachment-store.ts +70 -0
  99. package/src/be/users.ts +77 -0
  100. package/src/commands/attachments-section.ts +37 -0
  101. package/src/commands/claude-managed-setup.ts +45 -3
  102. package/src/commands/claude-md-session.ts +408 -0
  103. package/src/commands/context-preamble.ts +15 -3
  104. package/src/commands/profile-sync.ts +205 -5
  105. package/src/commands/provider-credentials.ts +60 -27
  106. package/src/commands/runner.ts +63 -58
  107. package/src/commands/templates.ts +25 -2
  108. package/src/hooks/hook.ts +26 -67
  109. package/src/http/agents.ts +40 -1
  110. package/src/http/auth.ts +13 -1
  111. package/src/http/fs.ts +21 -51
  112. package/src/http/index.ts +2 -0
  113. package/src/http/mcp.ts +32 -0
  114. package/src/http/pages-public.ts +20 -4
  115. package/src/http/pages.ts +11 -3
  116. package/src/http/poll.ts +4 -0
  117. package/src/http/repos.ts +3 -3
  118. package/src/http/sessions.ts +82 -0
  119. package/src/http/tasks.ts +10 -2
  120. package/src/http/utils.ts +79 -1
  121. package/src/prompts/session-templates.ts +12 -8
  122. package/src/providers/acp-adapter.ts +62 -8
  123. package/src/providers/pi-mono-adapter.ts +23 -6
  124. package/src/providers/pricing-sources.md +11 -0
  125. package/src/scheduler/scheduler.test.ts +49 -2
  126. package/src/scheduler/scheduler.ts +4 -0
  127. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  128. package/src/scripts-runtime/types/stdlib.d.ts +8 -0
  129. package/src/scripts-runtime/types/swarm-sdk.d.ts +8 -0
  130. package/src/server.ts +2 -0
  131. package/src/slack/assistant.ts +87 -46
  132. package/src/slack/handlers.test.ts +1 -3
  133. package/src/slack/handlers.ts +102 -87
  134. package/src/slack/inbound-files.ts +466 -0
  135. package/src/slack/templates.ts +14 -0
  136. package/src/tasks/task-terminal-effects.ts +158 -0
  137. package/src/tests/acp-adapter.test.ts +369 -117
  138. package/src/tests/budget-claim-gate.test.ts +1 -0
  139. package/src/tests/build-pi-skills.test.ts +5 -7
  140. package/src/tests/claude-managed-setup.test.ts +52 -4
  141. package/src/tests/claude-md-session.test.ts +603 -0
  142. package/src/tests/codex-skill-resolver.test.ts +19 -0
  143. package/src/tests/context-versioning.test.ts +107 -0
  144. package/src/tests/cors-allowlist.test.ts +309 -0
  145. package/src/tests/credential-check.test.ts +157 -14
  146. package/src/tests/db-agents.test.ts +91 -0
  147. package/src/tests/db-context-versions.test.ts +37 -0
  148. package/src/tests/db-extraction-contract.test.ts +45 -0
  149. package/src/tests/db-runtime.test.ts +70 -0
  150. package/src/tests/db-task-read-contract.test.ts +624 -0
  151. package/src/tests/db-task-write-contract.test.ts +653 -0
  152. package/src/tests/db-tasks-read.test.ts +183 -0
  153. package/src/tests/db-tasks-write.test.ts +230 -0
  154. package/src/tests/defer-task.test.ts +389 -0
  155. package/src/tests/docs-index-footprint.test.js +2 -2
  156. package/src/tests/e2e-visuals-stage.test.ts +151 -0
  157. package/src/tests/file-lock.test.ts +144 -0
  158. package/src/tests/fixtures/file-lock-holder.ts +13 -0
  159. package/src/tests/fixtures/hold-file-lock.ts +25 -0
  160. package/src/tests/heartbeat-expiry.test.ts +188 -0
  161. package/src/tests/http-api-integration.test.ts +78 -1
  162. package/src/tests/pages-public-html.test.ts +43 -0
  163. package/src/tests/pages-svg-migration.test.ts +42 -0
  164. package/src/tests/preload.ts +4 -1
  165. package/src/tests/profile-sync-warning.test.ts +16 -0
  166. package/src/tests/profile-sync.test.ts +126 -0
  167. package/src/tests/prompt-template-session.test.ts +20 -4
  168. package/src/tests/provider-registration.test.ts +93 -0
  169. package/src/tests/rbac-e2e-helpers.test.ts +53 -0
  170. package/src/tests/rbac-e2e-helpers.ts +19 -2
  171. package/src/tests/reload-config.test.ts +26 -0
  172. package/src/tests/routing-affinity-inventory.test.ts +1 -1
  173. package/src/tests/runner-attachments-section.test.ts +22 -2
  174. package/src/tests/runner-context-preamble.test.ts +30 -0
  175. package/src/tests/runner-deployment-resume.test.ts +107 -0
  176. package/src/tests/runner-repo-autostash.test.ts +12 -0
  177. package/src/tests/script-connections.test.ts +4 -0
  178. package/src/tests/seed-scripts.test.ts +2 -2
  179. package/src/tests/session-tokens.test.ts +279 -0
  180. package/src/tests/slack-file-tools-attachments.test.ts +408 -0
  181. package/src/tests/slack-inbound-files.test.ts +847 -0
  182. package/src/tests/star-history-refresh.test.ts +102 -0
  183. package/src/tests/swarm-config-reserved-keys.test.ts +21 -0
  184. package/src/tests/swarm-repos.test.ts +20 -0
  185. package/src/tests/tool-annotations.test.ts +4 -3
  186. package/src/tests/ui-logs-parser.test.ts +176 -0
  187. package/src/tools/create-page.ts +4 -2
  188. package/src/tools/defer-task.ts +239 -0
  189. package/src/tools/repos/update-repo.ts +2 -2
  190. package/src/tools/schedules/create-schedule.ts +1 -0
  191. package/src/tools/schedules/list-schedules.ts +1 -0
  192. package/src/tools/schedules/patch-schedule.ts +1 -0
  193. package/src/tools/schedules/update-schedule.ts +1 -0
  194. package/src/tools/slack-download-file.ts +60 -29
  195. package/src/tools/slack-read.ts +73 -32
  196. package/src/tools/store-progress.ts +16 -146
  197. package/src/tools/tool-config.ts +1 -0
  198. package/src/tools/utils.ts +21 -0
  199. package/src/types.ts +37 -4
  200. package/src/utils/acp-session-token.ts +56 -0
  201. package/src/utils/file-lock.ts +111 -0
  202. package/src/utils/heartbeat-expiry.ts +78 -0
  203. package/src/utils/request-auth-context.ts +2 -0
  204. package/src/utils/task-attachment-links.ts +22 -0
  205. package/templates/skills/code-quality/content.md +8 -5
  206. package/templates/skills/download-task-attachment/content.md +2 -1
  207. package/{plugin/pi-skills → templates/skills}/review-offered-task/SKILL.md +5 -5
  208. package/templates/skills/review-offered-task/config.json +14 -0
  209. package/{plugin/commands/review-offered-task.md → templates/skills/review-offered-task/content.md} +3 -8
  210. package/templates/skills/scheduling/content.md +4 -0
  211. package/templates/skills/slack-interaction/content.md +2 -2
  212. package/templates/skills/work-on-task/SKILL.md +16 -0
  213. package/templates/skills/work-on-task/config.json +14 -0
  214. package/templates/skills/work-on-task/content.md +11 -0
  215. package/dist/assistant-0qt42nzg.js +0 -212
  216. package/dist/cli-fs1kkqgr.js +0 -317
  217. package/dist/cli-jnf5zghe.js +0 -269
  218. package/dist/cli-n1q8q14d.js +0 -177
  219. package/dist/handlers-yfp1qn81.js +0 -592
  220. package/plugin/commands/work-on-task.md +0 -39
  221. package/plugin/pi-skills/work-on-task/SKILL.md +0 -39
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Handler-level coverage for the `defer-task` MCP tool.
3
+ *
4
+ * `defer-task` completes the task the caller is working on and books a one-off
5
+ * schedule that wakes the same agent up later with a child task whose
6
+ * `parentTaskId` is the deferred task. The schedule row IS the state — there
7
+ * is no new task status — so every test below asserts on the schedule row and
8
+ * the task row together.
9
+ *
10
+ * The handler is pulled straight out of the SDK registry, same pattern as
11
+ * `store-progress-attachments-handler.test.ts`.
12
+ */
13
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
14
+ import crypto from "node:crypto";
15
+ import { unlink } from "node:fs/promises";
16
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
+ import {
18
+ closeDb,
19
+ completeTask,
20
+ createAgent,
21
+ createTaskExtended,
22
+ createWorkflow,
23
+ createWorkflowRun,
24
+ createWorkflowRunStep,
25
+ getScheduledTasks,
26
+ getTaskById,
27
+ initDb,
28
+ startTask,
29
+ } from "../be/db";
30
+ import { registerDeferTaskTool } from "../tools/defer-task";
31
+
32
+ const TEST_DB_PATH = "./test-defer-task.sqlite";
33
+
34
+ type RegisteredTool = {
35
+ handler: (args: unknown, extra: unknown) => Promise<unknown>;
36
+ // The handler is the raw callback: calling it directly bypasses the SDK's
37
+ // zod validation, so input-schema assertions go through this field.
38
+ inputSchema: { safeParse: (value: unknown) => { success: boolean } };
39
+ };
40
+
41
+ type DeferTaskResult = {
42
+ content: Array<{ type: string; text: string }>;
43
+ structuredContent: {
44
+ success: boolean;
45
+ message: string;
46
+ yourAgentId?: string;
47
+ taskId?: string;
48
+ scheduleId?: string;
49
+ nextRunAt?: string;
50
+ nudge?: string;
51
+ };
52
+ };
53
+
54
+ function buildTool(): RegisteredTool {
55
+ const server = new McpServer({ name: "defer-task-test", version: "1.0.0" });
56
+ registerDeferTaskTool(server);
57
+ const registered = (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
58
+ ._registeredTools;
59
+ const tool = registered["defer-task"];
60
+ if (!tool) throw new Error("defer-task tool not registered");
61
+ return tool;
62
+ }
63
+
64
+ describe("defer-task handler", () => {
65
+ let agentId: string;
66
+ let otherAgentId: string;
67
+
68
+ beforeAll(async () => {
69
+ for (const suffix of ["", "-wal", "-shm"]) {
70
+ try {
71
+ await unlink(`${TEST_DB_PATH}${suffix}`);
72
+ } catch {}
73
+ }
74
+ initDb(TEST_DB_PATH);
75
+ agentId = (
76
+ await createAgent({
77
+ name: "Defer Worker",
78
+ description: "Agent that defers tasks",
79
+ role: "worker",
80
+ isLead: false,
81
+ status: "busy",
82
+ maxTasks: 1,
83
+ capabilities: [],
84
+ })
85
+ ).id;
86
+ otherAgentId = (
87
+ await createAgent({
88
+ name: "Other Worker",
89
+ description: "Agent that owns a different task",
90
+ role: "worker",
91
+ isLead: false,
92
+ status: "busy",
93
+ maxTasks: 1,
94
+ capabilities: [],
95
+ })
96
+ ).id;
97
+ });
98
+
99
+ afterAll(async () => {
100
+ closeDb();
101
+ for (const suffix of ["", "-wal", "-shm"]) {
102
+ try {
103
+ await unlink(`${TEST_DB_PATH}${suffix}`);
104
+ } catch {}
105
+ }
106
+ });
107
+
108
+ function meta(callerId: string = agentId) {
109
+ return {
110
+ sessionId: `session-${crypto.randomUUID()}`,
111
+ requestInfo: { headers: { "x-agent-id": callerId } },
112
+ };
113
+ }
114
+
115
+ async function startedTask(
116
+ description: string,
117
+ owner: string = agentId,
118
+ extra: Partial<Parameters<typeof createTaskExtended>[1]> = {},
119
+ ) {
120
+ const task = await createTaskExtended(description, {
121
+ agentId: owner,
122
+ source: "mcp",
123
+ priority: 70,
124
+ modelTier: "smart",
125
+ ...extra,
126
+ });
127
+ await startTask(task.id);
128
+ return task;
129
+ }
130
+
131
+ async function schedulesForTask(taskId: string) {
132
+ return (await getScheduledTasks({ hideCompleted: false })).filter(
133
+ (s) => s.parentTaskId === taskId,
134
+ );
135
+ }
136
+
137
+ const SUMMARY = "pushed the config change and kicked off deploy 42";
138
+
139
+ test("delayMs path: completes the task and books a one-off wake-up schedule", async () => {
140
+ const task = await startedTask("wait for the deploy to finish");
141
+ const before = Date.now();
142
+
143
+ const result = (await buildTool().handler(
144
+ {
145
+ taskId: task.id,
146
+ delayMs: 1_800_000,
147
+ summary: SUMMARY,
148
+ note: "deploy 42 is still running",
149
+ checks: ["deploy 42 status is green", "smoke tests pass"],
150
+ },
151
+ meta(),
152
+ )) as DeferTaskResult;
153
+
154
+ expect(result.structuredContent.success).toBe(true);
155
+ expect(result.structuredContent.taskId).toBe(task.id);
156
+ // The NUDGES entry tells the caller to stop working on the task.
157
+ expect(result.structuredContent.nudge).toContain("Stop working on this task now");
158
+
159
+ const schedules = await schedulesForTask(task.id);
160
+ expect(schedules.length).toBe(1);
161
+ const schedule = schedules[0]!;
162
+ expect(schedule.id).toBe(result.structuredContent.scheduleId!);
163
+ expect(schedule.scheduleType).toBe("one_time");
164
+ expect(schedule.targetType).toBe("agent-task");
165
+ expect(schedule.targetAgentId).toBe(agentId);
166
+ expect(schedule.createdByAgentId).toBe(agentId);
167
+ expect(schedule.parentTaskId).toBe(task.id);
168
+ expect(schedule.taskType).toBe("deferred");
169
+ expect(schedule.tags).toContain("deferred");
170
+ expect(schedule.priority).toBe(70);
171
+ expect(schedule.modelTier).toBe("smart");
172
+ expect(schedule.taskTemplate).toContain(`Resume task ${task.id}`);
173
+ expect(schedule.taskTemplate).toContain("- deploy 42 status is green");
174
+ // The wake-up run reads the summary through the parent task's context.
175
+ expect(schedule.taskTemplate).not.toContain(SUMMARY);
176
+
177
+ // nextRunAt ≈ now + delay (generous window; the handler stamps its own now)
178
+ const nextRunMs = new Date(schedule.nextRunAt!).getTime();
179
+ expect(nextRunMs).toBeGreaterThanOrEqual(before + 1_800_000);
180
+ expect(nextRunMs).toBeLessThan(before + 1_800_000 + 60_000);
181
+ expect(result.structuredContent.nextRunAt).toBe(schedule.nextRunAt!);
182
+
183
+ const stored = await getTaskById(task.id);
184
+ expect(stored?.status).toBe("completed");
185
+ // Summary first, then the deferral status line, then the checks.
186
+ expect(stored?.output?.startsWith(`${SUMMARY}\n\n`)).toBe(true);
187
+ expect(stored?.output).toContain("Pending: deploy 42 is still running");
188
+ expect(stored?.output).toContain(schedule.nextRunAt!);
189
+ expect(stored?.output).toContain(schedule.id);
190
+ expect(stored?.output).toContain("- smoke tests pass");
191
+ });
192
+
193
+ test("runAt path is honoured verbatim", async () => {
194
+ const task = await startedTask("wait for the reply");
195
+ const runAt = new Date(Date.now() + 7_200_000).toISOString();
196
+
197
+ const result = (await buildTool().handler(
198
+ {
199
+ taskId: task.id,
200
+ runAt,
201
+ summary: "asked the customer",
202
+ note: "waiting on the customer reply",
203
+ },
204
+ meta(),
205
+ )) as DeferTaskResult;
206
+
207
+ expect(result.structuredContent.success).toBe(true);
208
+ expect(result.structuredContent.nextRunAt).toBe(runAt);
209
+
210
+ const schedules = await schedulesForTask(task.id);
211
+ expect(schedules.length).toBe(1);
212
+ expect(schedules[0]!.nextRunAt).toBe(runAt);
213
+ expect((await getTaskById(task.id))?.status).toBe("completed");
214
+ });
215
+
216
+ test("rejects both delayMs and runAt, and rejects neither", async () => {
217
+ const both = await startedTask("both timings");
218
+ const bothResult = (await buildTool().handler(
219
+ {
220
+ taskId: both.id,
221
+ delayMs: 60_000,
222
+ runAt: new Date(Date.now() + 60_000).toISOString(),
223
+ summary: "did some work",
224
+ note: "pending",
225
+ },
226
+ meta(),
227
+ )) as DeferTaskResult;
228
+ expect(bothResult.structuredContent.success).toBe(false);
229
+ expect(bothResult.structuredContent.message).toContain("not both");
230
+ expect((await schedulesForTask(both.id)).length).toBe(0);
231
+ expect((await getTaskById(both.id))?.status).toBe("in_progress");
232
+
233
+ const neither = await startedTask("no timing");
234
+ const neitherResult = (await buildTool().handler(
235
+ { taskId: neither.id, summary: "did some work", note: "pending" },
236
+ meta(),
237
+ )) as DeferTaskResult;
238
+ expect(neitherResult.structuredContent.success).toBe(false);
239
+ expect(neitherResult.structuredContent.message).toContain("delayMs or runAt");
240
+ expect((await schedulesForTask(neither.id)).length).toBe(0);
241
+ expect((await getTaskById(neither.id))?.status).toBe("in_progress");
242
+ });
243
+
244
+ test("an already-terminal task cannot be deferred and books no schedule", async () => {
245
+ const task = await startedTask("already done");
246
+ await completeTask(task.id, "finished");
247
+
248
+ const result = (await buildTool().handler(
249
+ { taskId: task.id, delayMs: 60_000, summary: "did some work", note: "pending" },
250
+ meta(),
251
+ )) as DeferTaskResult;
252
+
253
+ expect(result.structuredContent.success).toBe(false);
254
+ expect(result.structuredContent.message).toContain("already completed");
255
+ expect((await schedulesForTask(task.id)).length).toBe(0);
256
+ });
257
+
258
+ test("another agent's task is refused and books no schedule", async () => {
259
+ const task = await startedTask("not yours", otherAgentId);
260
+
261
+ const result = (await buildTool().handler(
262
+ { taskId: task.id, delayMs: 60_000, summary: "did some work", note: "pending" },
263
+ meta(),
264
+ )) as DeferTaskResult;
265
+
266
+ expect(result.structuredContent.success).toBe(false);
267
+ expect(result.structuredContent.message).toContain("not assigned to you");
268
+ expect((await schedulesForTask(task.id)).length).toBe(0);
269
+ expect((await getTaskById(task.id))?.status).toBe("in_progress");
270
+ });
271
+
272
+ test("summary is required by the input schema", () => {
273
+ const schema = buildTool().inputSchema;
274
+ const base = { taskId: crypto.randomUUID(), delayMs: 60_000, note: "pending" };
275
+ expect(schema.safeParse(base).success).toBe(false);
276
+ expect(schema.safeParse({ ...base, summary: "" }).success).toBe(false);
277
+ expect(schema.safeParse({ ...base, summary: SUMMARY }).success).toBe(true);
278
+ });
279
+
280
+ test("an unknown task is refused", async () => {
281
+ const result = (await buildTool().handler(
282
+ { taskId: crypto.randomUUID(), delayMs: 60_000, summary: "did some work", note: "pending" },
283
+ meta(),
284
+ )) as DeferTaskResult;
285
+
286
+ expect(result.structuredContent.success).toBe(false);
287
+ expect(result.structuredContent.message).toContain("not found");
288
+ });
289
+
290
+ test("a workflow-owned task cannot be deferred and books no schedule", async () => {
291
+ const workflow = await createWorkflow({
292
+ name: `defer-race-${crypto.randomUUID()}`,
293
+ definition: { nodes: [] },
294
+ });
295
+ const runId = crypto.randomUUID();
296
+ const stepId = crypto.randomUUID();
297
+ await createWorkflowRun({ id: runId, workflowId: workflow.id });
298
+ await createWorkflowRunStep({ id: stepId, runId, nodeId: "step", nodeType: "agent-task" });
299
+
300
+ const task = await startedTask("workflow step", agentId, {
301
+ workflowRunId: runId,
302
+ workflowRunStepId: stepId,
303
+ });
304
+
305
+ const result = (await buildTool().handler(
306
+ { taskId: task.id, delayMs: 60_000, summary: "did some work", note: "pending" },
307
+ meta(),
308
+ )) as DeferTaskResult;
309
+
310
+ expect(result.structuredContent.success).toBe(false);
311
+ expect(result.structuredContent.message).toContain("workflow");
312
+ expect((await schedulesForTask(task.id)).length).toBe(0);
313
+ expect((await getTaskById(task.id))?.status).toBe("in_progress");
314
+ });
315
+
316
+ test("a task with an outputSchema refuses a prose deferral and books no schedule", async () => {
317
+ const task = await startedTask("structured output task", agentId, {
318
+ outputSchema: {
319
+ type: "object",
320
+ required: ["result"],
321
+ properties: { result: { type: "string" } },
322
+ },
323
+ });
324
+
325
+ const result = (await buildTool().handler(
326
+ { taskId: task.id, delayMs: 60_000, summary: "did some work", note: "pending" },
327
+ meta(),
328
+ )) as DeferTaskResult;
329
+
330
+ expect(result.structuredContent.success).toBe(false);
331
+ expect(result.structuredContent.message).toContain("outputSchema");
332
+ expect((await schedulesForTask(task.id)).length).toBe(0);
333
+ const stored = await getTaskById(task.id);
334
+ expect(stored?.status).toBe("in_progress");
335
+ expect(stored?.output).toBeFalsy();
336
+ });
337
+
338
+ test("the wake-up schedule carries modelTier but never the parent's concrete model", async () => {
339
+ const task = await startedTask("pinned model task", agentId, {
340
+ model: "claude-sonnet-5",
341
+ });
342
+
343
+ const result = (await buildTool().handler(
344
+ { taskId: task.id, delayMs: 60_000, summary: "did some work", note: "pending" },
345
+ meta(),
346
+ )) as DeferTaskResult;
347
+
348
+ expect(result.structuredContent.success).toBe(true);
349
+ const schedules = await schedulesForTask(task.id);
350
+ expect(schedules.length).toBe(1);
351
+ expect(schedules[0]!.modelTier).toBe("smart");
352
+ expect(schedules[0]!.model).toBeFalsy();
353
+ });
354
+
355
+ test("a competing terminal transition between schedule creation and completion aborts the loser and books no orphan schedule", async () => {
356
+ const task = await startedTask("race target");
357
+
358
+ const [first, second] = (await Promise.all([
359
+ buildTool().handler(
360
+ { taskId: task.id, delayMs: 60_000, summary: "first writer", note: "pending A" },
361
+ meta(),
362
+ ),
363
+ buildTool().handler(
364
+ { taskId: task.id, delayMs: 90_000, summary: "second writer", note: "pending B" },
365
+ meta(),
366
+ ),
367
+ ])) as [DeferTaskResult, DeferTaskResult];
368
+
369
+ const results = [first, second];
370
+ const succeeded = results.filter((r) => r.structuredContent.success);
371
+ const failed = results.filter((r) => !r.structuredContent.success);
372
+
373
+ // Both pass the early terminal check (neither has completed yet when it
374
+ // reads); the FIFO transaction lock serializes their writes, so exactly
375
+ // one wins the completeTask race and the other's completeTask() returns
376
+ // null and must abort rather than substitute the stale pre-write task.
377
+ expect(succeeded.length).toBe(1);
378
+ expect(failed.length).toBe(1);
379
+ expect(failed[0]!.structuredContent.message).toContain("terminal state");
380
+
381
+ // The loser's schedule INSERT rolled back with its transaction — only
382
+ // the winner's wake-up was committed. No orphan schedule.
383
+ const schedules = await schedulesForTask(task.id);
384
+ expect(schedules.length).toBe(1);
385
+
386
+ const stored = await getTaskById(task.id);
387
+ expect(stored?.status).toBe("completed");
388
+ });
389
+ });
@@ -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(359);
107
- expect(generatedKeys).toHaveLength(359);
106
+ expect(operationKeys).toHaveLength(361);
107
+ expect(generatedKeys).toHaveLength(361);
108
108
  expect(new Set(generatedKeys)).toEqual(new Set(operationKeys));
109
109
  });
110
110
 
@@ -0,0 +1,151 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { runChild } from "./test-proc";
6
+
7
+ const SCRIPT = join(import.meta.dir, "../../scripts/e2e/visuals-stage.sh");
8
+
9
+ let outRoot: string;
10
+ let stagedRoot: string;
11
+
12
+ beforeEach(() => {
13
+ outRoot = mkdtempSync(join(tmpdir(), "visuals-stage-out-"));
14
+ stagedRoot = join(mkdtempSync(join(tmpdir(), "visuals-stage-target-")), "staged");
15
+ });
16
+
17
+ afterEach(() => {
18
+ rmSync(outRoot, { recursive: true, force: true });
19
+ rmSync(stagedRoot, { recursive: true, force: true });
20
+ });
21
+
22
+ function writeProfile(profile: string) {
23
+ const dir = join(outRoot, profile);
24
+ mkdirSync(dir, { recursive: true });
25
+ writeFileSync(join(dir, "index.json"), JSON.stringify({ profile }));
26
+ writeFileSync(join(dir, "channel.png"), "png-bytes");
27
+ mkdirSync(join(dir, "frames"), { recursive: true });
28
+ return dir;
29
+ }
30
+
31
+ function writeFrame(profileDir: string, relPath: string, contents = "frame-bytes") {
32
+ const full = join(profileDir, "frames", relPath);
33
+ mkdirSync(join(full, ".."), { recursive: true });
34
+ writeFileSync(full, contents);
35
+ }
36
+
37
+ async function stage() {
38
+ return runChild(["bash", SCRIPT, outRoot, stagedRoot]);
39
+ }
40
+
41
+ describe("visuals-stage.sh", () => {
42
+ test("stages nested scenario/thread frames at their original relative path", async () => {
43
+ const dir = writeProfile("v2");
44
+ writeFrame(dir, "slack-mention/mention/final-thread.png");
45
+ writeFrame(dir, "slack-mention/mention/final-desktop.gif");
46
+ writeFrame(dir, "slack-delegation/delegation-child-result/final-desktop.png");
47
+
48
+ const result = await stage();
49
+ expect(result.exitCode).toBe(0);
50
+
51
+ const staged = Bun.file(
52
+ join(stagedRoot, "v2", "frames", "slack-mention", "mention", "final-thread.png"),
53
+ );
54
+ expect(await staged.exists()).toBe(true);
55
+ expect(await staged.text()).toBe("frame-bytes");
56
+
57
+ const gif = Bun.file(
58
+ join(stagedRoot, "v2", "frames", "slack-mention", "mention", "final-desktop.gif"),
59
+ );
60
+ expect(await gif.exists()).toBe(true);
61
+
62
+ const other = Bun.file(
63
+ join(
64
+ stagedRoot,
65
+ "v2",
66
+ "frames",
67
+ "slack-delegation",
68
+ "delegation-child-result",
69
+ "final-desktop.png",
70
+ ),
71
+ );
72
+ expect(await other.exists()).toBe(true);
73
+ });
74
+
75
+ test("rejects a symlinked intermediate frame directory", async () => {
76
+ const dir = writeProfile("v2");
77
+ const realDir = join(dir, "frames", "real-scenario");
78
+ mkdirSync(realDir, { recursive: true });
79
+ writeFileSync(join(realDir, "thread"), "");
80
+ rmSync(join(realDir, "thread"));
81
+ mkdirSync(join(realDir, "thread"), { recursive: true });
82
+ writeFileSync(join(realDir, "thread", "final.png"), "frame-bytes");
83
+
84
+ // Symlink a second scenario dir's "thread" segment at the intermediate level.
85
+ const evilScenario = join(dir, "frames", "evil-scenario");
86
+ mkdirSync(evilScenario, { recursive: true });
87
+ symlinkSync(join(realDir, "thread"), join(evilScenario, "thread"));
88
+
89
+ const result = await stage();
90
+ // The good scenario still stages one frame, so overall exit is 0 — assert the symlinked
91
+ // one specifically never lands, either flagged or simply absent from the traversal.
92
+ expect(result.exitCode).toBe(0);
93
+ const evilStaged = Bun.file(
94
+ join(stagedRoot, "v2", "frames", "evil-scenario", "thread", "final.png"),
95
+ );
96
+ expect(await evilStaged.exists()).toBe(false);
97
+ });
98
+
99
+ // A literal ".." path component cannot exist as a real filesystem entry — the OS always
100
+ // resolves it to the parent directory, and `find` (without `-L`) never emits "." or ".."
101
+ // as traversal results. So the script's `case "/$rel_path/" in */../*)` guard is defense in
102
+ // depth for a rel_path construction bug, not something a crafted artifact can trigger via a
103
+ // real directory name. This test instead proves the adjacent, actually reachable guard: a
104
+ // directory segment outside the safe charset is rejected while safe siblings still stage.
105
+ test("rejects an unsafe directory segment but keeps safe siblings", async () => {
106
+ const dir = writeProfile("v2");
107
+ writeFrame(dir, "scenario/thread/ok.png");
108
+ writeFrame(dir, "scenario/th read/bad.png");
109
+
110
+ const result = await stage();
111
+ expect(result.exitCode).toBe(0);
112
+ const bad = Bun.file(join(stagedRoot, "v2", "frames", "scenario", "th read", "bad.png"));
113
+ expect(await bad.exists()).toBe(false);
114
+ const ok = Bun.file(join(stagedRoot, "v2", "frames", "scenario", "thread", "ok.png"));
115
+ expect(await ok.exists()).toBe(true);
116
+ });
117
+
118
+ test("rejects an unsafe leaf frame name but keeps safe siblings", async () => {
119
+ const dir = writeProfile("v2");
120
+ writeFrame(dir, "scenario/thread/ok.png");
121
+ writeFrame(dir, "scenario/thread/bad name!.png");
122
+
123
+ const result = await stage();
124
+ expect(result.exitCode).toBe(0);
125
+ const bad = Bun.file(join(stagedRoot, "v2", "frames", "scenario", "thread", "bad name!.png"));
126
+ expect(await bad.exists()).toBe(false);
127
+ const ok = Bun.file(join(stagedRoot, "v2", "frames", "scenario", "thread", "ok.png"));
128
+ expect(await ok.exists()).toBe(true);
129
+ });
130
+
131
+ test("fails the whole run when a profile stages zero frames", async () => {
132
+ const dir = writeProfile("v2");
133
+ // frames/ exists but every entry is invalid, so nothing gets staged.
134
+ writeFrame(dir, "scenario/thread/bad name!.png");
135
+
136
+ const result = await stage();
137
+ expect(result.exitCode).not.toBe(0);
138
+ expect(result.stderr).toContain("Staged zero frames");
139
+ });
140
+
141
+ test("fails when frames/ is missing entirely", async () => {
142
+ const dir = join(outRoot, "v2");
143
+ mkdirSync(dir, { recursive: true });
144
+ writeFileSync(join(dir, "index.json"), "{}");
145
+ writeFileSync(join(dir, "channel.png"), "png-bytes");
146
+
147
+ const result = await stage();
148
+ expect(result.exitCode).not.toBe(0);
149
+ expect(result.stderr).toContain("Missing frames directory");
150
+ });
151
+ });
@@ -0,0 +1,144 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm, utimes } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { setFlockForTests, withFileLock } from "../utils/file-lock";
6
+ import { CHILD_PROCESS_TEST_BUDGET_MS, CHILD_PROCESS_TIMEOUT_MS } from "./test-proc";
7
+
8
+ const HOLDER = join(import.meta.dir, "fixtures/file-lock-holder.ts");
9
+
10
+ /**
11
+ * Start a separate process holding the lock; resolves once it has it. The child
12
+ * is killed at CHILD_PROCESS_TIMEOUT_MS no matter what, and waiting for its
13
+ * "locked" line gives up at the same deadline.
14
+ */
15
+ async function holdInAnotherProcess(lockPath: string, holdMs: number) {
16
+ const child = Bun.spawn([process.execPath, HOLDER, lockPath, String(holdMs)], {
17
+ stdout: "pipe",
18
+ stderr: "inherit",
19
+ timeout: CHILD_PROCESS_TIMEOUT_MS,
20
+ killSignal: "SIGKILL",
21
+ });
22
+ const reader = child.stdout.getReader();
23
+ const deadline = Date.now() + CHILD_PROCESS_TIMEOUT_MS;
24
+ let out = "";
25
+ while (!out.includes("locked")) {
26
+ const next = await Promise.race([
27
+ reader.read(),
28
+ Bun.sleep(Math.max(0, deadline - Date.now())).then(() => "timeout" as const),
29
+ ]);
30
+ if (next === "timeout" || next.done) {
31
+ child.kill("SIGKILL");
32
+ throw new Error(`holder did not lock: ${out}`);
33
+ }
34
+ out += new TextDecoder().decode(next.value);
35
+ }
36
+ reader.releaseLock();
37
+ return child;
38
+ }
39
+
40
+ describe("withFileLock (kernel flock)", () => {
41
+ let root: string;
42
+ let lock: string;
43
+
44
+ beforeEach(async () => {
45
+ root = await mkdtemp(join(tmpdir(), "file-lock-"));
46
+ lock = join(root, "nested/dir/test.lock"); // parent created on demand
47
+ });
48
+
49
+ afterEach(async () => {
50
+ setFlockForTests(undefined);
51
+ await rm(root, { recursive: true, force: true });
52
+ });
53
+
54
+ test("serializes concurrent holders", async () => {
55
+ const events: string[] = [];
56
+ const hold = (name: string) =>
57
+ withFileLock(lock, async () => {
58
+ events.push(`${name}:in`);
59
+ await Bun.sleep(50);
60
+ events.push(`${name}:out`);
61
+ });
62
+
63
+ await Promise.all([hold("a"), hold("b")]);
64
+
65
+ expect(events).toHaveLength(4);
66
+ expect(events[1]).toBe(events[0]?.replace(":in", ":out")); // no overlap
67
+ });
68
+
69
+ test(
70
+ "a live holder is never robbed, however old its lock file looks",
71
+ async () => {
72
+ // The previous lock broke any lock file older than 30 s. Backdate the file
73
+ // while a live process holds it: nothing may take it over.
74
+ const holder = await holdInAnotherProcess(lock, 2_000);
75
+ try {
76
+ const longAgo = new Date(0);
77
+ await utimes(lock, longAgo, longAgo);
78
+
79
+ const whileHeld = await withFileLock(lock, async () => "ran", { waitMs: 300 });
80
+ expect(whileHeld).toEqual({ acquired: false, reason: "busy" });
81
+
82
+ await holder.exited;
83
+ expect(await withFileLock(lock, async () => "ran", { waitMs: 1_000 })).toEqual({
84
+ acquired: true,
85
+ value: "ran",
86
+ });
87
+ } finally {
88
+ holder.kill("SIGKILL");
89
+ }
90
+ },
91
+ CHILD_PROCESS_TEST_BUDGET_MS,
92
+ );
93
+
94
+ test(
95
+ "a holder killed with SIGKILL releases the lock",
96
+ async () => {
97
+ const holder = await holdInAnotherProcess(lock, 60_000);
98
+ try {
99
+ holder.kill("SIGKILL");
100
+ await holder.exited;
101
+
102
+ expect(await withFileLock(lock, async () => "ran", { waitMs: 1_000 })).toEqual({
103
+ acquired: true,
104
+ value: "ran",
105
+ });
106
+ } finally {
107
+ holder.kill("SIGKILL");
108
+ }
109
+ },
110
+ CHILD_PROCESS_TEST_BUDGET_MS,
111
+ );
112
+
113
+ test("releases the lock when fn throws, and never deletes the lock file", async () => {
114
+ await expect(
115
+ withFileLock(lock, async () => {
116
+ throw new Error("boom");
117
+ }),
118
+ ).rejects.toThrow("boom");
119
+
120
+ expect(await Bun.file(lock).exists()).toBe(true); // unlinking would allow a second inode
121
+ expect(await withFileLock(lock, async () => "again", { waitMs: 100 })).toEqual({
122
+ acquired: true,
123
+ value: "again",
124
+ });
125
+ });
126
+
127
+ test("reports an unusable lock path as an error, not as busy", async () => {
128
+ await Bun.write(join(root, "file"), "not a directory");
129
+ const result = await withFileLock(join(root, "file/test.lock"), async () => "ran");
130
+
131
+ expect(result).toMatchObject({ acquired: false, reason: "error" });
132
+ });
133
+
134
+ test("without flock on the platform it never runs fn", async () => {
135
+ setFlockForTests(null);
136
+ let ran = false;
137
+ const result = await withFileLock(lock, async () => {
138
+ ran = true;
139
+ });
140
+
141
+ expect(result).toEqual({ acquired: false, reason: "unsupported" });
142
+ expect(ran).toBe(false);
143
+ });
144
+ });