@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,384 @@
1
+ /**
2
+ * Tests for the AuditStore SQLite implementation.
3
+ *
4
+ * Uses real temp-file databases — no mocking of store logic.
5
+ */
6
+
7
+ import { mkdir, rm } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
11
+ import { createAuditStore } from "./audit-store.ts";
12
+
13
+ let tempDir: string;
14
+ let dbPath: string;
15
+
16
+ beforeEach(async () => {
17
+ tempDir = join(tmpdir(), `audit-store-test-${Date.now()}`);
18
+ await mkdir(tempDir, { recursive: true });
19
+ dbPath = join(tempDir, "audit.db");
20
+ });
21
+
22
+ afterEach(async () => {
23
+ await rm(tempDir, { recursive: true, force: true });
24
+ });
25
+
26
+ describe("createAuditStore", () => {
27
+ it("creates a store and returns it", () => {
28
+ const store = createAuditStore(dbPath);
29
+ expect(store).toBeDefined();
30
+ store.close();
31
+ });
32
+
33
+ it("insert + getAll round-trip", () => {
34
+ const store = createAuditStore(dbPath);
35
+ try {
36
+ const id = store.insert({
37
+ type: "command",
38
+ agent: "orchestrator",
39
+ source: "web_ui",
40
+ summary: "User sent a command",
41
+ detail: "ls -la",
42
+ });
43
+ expect(typeof id).toBe("number");
44
+ expect(id).toBeGreaterThan(0);
45
+
46
+ const events = store.getAll();
47
+ expect(events.length).toBe(1);
48
+ const ev = events[0];
49
+ expect(ev).toBeDefined();
50
+ if (!ev) return;
51
+ expect(ev.id).toBe(id);
52
+ expect(ev.type).toBe("command");
53
+ expect(ev.agent).toBe("orchestrator");
54
+ expect(ev.source).toBe("web_ui");
55
+ expect(ev.summary).toBe("User sent a command");
56
+ expect(ev.detail).toBe("ls -la");
57
+ expect(ev.sessionId).toBeNull();
58
+ expect(typeof ev.createdAt).toBe("string");
59
+ } finally {
60
+ store.close();
61
+ }
62
+ });
63
+
64
+ it("defaults source to system when not provided", () => {
65
+ const store = createAuditStore(dbPath);
66
+ try {
67
+ store.insert({ type: "system", summary: "Startup" });
68
+ const events = store.getAll();
69
+ expect(events[0]?.source).toBe("system");
70
+ } finally {
71
+ store.close();
72
+ }
73
+ });
74
+
75
+ it("allows null agent", () => {
76
+ const store = createAuditStore(dbPath);
77
+ try {
78
+ store.insert({ type: "system", summary: "No agent event", agent: null });
79
+ const events = store.getAll();
80
+ expect(events[0]?.agent).toBeNull();
81
+ } finally {
82
+ store.close();
83
+ }
84
+ });
85
+
86
+ it("stores sessionId when provided", () => {
87
+ const store = createAuditStore(dbPath);
88
+ try {
89
+ store.insert({ type: "state_change", summary: "Agent started", sessionId: "sess-abc" });
90
+ const events = store.getAll();
91
+ expect(events[0]?.sessionId).toBe("sess-abc");
92
+ } finally {
93
+ store.close();
94
+ }
95
+ });
96
+ });
97
+
98
+ describe("getAll filters", () => {
99
+ function seedStore(store: ReturnType<typeof createAuditStore>): void {
100
+ store.insert({ type: "command", agent: "orchestrator", source: "web_ui", summary: "cmd1" });
101
+ store.insert({ type: "response", agent: "coordinator", source: "system", summary: "resp1" });
102
+ store.insert({ type: "error", agent: "orchestrator", source: "cli", summary: "err1" });
103
+ store.insert({ type: "command", agent: "coordinator", source: "web_ui", summary: "cmd2" });
104
+ }
105
+
106
+ it("returns all events without filters", () => {
107
+ const store = createAuditStore(dbPath);
108
+ try {
109
+ seedStore(store);
110
+ expect(store.getAll().length).toBe(4);
111
+ } finally {
112
+ store.close();
113
+ }
114
+ });
115
+
116
+ it("filters by type", () => {
117
+ const store = createAuditStore(dbPath);
118
+ try {
119
+ seedStore(store);
120
+ const events = store.getAll({ type: "command" });
121
+ expect(events.length).toBe(2);
122
+ expect(events.every((e) => e.type === "command")).toBe(true);
123
+ } finally {
124
+ store.close();
125
+ }
126
+ });
127
+
128
+ it("filters by agent", () => {
129
+ const store = createAuditStore(dbPath);
130
+ try {
131
+ seedStore(store);
132
+ const events = store.getAll({ agent: "coordinator" });
133
+ expect(events.length).toBe(2);
134
+ expect(events.every((e) => e.agent === "coordinator")).toBe(true);
135
+ } finally {
136
+ store.close();
137
+ }
138
+ });
139
+
140
+ it("filters by source", () => {
141
+ const store = createAuditStore(dbPath);
142
+ try {
143
+ seedStore(store);
144
+ const events = store.getAll({ source: "web_ui" });
145
+ expect(events.length).toBe(2);
146
+ expect(events.every((e) => e.source === "web_ui")).toBe(true);
147
+ } finally {
148
+ store.close();
149
+ }
150
+ });
151
+
152
+ it("filters by since timestamp", () => {
153
+ const store = createAuditStore(dbPath);
154
+ try {
155
+ // Insert one event, record time, insert another after
156
+ store.insert({ type: "command", summary: "before" });
157
+ const midpoint = new Date().toISOString();
158
+ // Wait a tiny bit so the next event has a later timestamp
159
+ const now = Date.now();
160
+ while (Date.now() - now < 5) {
161
+ // spin
162
+ }
163
+ store.insert({ type: "command", summary: "after" });
164
+
165
+ const events = store.getAll({ since: midpoint });
166
+ // The "after" event should be returned; "before" may or may not depending on timing
167
+ expect(events.length).toBeGreaterThanOrEqual(1);
168
+ const latest = events[events.length - 1];
169
+ expect(latest?.summary).toBe("after");
170
+ } finally {
171
+ store.close();
172
+ }
173
+ });
174
+
175
+ it("applies limit", () => {
176
+ const store = createAuditStore(dbPath);
177
+ try {
178
+ seedStore(store);
179
+ const events = store.getAll({ limit: 2 });
180
+ expect(events.length).toBe(2);
181
+ } finally {
182
+ store.close();
183
+ }
184
+ });
185
+
186
+ it("combines multiple filters", () => {
187
+ const store = createAuditStore(dbPath);
188
+ try {
189
+ seedStore(store);
190
+ const events = store.getAll({ type: "command", agent: "orchestrator" });
191
+ expect(events.length).toBe(1);
192
+ expect(events[0]?.summary).toBe("cmd1");
193
+ } finally {
194
+ store.close();
195
+ }
196
+ });
197
+ });
198
+
199
+ describe("getTimeline", () => {
200
+ it("returns events in chronological order", () => {
201
+ const store = createAuditStore(dbPath);
202
+ try {
203
+ store.insert({ type: "command", summary: "first" });
204
+ store.insert({ type: "response", summary: "second" });
205
+ store.insert({ type: "state_change", summary: "third" });
206
+ const events = store.getTimeline();
207
+ expect(events.length).toBe(3);
208
+ for (let i = 0; i < events.length - 1; i++) {
209
+ const a = events[i];
210
+ const b = events[i + 1];
211
+ if (a && b) {
212
+ expect(a.createdAt <= b.createdAt).toBe(true);
213
+ }
214
+ }
215
+ } finally {
216
+ store.close();
217
+ }
218
+ });
219
+
220
+ it("filters by since and until", () => {
221
+ const store = createAuditStore(dbPath);
222
+ try {
223
+ const past = "1970-01-01T00:00:00.000Z";
224
+ const future = "2099-01-01T00:00:00.000Z";
225
+ store.insert({ type: "command", summary: "event" });
226
+ const events = store.getTimeline({ since: past, until: future });
227
+ expect(events.length).toBe(1);
228
+ } finally {
229
+ store.close();
230
+ }
231
+ });
232
+
233
+ it("applies limit", () => {
234
+ const store = createAuditStore(dbPath);
235
+ try {
236
+ for (let i = 0; i < 5; i++) {
237
+ store.insert({ type: "command", summary: `event-${i}` });
238
+ }
239
+ const events = store.getTimeline({ limit: 3 });
240
+ expect(events.length).toBe(3);
241
+ } finally {
242
+ store.close();
243
+ }
244
+ });
245
+
246
+ it("returns empty array when no events match", () => {
247
+ const store = createAuditStore(dbPath);
248
+ try {
249
+ const events = store.getTimeline({ since: "2099-01-01T00:00:00.000Z" });
250
+ expect(events).toEqual([]);
251
+ } finally {
252
+ store.close();
253
+ }
254
+ });
255
+ });
256
+
257
+ describe("getByAgent", () => {
258
+ it("returns events for the given agent", () => {
259
+ const store = createAuditStore(dbPath);
260
+ try {
261
+ store.insert({ type: "command", agent: "orch", summary: "cmd" });
262
+ store.insert({ type: "response", agent: "coord", summary: "resp" });
263
+ store.insert({ type: "error", agent: "orch", summary: "err" });
264
+ const events = store.getByAgent("orch");
265
+ expect(events.length).toBe(2);
266
+ expect(events.every((e) => e.agent === "orch")).toBe(true);
267
+ } finally {
268
+ store.close();
269
+ }
270
+ });
271
+
272
+ it("filters by since", () => {
273
+ const store = createAuditStore(dbPath);
274
+ try {
275
+ store.insert({ type: "command", agent: "orch", summary: "old" });
276
+ const mid = new Date().toISOString();
277
+ const now = Date.now();
278
+ while (Date.now() - now < 5) {
279
+ // spin
280
+ }
281
+ store.insert({ type: "command", agent: "orch", summary: "new" });
282
+ const events = store.getByAgent("orch", { since: mid });
283
+ expect(events.length).toBeGreaterThanOrEqual(1);
284
+ const last = events[events.length - 1];
285
+ expect(last?.summary).toBe("new");
286
+ } finally {
287
+ store.close();
288
+ }
289
+ });
290
+
291
+ it("applies limit", () => {
292
+ const store = createAuditStore(dbPath);
293
+ try {
294
+ for (let i = 0; i < 4; i++) {
295
+ store.insert({ type: "command", agent: "orch", summary: `cmd-${i}` });
296
+ }
297
+ const events = store.getByAgent("orch", { limit: 2 });
298
+ expect(events.length).toBe(2);
299
+ } finally {
300
+ store.close();
301
+ }
302
+ });
303
+ });
304
+
305
+ describe("getByType", () => {
306
+ it("returns events of the given type", () => {
307
+ const store = createAuditStore(dbPath);
308
+ try {
309
+ store.insert({ type: "command", summary: "cmd1" });
310
+ store.insert({ type: "error", summary: "err1" });
311
+ store.insert({ type: "command", summary: "cmd2" });
312
+ const events = store.getByType("command");
313
+ expect(events.length).toBe(2);
314
+ expect(events.every((e) => e.type === "command")).toBe(true);
315
+ } finally {
316
+ store.close();
317
+ }
318
+ });
319
+
320
+ it("applies limit", () => {
321
+ const store = createAuditStore(dbPath);
322
+ try {
323
+ for (let i = 0; i < 5; i++) {
324
+ store.insert({ type: "merge", summary: `merge-${i}` });
325
+ }
326
+ const events = store.getByType("merge", { limit: 3 });
327
+ expect(events.length).toBe(3);
328
+ } finally {
329
+ store.close();
330
+ }
331
+ });
332
+ });
333
+
334
+ describe("purge", () => {
335
+ it("purge all deletes all events and returns count", () => {
336
+ const store = createAuditStore(dbPath);
337
+ try {
338
+ store.insert({ type: "command", summary: "a" });
339
+ store.insert({ type: "command", summary: "b" });
340
+ store.insert({ type: "command", summary: "c" });
341
+ const deleted = store.purge({ all: true });
342
+ expect(deleted).toBe(3);
343
+ expect(store.getAll().length).toBe(0);
344
+ } finally {
345
+ store.close();
346
+ }
347
+ });
348
+
349
+ it("purge olderThanMs deletes events older than the threshold", () => {
350
+ const store = createAuditStore(dbPath);
351
+ try {
352
+ store.insert({ type: "command", summary: "old" });
353
+ // After insertion, the event's created_at is in the past.
354
+ // Use a large enough window that the event is "recent" — but we want it gone.
355
+ // Actually we want to test that events are NOT purged if they are new.
356
+ // Insert, then purge with 10 seconds threshold — the event is brand new,
357
+ // so nothing should be deleted.
358
+ const deleted = store.purge({ olderThanMs: 10_000 });
359
+ expect(deleted).toBe(0);
360
+ expect(store.getAll().length).toBe(1);
361
+ } finally {
362
+ store.close();
363
+ }
364
+ });
365
+
366
+ it("purge with no options returns 0 and deletes nothing", () => {
367
+ const store = createAuditStore(dbPath);
368
+ try {
369
+ store.insert({ type: "command", summary: "event" });
370
+ const deleted = store.purge({});
371
+ expect(deleted).toBe(0);
372
+ expect(store.getAll().length).toBe(1);
373
+ } finally {
374
+ store.close();
375
+ }
376
+ });
377
+ });
378
+
379
+ describe("close", () => {
380
+ it("can be called without error", () => {
381
+ const store = createAuditStore(dbPath);
382
+ expect(() => store.close()).not.toThrow();
383
+ });
384
+ });
@@ -0,0 +1,257 @@
1
+ /**
2
+ * SQLite-backed audit trail store for the legio web UI.
3
+ *
4
+ * Records orchestration events: commands sent via UI, coordinator responses,
5
+ * state transitions, merge events, errors, and system-level events.
6
+ * Uses better-sqlite3 for synchronous database access.
7
+ * WAL mode enables concurrent reads from multiple processes.
8
+ */
9
+
10
+ import Database from "better-sqlite3";
11
+
12
+ /** Row shape as stored in SQLite (snake_case columns). */
13
+ interface AuditRow {
14
+ id: number;
15
+ type: string;
16
+ agent: string | null;
17
+ source: string;
18
+ summary: string;
19
+ detail: string | null;
20
+ session_id: string | null;
21
+ created_at: string;
22
+ }
23
+
24
+ export interface AuditEvent {
25
+ id: number;
26
+ type: string;
27
+ agent: string | null;
28
+ source: string;
29
+ summary: string;
30
+ detail: string | null;
31
+ sessionId: string | null;
32
+ createdAt: string;
33
+ }
34
+
35
+ export interface InsertAuditEvent {
36
+ type: string;
37
+ agent?: string | null;
38
+ source?: string;
39
+ summary: string;
40
+ detail?: string | null;
41
+ sessionId?: string | null;
42
+ }
43
+
44
+ export interface AuditQueryOptions {
45
+ since?: string;
46
+ until?: string;
47
+ agent?: string;
48
+ type?: string;
49
+ source?: string;
50
+ limit?: number;
51
+ }
52
+
53
+ export interface AuditStore {
54
+ insert(event: InsertAuditEvent): number;
55
+ getAll(opts?: AuditQueryOptions): AuditEvent[];
56
+ getTimeline(opts?: { since?: string; until?: string; limit?: number }): AuditEvent[];
57
+ getByAgent(agent: string, opts?: { since?: string; limit?: number }): AuditEvent[];
58
+ getByType(type: string, opts?: { since?: string; limit?: number }): AuditEvent[];
59
+ purge(opts: { all?: boolean; olderThanMs?: number }): number;
60
+ close(): void;
61
+ }
62
+
63
+ const CREATE_TABLE = `
64
+ CREATE TABLE IF NOT EXISTS audit_events (
65
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
66
+ type TEXT NOT NULL,
67
+ agent TEXT,
68
+ source TEXT NOT NULL DEFAULT 'system',
69
+ summary TEXT NOT NULL,
70
+ detail TEXT,
71
+ session_id TEXT,
72
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f','now'))
73
+ )`;
74
+
75
+ const CREATE_INDEXES = `
76
+ CREATE INDEX IF NOT EXISTS idx_audit_type_time ON audit_events(type, created_at);
77
+ CREATE INDEX IF NOT EXISTS idx_audit_agent_time ON audit_events(agent, created_at);
78
+ CREATE INDEX IF NOT EXISTS idx_audit_source ON audit_events(source)`;
79
+
80
+ /** Convert a database row (snake_case) to an AuditEvent object (camelCase). */
81
+ function rowToAuditEvent(row: AuditRow): AuditEvent {
82
+ return {
83
+ id: row.id,
84
+ type: row.type,
85
+ agent: row.agent,
86
+ source: row.source,
87
+ summary: row.summary,
88
+ detail: row.detail,
89
+ sessionId: row.session_id,
90
+ createdAt: row.created_at,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Create a new AuditStore backed by a SQLite database at the given path.
96
+ *
97
+ * Initializes the database with WAL mode and a 5-second busy timeout.
98
+ * Creates the audit_events table and indexes if they do not already exist.
99
+ */
100
+ export function createAuditStore(dbPath: string): AuditStore {
101
+ const db = new Database(dbPath);
102
+
103
+ // Configure for concurrent access from multiple processes.
104
+ db.exec("PRAGMA journal_mode = WAL");
105
+ db.exec("PRAGMA synchronous = NORMAL");
106
+ db.exec("PRAGMA busy_timeout = 5000");
107
+
108
+ // Create schema
109
+ db.exec(CREATE_TABLE);
110
+ db.exec(CREATE_INDEXES);
111
+
112
+ // Prepare the insert statement
113
+ const insertStmt = db.prepare(`
114
+ INSERT INTO audit_events
115
+ (type, agent, source, summary, detail, session_id)
116
+ VALUES
117
+ ($type, $agent, $source, $summary, $detail, $session_id)
118
+ RETURNING id
119
+ `);
120
+
121
+ return {
122
+ insert(event: InsertAuditEvent): number {
123
+ const row = insertStmt.get({
124
+ type: event.type,
125
+ agent: event.agent ?? null,
126
+ source: event.source ?? "system",
127
+ summary: event.summary,
128
+ detail: event.detail ?? null,
129
+ session_id: event.sessionId ?? null,
130
+ }) as { id: number } | undefined;
131
+ if (!row) {
132
+ return 0;
133
+ }
134
+ return row.id;
135
+ },
136
+
137
+ getAll(opts?: AuditQueryOptions): AuditEvent[] {
138
+ const conditions: string[] = [];
139
+ const params: Record<string, string | number> = {};
140
+
141
+ if (opts?.since !== undefined) {
142
+ conditions.push("created_at >= $since");
143
+ params.since = opts.since;
144
+ }
145
+ if (opts?.until !== undefined) {
146
+ conditions.push("created_at <= $until");
147
+ params.until = opts.until;
148
+ }
149
+ if (opts?.agent !== undefined) {
150
+ conditions.push("agent = $agent");
151
+ params.agent = opts.agent;
152
+ }
153
+ if (opts?.type !== undefined) {
154
+ conditions.push("type = $type");
155
+ params.type = opts.type;
156
+ }
157
+ if (opts?.source !== undefined) {
158
+ conditions.push("source = $source");
159
+ params.source = opts.source;
160
+ }
161
+
162
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
163
+ const limitClause = opts?.limit !== undefined ? `LIMIT ${opts.limit}` : "";
164
+ const query = `SELECT * FROM audit_events ${whereClause} ORDER BY created_at ASC ${limitClause}`;
165
+
166
+ const rows = db.prepare(query).all(params) as AuditRow[];
167
+ return rows.map(rowToAuditEvent);
168
+ },
169
+
170
+ getTimeline(opts?: { since?: string; until?: string; limit?: number }): AuditEvent[] {
171
+ const conditions: string[] = [];
172
+ const params: Record<string, string | number> = {};
173
+
174
+ if (opts?.since !== undefined) {
175
+ conditions.push("created_at >= $since");
176
+ params.since = opts.since;
177
+ }
178
+ if (opts?.until !== undefined) {
179
+ conditions.push("created_at <= $until");
180
+ params.until = opts.until;
181
+ }
182
+
183
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
184
+ const limitClause = opts?.limit !== undefined ? `LIMIT ${opts.limit}` : "";
185
+ const query = `SELECT * FROM audit_events ${whereClause} ORDER BY created_at ASC ${limitClause}`;
186
+
187
+ const rows = db.prepare(query).all(params) as AuditRow[];
188
+ return rows.map(rowToAuditEvent);
189
+ },
190
+
191
+ getByAgent(agent: string, opts?: { since?: string; limit?: number }): AuditEvent[] {
192
+ const conditions: string[] = ["agent = $agent"];
193
+ const params: Record<string, string | number> = { agent };
194
+
195
+ if (opts?.since !== undefined) {
196
+ conditions.push("created_at >= $since");
197
+ params.since = opts.since;
198
+ }
199
+
200
+ const whereClause = `WHERE ${conditions.join(" AND ")}`;
201
+ const limitClause = opts?.limit !== undefined ? `LIMIT ${opts.limit}` : "";
202
+ const query = `SELECT * FROM audit_events ${whereClause} ORDER BY created_at ASC ${limitClause}`;
203
+
204
+ const rows = db.prepare(query).all(params) as AuditRow[];
205
+ return rows.map(rowToAuditEvent);
206
+ },
207
+
208
+ getByType(type: string, opts?: { since?: string; limit?: number }): AuditEvent[] {
209
+ const conditions: string[] = ["type = $type"];
210
+ const params: Record<string, string | number> = { type };
211
+
212
+ if (opts?.since !== undefined) {
213
+ conditions.push("created_at >= $since");
214
+ params.since = opts.since;
215
+ }
216
+
217
+ const whereClause = `WHERE ${conditions.join(" AND ")}`;
218
+ const limitClause = opts?.limit !== undefined ? `LIMIT ${opts.limit}` : "";
219
+ const query = `SELECT * FROM audit_events ${whereClause} ORDER BY created_at ASC ${limitClause}`;
220
+
221
+ const rows = db.prepare(query).all(params) as AuditRow[];
222
+ return rows.map(rowToAuditEvent);
223
+ },
224
+
225
+ purge(opts: { all?: boolean; olderThanMs?: number }): number {
226
+ if (opts.all) {
227
+ const countRow = db.prepare("SELECT COUNT(*) as cnt FROM audit_events").get() as
228
+ | { cnt: number }
229
+ | undefined;
230
+ const count = countRow?.cnt ?? 0;
231
+ db.prepare("DELETE FROM audit_events").run();
232
+ return count;
233
+ }
234
+
235
+ if (opts.olderThanMs !== undefined) {
236
+ const cutoff = new Date(Date.now() - opts.olderThanMs).toISOString();
237
+ const countRow = db
238
+ .prepare("SELECT COUNT(*) as cnt FROM audit_events WHERE created_at < $cutoff")
239
+ .get({ cutoff }) as { cnt: number } | undefined;
240
+ const count = countRow?.cnt ?? 0;
241
+ db.prepare("DELETE FROM audit_events WHERE created_at < $cutoff").run({ cutoff });
242
+ return count;
243
+ }
244
+
245
+ return 0;
246
+ },
247
+
248
+ close(): void {
249
+ try {
250
+ db.exec("PRAGMA wal_checkpoint(PASSIVE)");
251
+ } catch {
252
+ // Best effort — checkpoint failure is non-fatal
253
+ }
254
+ db.close();
255
+ },
256
+ };
257
+ }