@katyella/legio 0.1.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 (219) hide show
  1. package/CHANGELOG.md +422 -0
  2. package/LICENSE +21 -0
  3. package/README.md +555 -0
  4. package/agents/builder.md +141 -0
  5. package/agents/coordinator.md +351 -0
  6. package/agents/cto.md +196 -0
  7. package/agents/gateway.md +276 -0
  8. package/agents/lead.md +281 -0
  9. package/agents/merger.md +156 -0
  10. package/agents/monitor.md +212 -0
  11. package/agents/reviewer.md +142 -0
  12. package/agents/scout.md +131 -0
  13. package/agents/supervisor.md +416 -0
  14. package/bin/legio.mjs +38 -0
  15. package/package.json +77 -0
  16. package/src/agents/checkpoint.test.ts +88 -0
  17. package/src/agents/checkpoint.ts +102 -0
  18. package/src/agents/hooks-deployer.test.ts +1820 -0
  19. package/src/agents/hooks-deployer.ts +574 -0
  20. package/src/agents/identity.test.ts +614 -0
  21. package/src/agents/identity.ts +385 -0
  22. package/src/agents/lifecycle.test.ts +202 -0
  23. package/src/agents/lifecycle.ts +184 -0
  24. package/src/agents/manifest.test.ts +558 -0
  25. package/src/agents/manifest.ts +297 -0
  26. package/src/agents/overlay.test.ts +592 -0
  27. package/src/agents/overlay.ts +316 -0
  28. package/src/beads/client.test.ts +210 -0
  29. package/src/beads/client.ts +227 -0
  30. package/src/beads/molecules.test.ts +320 -0
  31. package/src/beads/molecules.ts +209 -0
  32. package/src/commands/agents.test.ts +325 -0
  33. package/src/commands/agents.ts +286 -0
  34. package/src/commands/clean.test.ts +730 -0
  35. package/src/commands/clean.ts +653 -0
  36. package/src/commands/completions.test.ts +346 -0
  37. package/src/commands/completions.ts +950 -0
  38. package/src/commands/coordinator.test.ts +1524 -0
  39. package/src/commands/coordinator.ts +880 -0
  40. package/src/commands/costs.test.ts +1015 -0
  41. package/src/commands/costs.ts +473 -0
  42. package/src/commands/dashboard.test.ts +94 -0
  43. package/src/commands/dashboard.ts +607 -0
  44. package/src/commands/doctor.test.ts +295 -0
  45. package/src/commands/doctor.ts +213 -0
  46. package/src/commands/down.test.ts +308 -0
  47. package/src/commands/down.ts +124 -0
  48. package/src/commands/errors.test.ts +648 -0
  49. package/src/commands/errors.ts +255 -0
  50. package/src/commands/feed.test.ts +579 -0
  51. package/src/commands/feed.ts +368 -0
  52. package/src/commands/gateway.test.ts +698 -0
  53. package/src/commands/gateway.ts +419 -0
  54. package/src/commands/group.test.ts +262 -0
  55. package/src/commands/group.ts +539 -0
  56. package/src/commands/hooks.test.ts +292 -0
  57. package/src/commands/hooks.ts +210 -0
  58. package/src/commands/init.test.ts +211 -0
  59. package/src/commands/init.ts +622 -0
  60. package/src/commands/inspect.test.ts +670 -0
  61. package/src/commands/inspect.ts +455 -0
  62. package/src/commands/log.test.ts +1556 -0
  63. package/src/commands/log.ts +752 -0
  64. package/src/commands/logs.test.ts +379 -0
  65. package/src/commands/logs.ts +544 -0
  66. package/src/commands/mail.test.ts +1726 -0
  67. package/src/commands/mail.ts +926 -0
  68. package/src/commands/merge.test.ts +676 -0
  69. package/src/commands/merge.ts +374 -0
  70. package/src/commands/metrics.test.ts +444 -0
  71. package/src/commands/metrics.ts +150 -0
  72. package/src/commands/monitor.test.ts +151 -0
  73. package/src/commands/monitor.ts +394 -0
  74. package/src/commands/nudge.test.ts +230 -0
  75. package/src/commands/nudge.ts +373 -0
  76. package/src/commands/prime.test.ts +467 -0
  77. package/src/commands/prime.ts +386 -0
  78. package/src/commands/replay.test.ts +742 -0
  79. package/src/commands/replay.ts +367 -0
  80. package/src/commands/run.test.ts +443 -0
  81. package/src/commands/run.ts +365 -0
  82. package/src/commands/server.test.ts +626 -0
  83. package/src/commands/server.ts +298 -0
  84. package/src/commands/sling.test.ts +810 -0
  85. package/src/commands/sling.ts +700 -0
  86. package/src/commands/spec.test.ts +206 -0
  87. package/src/commands/spec.ts +171 -0
  88. package/src/commands/status.test.ts +276 -0
  89. package/src/commands/status.ts +339 -0
  90. package/src/commands/stop.test.ts +357 -0
  91. package/src/commands/stop.ts +119 -0
  92. package/src/commands/supervisor.test.ts +186 -0
  93. package/src/commands/supervisor.ts +544 -0
  94. package/src/commands/trace.test.ts +746 -0
  95. package/src/commands/trace.ts +332 -0
  96. package/src/commands/up.test.ts +597 -0
  97. package/src/commands/up.ts +275 -0
  98. package/src/commands/watch.test.ts +152 -0
  99. package/src/commands/watch.ts +238 -0
  100. package/src/commands/worktree.test.ts +648 -0
  101. package/src/commands/worktree.ts +266 -0
  102. package/src/config.test.ts +496 -0
  103. package/src/config.ts +616 -0
  104. package/src/doctor/agents.test.ts +448 -0
  105. package/src/doctor/agents.ts +396 -0
  106. package/src/doctor/config-check.test.ts +184 -0
  107. package/src/doctor/config-check.ts +185 -0
  108. package/src/doctor/consistency.test.ts +645 -0
  109. package/src/doctor/consistency.ts +294 -0
  110. package/src/doctor/databases.test.ts +284 -0
  111. package/src/doctor/databases.ts +211 -0
  112. package/src/doctor/dependencies.test.ts +150 -0
  113. package/src/doctor/dependencies.ts +179 -0
  114. package/src/doctor/logs.test.ts +244 -0
  115. package/src/doctor/logs.ts +295 -0
  116. package/src/doctor/merge-queue.test.ts +210 -0
  117. package/src/doctor/merge-queue.ts +144 -0
  118. package/src/doctor/structure.test.ts +285 -0
  119. package/src/doctor/structure.ts +195 -0
  120. package/src/doctor/types.ts +37 -0
  121. package/src/doctor/version.test.ts +130 -0
  122. package/src/doctor/version.ts +131 -0
  123. package/src/e2e/chat-flow.test.ts +346 -0
  124. package/src/e2e/init-sling-lifecycle.test.ts +288 -0
  125. package/src/errors.test.ts +21 -0
  126. package/src/errors.ts +246 -0
  127. package/src/events/store.test.ts +660 -0
  128. package/src/events/store.ts +344 -0
  129. package/src/events/tool-filter.test.ts +330 -0
  130. package/src/events/tool-filter.ts +126 -0
  131. package/src/global-setup.ts +14 -0
  132. package/src/index.ts +339 -0
  133. package/src/insights/analyzer.test.ts +466 -0
  134. package/src/insights/analyzer.ts +203 -0
  135. package/src/logging/color.test.ts +118 -0
  136. package/src/logging/color.ts +71 -0
  137. package/src/logging/logger.test.ts +812 -0
  138. package/src/logging/logger.ts +266 -0
  139. package/src/logging/reporter.test.ts +258 -0
  140. package/src/logging/reporter.ts +109 -0
  141. package/src/logging/sanitizer.test.ts +190 -0
  142. package/src/logging/sanitizer.ts +57 -0
  143. package/src/mail/broadcast.test.ts +203 -0
  144. package/src/mail/broadcast.ts +92 -0
  145. package/src/mail/client.test.ts +873 -0
  146. package/src/mail/client.ts +236 -0
  147. package/src/mail/store.test.ts +815 -0
  148. package/src/mail/store.ts +402 -0
  149. package/src/merge/queue.test.ts +449 -0
  150. package/src/merge/queue.ts +262 -0
  151. package/src/merge/resolver.test.ts +1453 -0
  152. package/src/merge/resolver.ts +759 -0
  153. package/src/metrics/store.test.ts +1167 -0
  154. package/src/metrics/store.ts +511 -0
  155. package/src/metrics/summary.test.ts +397 -0
  156. package/src/metrics/summary.ts +178 -0
  157. package/src/metrics/transcript.test.ts +643 -0
  158. package/src/metrics/transcript.ts +351 -0
  159. package/src/mulch/client.test.ts +547 -0
  160. package/src/mulch/client.ts +416 -0
  161. package/src/server/audit-store.test.ts +384 -0
  162. package/src/server/audit-store.ts +257 -0
  163. package/src/server/headless.test.ts +180 -0
  164. package/src/server/headless.ts +151 -0
  165. package/src/server/index.test.ts +241 -0
  166. package/src/server/index.ts +317 -0
  167. package/src/server/public/app.js +187 -0
  168. package/src/server/public/apple-touch-icon.png +0 -0
  169. package/src/server/public/components/agent-badge.js +37 -0
  170. package/src/server/public/components/data-table.js +114 -0
  171. package/src/server/public/components/gateway-chat.js +256 -0
  172. package/src/server/public/components/issue-card.js +96 -0
  173. package/src/server/public/components/layout.js +88 -0
  174. package/src/server/public/components/message-bubble.js +120 -0
  175. package/src/server/public/components/stat-card.js +26 -0
  176. package/src/server/public/components/terminal-panel.js +140 -0
  177. package/src/server/public/favicon-16.png +0 -0
  178. package/src/server/public/favicon-32.png +0 -0
  179. package/src/server/public/favicon.ico +0 -0
  180. package/src/server/public/favicon.png +0 -0
  181. package/src/server/public/index.html +64 -0
  182. package/src/server/public/lib/api.js +35 -0
  183. package/src/server/public/lib/markdown.js +8 -0
  184. package/src/server/public/lib/preact-setup.js +8 -0
  185. package/src/server/public/lib/state.js +99 -0
  186. package/src/server/public/lib/utils.js +309 -0
  187. package/src/server/public/lib/ws.js +79 -0
  188. package/src/server/public/views/chat.js +983 -0
  189. package/src/server/public/views/costs.js +692 -0
  190. package/src/server/public/views/dashboard.js +781 -0
  191. package/src/server/public/views/gateway-chat.js +622 -0
  192. package/src/server/public/views/inspect.js +399 -0
  193. package/src/server/public/views/issues.js +470 -0
  194. package/src/server/public/views/setup.js +94 -0
  195. package/src/server/public/views/task-detail.js +422 -0
  196. package/src/server/routes.test.ts +3816 -0
  197. package/src/server/routes.ts +1964 -0
  198. package/src/server/websocket.test.ts +288 -0
  199. package/src/server/websocket.ts +196 -0
  200. package/src/sessions/compat.test.ts +109 -0
  201. package/src/sessions/compat.ts +17 -0
  202. package/src/sessions/store.test.ts +969 -0
  203. package/src/sessions/store.ts +480 -0
  204. package/src/test-helpers.test.ts +97 -0
  205. package/src/test-helpers.ts +143 -0
  206. package/src/types.ts +708 -0
  207. package/src/watchdog/daemon.test.ts +1233 -0
  208. package/src/watchdog/daemon.ts +533 -0
  209. package/src/watchdog/health.test.ts +371 -0
  210. package/src/watchdog/health.ts +248 -0
  211. package/src/watchdog/triage.test.ts +162 -0
  212. package/src/watchdog/triage.ts +193 -0
  213. package/src/worktree/manager.test.ts +444 -0
  214. package/src/worktree/manager.ts +224 -0
  215. package/src/worktree/tmux.test.ts +1238 -0
  216. package/src/worktree/tmux.ts +644 -0
  217. package/templates/CLAUDE.md.tmpl +89 -0
  218. package/templates/hooks.json.tmpl +132 -0
  219. package/templates/overlay.md.tmpl +79 -0
@@ -0,0 +1,815 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import Database from "better-sqlite3";
5
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
6
+ import { MailError } from "../errors.ts";
7
+ import type { MailMessage } from "../types.ts";
8
+ import { MAIL_MESSAGE_TYPES } from "../types.ts";
9
+ import { createMailStore, type MailStore } from "./store.ts";
10
+
11
+ describe("createMailStore", () => {
12
+ let tempDir: string;
13
+ let store: MailStore;
14
+
15
+ beforeEach(async () => {
16
+ tempDir = await mkdtemp(join(tmpdir(), "legio-mail-test-"));
17
+ store = createMailStore(join(tempDir, "mail.db"));
18
+ });
19
+
20
+ afterEach(async () => {
21
+ store.close();
22
+ await rm(tempDir, { recursive: true, force: true });
23
+ });
24
+
25
+ describe("insert", () => {
26
+ test("inserts a message and returns it with generated id and timestamp", () => {
27
+ const msg = store.insert({
28
+ id: "",
29
+ from: "agent-a",
30
+ to: "orchestrator",
31
+ subject: "status update",
32
+ body: "All tests passing",
33
+ type: "status",
34
+ priority: "normal",
35
+ threadId: null,
36
+ });
37
+
38
+ expect(msg.id).toMatch(/^msg-[a-z0-9]{12}$/);
39
+ expect(msg.from).toBe("agent-a");
40
+ expect(msg.to).toBe("orchestrator");
41
+ expect(msg.subject).toBe("status update");
42
+ expect(msg.body).toBe("All tests passing");
43
+ expect(msg.type).toBe("status");
44
+ expect(msg.priority).toBe("normal");
45
+ expect(msg.threadId).toBeNull();
46
+ expect(msg.read).toBe(false);
47
+ expect(msg.createdAt).toBeTruthy();
48
+ });
49
+
50
+ test("uses provided id if non-empty", () => {
51
+ const msg = store.insert({
52
+ id: "custom-id-123",
53
+ from: "agent-a",
54
+ to: "orchestrator",
55
+ subject: "test",
56
+ body: "test body",
57
+ type: "status",
58
+ priority: "normal",
59
+ threadId: null,
60
+ });
61
+
62
+ expect(msg.id).toBe("custom-id-123");
63
+ });
64
+
65
+ test("throws MailError on duplicate id", () => {
66
+ store.insert({
67
+ id: "dupe-id",
68
+ from: "agent-a",
69
+ to: "orchestrator",
70
+ subject: "first",
71
+ body: "first message",
72
+ type: "status",
73
+ priority: "normal",
74
+ threadId: null,
75
+ });
76
+
77
+ expect(() =>
78
+ store.insert({
79
+ id: "dupe-id",
80
+ from: "agent-b",
81
+ to: "orchestrator",
82
+ subject: "second",
83
+ body: "second message",
84
+ type: "status",
85
+ priority: "normal",
86
+ threadId: null,
87
+ }),
88
+ ).toThrow(MailError);
89
+ });
90
+ });
91
+
92
+ describe("getById", () => {
93
+ test("returns message by id", () => {
94
+ store.insert({
95
+ id: "msg-test-001",
96
+ from: "agent-a",
97
+ to: "orchestrator",
98
+ subject: "test",
99
+ body: "body",
100
+ type: "status",
101
+ priority: "normal",
102
+ threadId: null,
103
+ });
104
+
105
+ const msg = store.getById("msg-test-001");
106
+ expect(msg).not.toBeNull();
107
+ expect(msg?.id).toBe("msg-test-001");
108
+ expect(msg?.from).toBe("agent-a");
109
+ });
110
+
111
+ test("returns null for non-existent id", () => {
112
+ const msg = store.getById("nonexistent");
113
+ expect(msg).toBeNull();
114
+ });
115
+ });
116
+
117
+ describe("getUnread", () => {
118
+ test("returns unread messages for a specific agent", () => {
119
+ store.insert({
120
+ id: "",
121
+ from: "agent-a",
122
+ to: "orchestrator",
123
+ subject: "msg1",
124
+ body: "body1",
125
+ type: "status",
126
+ priority: "normal",
127
+ threadId: null,
128
+ });
129
+ store.insert({
130
+ id: "",
131
+ from: "agent-b",
132
+ to: "orchestrator",
133
+ subject: "msg2",
134
+ body: "body2",
135
+ type: "status",
136
+ priority: "normal",
137
+ threadId: null,
138
+ });
139
+ store.insert({
140
+ id: "",
141
+ from: "agent-a",
142
+ to: "agent-c",
143
+ subject: "msg3",
144
+ body: "body3",
145
+ type: "status",
146
+ priority: "normal",
147
+ threadId: null,
148
+ });
149
+
150
+ const unread = store.getUnread("orchestrator");
151
+ expect(unread).toHaveLength(2);
152
+ expect(unread[0]?.subject).toBe("msg1");
153
+ expect(unread[1]?.subject).toBe("msg2");
154
+ });
155
+
156
+ test("returns empty array when no unread messages", () => {
157
+ const unread = store.getUnread("orchestrator");
158
+ expect(unread).toHaveLength(0);
159
+ });
160
+
161
+ test("does not return already-read messages", () => {
162
+ const msg = store.insert({
163
+ id: "",
164
+ from: "agent-a",
165
+ to: "orchestrator",
166
+ subject: "test",
167
+ body: "body",
168
+ type: "status",
169
+ priority: "normal",
170
+ threadId: null,
171
+ });
172
+ store.markRead(msg.id);
173
+
174
+ const unread = store.getUnread("orchestrator");
175
+ expect(unread).toHaveLength(0);
176
+ });
177
+
178
+ test("returns messages in chronological order (ASC)", () => {
179
+ store.insert({
180
+ id: "msg-first",
181
+ from: "agent-a",
182
+ to: "orchestrator",
183
+ subject: "first",
184
+ body: "body",
185
+ type: "status",
186
+ priority: "normal",
187
+ threadId: null,
188
+ });
189
+ store.insert({
190
+ id: "msg-second",
191
+ from: "agent-b",
192
+ to: "orchestrator",
193
+ subject: "second",
194
+ body: "body",
195
+ type: "status",
196
+ priority: "normal",
197
+ threadId: null,
198
+ });
199
+
200
+ const unread = store.getUnread("orchestrator");
201
+ expect(unread[0]?.id).toBe("msg-first");
202
+ expect(unread[1]?.id).toBe("msg-second");
203
+ });
204
+ });
205
+
206
+ describe("markRead", () => {
207
+ test("marks a message as read", () => {
208
+ const msg = store.insert({
209
+ id: "msg-to-read",
210
+ from: "agent-a",
211
+ to: "orchestrator",
212
+ subject: "test",
213
+ body: "body",
214
+ type: "status",
215
+ priority: "normal",
216
+ threadId: null,
217
+ });
218
+
219
+ store.markRead(msg.id);
220
+
221
+ const fetched = store.getById(msg.id);
222
+ expect(fetched?.read).toBe(true);
223
+ });
224
+
225
+ test("is idempotent (marking already-read message does not error)", () => {
226
+ const msg = store.insert({
227
+ id: "msg-idempotent",
228
+ from: "agent-a",
229
+ to: "orchestrator",
230
+ subject: "test",
231
+ body: "body",
232
+ type: "status",
233
+ priority: "normal",
234
+ threadId: null,
235
+ });
236
+
237
+ store.markRead(msg.id);
238
+ store.markRead(msg.id);
239
+
240
+ const fetched = store.getById(msg.id);
241
+ expect(fetched?.read).toBe(true);
242
+ });
243
+ });
244
+
245
+ describe("getAll", () => {
246
+ test("returns all messages without filters", () => {
247
+ store.insert({
248
+ id: "",
249
+ from: "agent-a",
250
+ to: "orchestrator",
251
+ subject: "msg1",
252
+ body: "body1",
253
+ type: "status",
254
+ priority: "normal",
255
+ threadId: null,
256
+ });
257
+ store.insert({
258
+ id: "",
259
+ from: "agent-b",
260
+ to: "agent-c",
261
+ subject: "msg2",
262
+ body: "body2",
263
+ type: "question",
264
+ priority: "high",
265
+ threadId: null,
266
+ });
267
+
268
+ const all = store.getAll();
269
+ expect(all).toHaveLength(2);
270
+ });
271
+
272
+ test("filters by from", () => {
273
+ store.insert({
274
+ id: "",
275
+ from: "agent-a",
276
+ to: "orchestrator",
277
+ subject: "msg1",
278
+ body: "body1",
279
+ type: "status",
280
+ priority: "normal",
281
+ threadId: null,
282
+ });
283
+ store.insert({
284
+ id: "",
285
+ from: "agent-b",
286
+ to: "orchestrator",
287
+ subject: "msg2",
288
+ body: "body2",
289
+ type: "status",
290
+ priority: "normal",
291
+ threadId: null,
292
+ });
293
+
294
+ const filtered = store.getAll({ from: "agent-a" });
295
+ expect(filtered).toHaveLength(1);
296
+ expect(filtered[0]?.from).toBe("agent-a");
297
+ });
298
+
299
+ test("filters by to", () => {
300
+ store.insert({
301
+ id: "",
302
+ from: "agent-a",
303
+ to: "orchestrator",
304
+ subject: "msg1",
305
+ body: "body1",
306
+ type: "status",
307
+ priority: "normal",
308
+ threadId: null,
309
+ });
310
+ store.insert({
311
+ id: "",
312
+ from: "agent-a",
313
+ to: "agent-b",
314
+ subject: "msg2",
315
+ body: "body2",
316
+ type: "status",
317
+ priority: "normal",
318
+ threadId: null,
319
+ });
320
+
321
+ const filtered = store.getAll({ to: "agent-b" });
322
+ expect(filtered).toHaveLength(1);
323
+ expect(filtered[0]?.to).toBe("agent-b");
324
+ });
325
+
326
+ test("filters by unread", () => {
327
+ const msg1 = store.insert({
328
+ id: "",
329
+ from: "agent-a",
330
+ to: "orchestrator",
331
+ subject: "msg1",
332
+ body: "body1",
333
+ type: "status",
334
+ priority: "normal",
335
+ threadId: null,
336
+ });
337
+ store.insert({
338
+ id: "",
339
+ from: "agent-b",
340
+ to: "orchestrator",
341
+ subject: "msg2",
342
+ body: "body2",
343
+ type: "status",
344
+ priority: "normal",
345
+ threadId: null,
346
+ });
347
+ store.markRead(msg1.id);
348
+
349
+ const unreadOnly = store.getAll({ unread: true });
350
+ expect(unreadOnly).toHaveLength(1);
351
+ expect(unreadOnly[0]?.subject).toBe("msg2");
352
+
353
+ const readOnly = store.getAll({ unread: false });
354
+ expect(readOnly).toHaveLength(1);
355
+ expect(readOnly[0]?.subject).toBe("msg1");
356
+ });
357
+
358
+ test("combines multiple filters", () => {
359
+ store.insert({
360
+ id: "",
361
+ from: "agent-a",
362
+ to: "orchestrator",
363
+ subject: "msg1",
364
+ body: "body1",
365
+ type: "status",
366
+ priority: "normal",
367
+ threadId: null,
368
+ });
369
+ store.insert({
370
+ id: "",
371
+ from: "agent-a",
372
+ to: "agent-b",
373
+ subject: "msg2",
374
+ body: "body2",
375
+ type: "status",
376
+ priority: "normal",
377
+ threadId: null,
378
+ });
379
+ store.insert({
380
+ id: "",
381
+ from: "agent-b",
382
+ to: "orchestrator",
383
+ subject: "msg3",
384
+ body: "body3",
385
+ type: "status",
386
+ priority: "normal",
387
+ threadId: null,
388
+ });
389
+
390
+ const filtered = store.getAll({ from: "agent-a", to: "orchestrator" });
391
+ expect(filtered).toHaveLength(1);
392
+ expect(filtered[0]?.subject).toBe("msg1");
393
+ });
394
+ });
395
+
396
+ describe("getByThread", () => {
397
+ test("returns messages in the same thread", () => {
398
+ store.insert({
399
+ id: "msg-thread-1",
400
+ from: "agent-a",
401
+ to: "orchestrator",
402
+ subject: "question",
403
+ body: "first message",
404
+ type: "question",
405
+ priority: "normal",
406
+ threadId: "thread-123",
407
+ });
408
+ store.insert({
409
+ id: "msg-thread-2",
410
+ from: "orchestrator",
411
+ to: "agent-a",
412
+ subject: "Re: question",
413
+ body: "reply",
414
+ type: "status",
415
+ priority: "normal",
416
+ threadId: "thread-123",
417
+ });
418
+ store.insert({
419
+ id: "msg-other",
420
+ from: "agent-b",
421
+ to: "orchestrator",
422
+ subject: "unrelated",
423
+ body: "different thread",
424
+ type: "status",
425
+ priority: "normal",
426
+ threadId: "thread-456",
427
+ });
428
+
429
+ const thread = store.getByThread("thread-123");
430
+ expect(thread).toHaveLength(2);
431
+ expect(thread[0]?.id).toBe("msg-thread-1");
432
+ expect(thread[1]?.id).toBe("msg-thread-2");
433
+ });
434
+
435
+ test("returns empty array for non-existent thread", () => {
436
+ const thread = store.getByThread("nonexistent");
437
+ expect(thread).toHaveLength(0);
438
+ });
439
+ });
440
+
441
+ describe("WAL mode and concurrent access", () => {
442
+ test("second store instance can read while first is writing", () => {
443
+ const store2 = createMailStore(join(tempDir, "mail.db"));
444
+
445
+ store.insert({
446
+ id: "msg-concurrent",
447
+ from: "agent-a",
448
+ to: "orchestrator",
449
+ subject: "test",
450
+ body: "concurrent",
451
+ type: "status",
452
+ priority: "normal",
453
+ threadId: null,
454
+ });
455
+
456
+ const msg = store2.getById("msg-concurrent");
457
+ expect(msg).not.toBeNull();
458
+ expect(msg?.body).toBe("concurrent");
459
+
460
+ store2.close();
461
+ });
462
+ });
463
+
464
+ describe("CHECK constraints", () => {
465
+ test("rejects invalid type at DB level", () => {
466
+ expect(() =>
467
+ store.insert({
468
+ id: "msg-bad-type",
469
+ from: "agent-a",
470
+ to: "orchestrator",
471
+ subject: "test",
472
+ body: "body",
473
+ type: "invalid_type" as MailMessage["type"],
474
+ priority: "normal",
475
+ threadId: null,
476
+ }),
477
+ ).toThrow();
478
+ });
479
+
480
+ test("rejects invalid priority at DB level", () => {
481
+ expect(() =>
482
+ store.insert({
483
+ id: "msg-bad-prio",
484
+ from: "agent-a",
485
+ to: "orchestrator",
486
+ subject: "test",
487
+ body: "body",
488
+ type: "status",
489
+ priority: "invalid_prio" as MailMessage["priority"],
490
+ threadId: null,
491
+ }),
492
+ ).toThrow();
493
+ });
494
+
495
+ test("accepts all valid type values including protocol types", () => {
496
+ const types: MailMessage["type"][] = [
497
+ "status",
498
+ "question",
499
+ "result",
500
+ "error",
501
+ "worker_done",
502
+ "merge_ready",
503
+ "merged",
504
+ "merge_failed",
505
+ "escalation",
506
+ "health_check",
507
+ "dispatch",
508
+ "assign",
509
+ ];
510
+ for (const type of types) {
511
+ const msg = store.insert({
512
+ id: "",
513
+ from: "agent-a",
514
+ to: "orchestrator",
515
+ subject: `type-${type}`,
516
+ body: "body",
517
+ type,
518
+ priority: "normal",
519
+ threadId: null,
520
+ });
521
+ expect(msg.type).toBe(type);
522
+ }
523
+ });
524
+
525
+ test("accepts all valid priority values", () => {
526
+ const priorities: MailMessage["priority"][] = ["low", "normal", "high", "urgent"];
527
+ for (const priority of priorities) {
528
+ const msg = store.insert({
529
+ id: "",
530
+ from: "agent-a",
531
+ to: "orchestrator",
532
+ subject: `prio-${priority}`,
533
+ body: "body",
534
+ type: "status",
535
+ priority,
536
+ threadId: null,
537
+ });
538
+ expect(msg.priority).toBe(priority);
539
+ }
540
+ });
541
+
542
+ test("migrates existing table to add payload column and protocol types", () => {
543
+ // Create a second store to verify migration works on an existing DB
544
+ // The beforeEach already created the DB with constraints,
545
+ // so this tests that reopening is safe
546
+ const store2 = createMailStore(join(tempDir, "mail.db"));
547
+ const msg = store2.insert({
548
+ id: "msg-after-migration",
549
+ from: "agent-a",
550
+ to: "orchestrator",
551
+ subject: "migration test",
552
+ body: "body",
553
+ type: "status",
554
+ priority: "normal",
555
+ threadId: null,
556
+ });
557
+ expect(msg.id).toBe("msg-after-migration");
558
+
559
+ // Invalid values should still be rejected
560
+ expect(() =>
561
+ store2.insert({
562
+ id: "msg-bad-after",
563
+ from: "agent-a",
564
+ to: "orchestrator",
565
+ subject: "test",
566
+ body: "body",
567
+ type: "bogus" as MailMessage["type"],
568
+ priority: "normal",
569
+ threadId: null,
570
+ }),
571
+ ).toThrow();
572
+
573
+ store2.close();
574
+ });
575
+ });
576
+
577
+ describe("payload column", () => {
578
+ test("stores null payload by default when not provided", () => {
579
+ const msg = store.insert({
580
+ id: "msg-no-payload",
581
+ from: "agent-a",
582
+ to: "orchestrator",
583
+ subject: "test",
584
+ body: "body",
585
+ type: "status",
586
+ priority: "normal",
587
+ threadId: null,
588
+ });
589
+
590
+ const fetched = store.getById(msg.id);
591
+ expect(fetched?.payload).toBeNull();
592
+ });
593
+
594
+ test("stores JSON payload string", () => {
595
+ const payload = JSON.stringify({
596
+ beadId: "beads-abc",
597
+ branch: "agent/builder-1",
598
+ exitCode: 0,
599
+ filesModified: ["src/foo.ts"],
600
+ });
601
+ const msg = store.insert({
602
+ id: "msg-with-payload",
603
+ from: "builder-1",
604
+ to: "lead-1",
605
+ subject: "Task complete",
606
+ body: "Implementation finished",
607
+ type: "worker_done",
608
+ priority: "normal",
609
+ threadId: null,
610
+ payload,
611
+ });
612
+
613
+ const fetched = store.getById(msg.id);
614
+ expect(fetched?.payload).toBe(payload);
615
+ expect(fetched?.type).toBe("worker_done");
616
+ });
617
+
618
+ test("returns payload in getUnread results", () => {
619
+ const payload = JSON.stringify({ severity: "critical", beadId: null, context: "OOM" });
620
+ store.insert({
621
+ id: "msg-escalation",
622
+ from: "builder-1",
623
+ to: "orchestrator",
624
+ subject: "Escalation",
625
+ body: "Out of memory",
626
+ type: "escalation",
627
+ priority: "urgent",
628
+ threadId: null,
629
+ payload,
630
+ });
631
+
632
+ const unread = store.getUnread("orchestrator");
633
+ expect(unread).toHaveLength(1);
634
+ expect(unread[0]?.payload).toBe(payload);
635
+ });
636
+
637
+ test("returns payload in getAll results", () => {
638
+ const payload = JSON.stringify({
639
+ branch: "agent/b1",
640
+ beadId: "beads-xyz",
641
+ tier: "clean-merge",
642
+ });
643
+ store.insert({
644
+ id: "msg-merged",
645
+ from: "merger-1",
646
+ to: "lead-1",
647
+ subject: "Merged",
648
+ body: "Branch merged",
649
+ type: "merged",
650
+ priority: "normal",
651
+ threadId: null,
652
+ payload,
653
+ });
654
+
655
+ const all = store.getAll();
656
+ expect(all).toHaveLength(1);
657
+ expect(all[0]?.payload).toBe(payload);
658
+ });
659
+ });
660
+
661
+ describe("audience column", () => {
662
+ test("defaults audience to agent when not provided", () => {
663
+ const msg = store.insert({
664
+ id: "msg-default-audience",
665
+ from: "agent-a",
666
+ to: "orchestrator",
667
+ subject: "test",
668
+ body: "body",
669
+ type: "status",
670
+ priority: "normal",
671
+ threadId: null,
672
+ });
673
+
674
+ const fetched = store.getById(msg.id);
675
+ expect(fetched?.audience).toBe("agent");
676
+ });
677
+
678
+ test("stores explicit audience value 'both'", () => {
679
+ const msg = store.insert({
680
+ id: "msg-both-audience",
681
+ from: "agent-a",
682
+ to: "orchestrator",
683
+ subject: "test",
684
+ body: "body",
685
+ type: "status",
686
+ priority: "normal",
687
+ threadId: null,
688
+ audience: "both",
689
+ });
690
+
691
+ const fetched = store.getById(msg.id);
692
+ expect(fetched?.audience).toBe("both");
693
+ });
694
+
695
+ test("stores human audience", () => {
696
+ const msg = store.insert({
697
+ id: "msg-human-audience",
698
+ from: "agent-a",
699
+ to: "orchestrator",
700
+ subject: "test",
701
+ body: "body",
702
+ type: "status",
703
+ priority: "normal",
704
+ threadId: null,
705
+ audience: "human",
706
+ });
707
+
708
+ const fetched = store.getById(msg.id);
709
+ expect(fetched?.audience).toBe("human");
710
+ });
711
+
712
+ test("rejects invalid audience at DB level", () => {
713
+ expect(() =>
714
+ store.insert({
715
+ id: "msg-bad-audience",
716
+ from: "agent-a",
717
+ to: "orchestrator",
718
+ subject: "test",
719
+ body: "body",
720
+ type: "status",
721
+ priority: "normal",
722
+ threadId: null,
723
+ audience: "invalid" as MailMessage["audience"],
724
+ }),
725
+ ).toThrow();
726
+ });
727
+
728
+ test("returns audience in getUnread results", () => {
729
+ store.insert({
730
+ id: "msg-unread-audience",
731
+ from: "agent-a",
732
+ to: "orchestrator",
733
+ subject: "test",
734
+ body: "body",
735
+ type: "status",
736
+ priority: "normal",
737
+ threadId: null,
738
+ audience: "both",
739
+ });
740
+
741
+ const unread = store.getUnread("orchestrator");
742
+ expect(unread).toHaveLength(1);
743
+ expect(unread[0]?.audience).toBe("both");
744
+ });
745
+
746
+ test("returns audience in getAll results", () => {
747
+ store.insert({
748
+ id: "msg-all-audience",
749
+ from: "agent-a",
750
+ to: "orchestrator",
751
+ subject: "test",
752
+ body: "body",
753
+ type: "status",
754
+ priority: "normal",
755
+ threadId: null,
756
+ audience: "human",
757
+ });
758
+
759
+ const all = store.getAll();
760
+ expect(all).toHaveLength(1);
761
+ expect(all[0]?.audience).toBe("human");
762
+ });
763
+
764
+ test("migrates existing table without audience column to add audience with default 'agent'", () => {
765
+ // Create a DB with the old schema (has CHECK + payload + protocol types, but no audience)
766
+ const oldDbPath = join(tempDir, "mail-migrate-audience.db");
767
+ const oldDb = new Database(oldDbPath);
768
+ oldDb.pragma("journal_mode = WAL");
769
+ const validTypes = MAIL_MESSAGE_TYPES.map((t) => `'${t}'`).join(",");
770
+ oldDb.exec(`
771
+ CREATE TABLE messages (
772
+ id TEXT PRIMARY KEY,
773
+ from_agent TEXT NOT NULL,
774
+ to_agent TEXT NOT NULL,
775
+ subject TEXT NOT NULL,
776
+ body TEXT NOT NULL,
777
+ type TEXT NOT NULL DEFAULT 'status' CHECK(type IN (${validTypes})),
778
+ priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('low','normal','high','urgent')),
779
+ thread_id TEXT,
780
+ payload TEXT,
781
+ read INTEGER NOT NULL DEFAULT 0,
782
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
783
+ )`);
784
+ oldDb
785
+ .prepare(
786
+ `INSERT INTO messages (id, from_agent, to_agent, subject, body, type, priority, thread_id, payload, read, created_at)
787
+ VALUES ('msg-pre-migration', 'agent-a', 'orchestrator', 'old msg', 'body', 'status', 'normal', NULL, NULL, 0, '2024-01-01T00:00:00.000Z')`,
788
+ )
789
+ .run();
790
+ oldDb.close();
791
+
792
+ // Reopen with createMailStore — should migrate and add audience column
793
+ const migratedStore = createMailStore(oldDbPath);
794
+ const msg = migratedStore.getById("msg-pre-migration");
795
+ expect(msg).not.toBeNull();
796
+ expect(msg?.audience).toBe("agent"); // Default applied to old row
797
+
798
+ // New inserts should also work correctly
799
+ const newMsg = migratedStore.insert({
800
+ id: "msg-post-migration",
801
+ from: "agent-b",
802
+ to: "orchestrator",
803
+ subject: "new",
804
+ body: "body",
805
+ type: "status",
806
+ priority: "normal",
807
+ threadId: null,
808
+ audience: "both",
809
+ });
810
+ expect(newMsg.audience).toBe("both");
811
+
812
+ migratedStore.close();
813
+ });
814
+ });
815
+ });