@os-eco/overstory-cli 0.6.1

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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +381 -0
  3. package/agents/builder.md +137 -0
  4. package/agents/coordinator.md +263 -0
  5. package/agents/lead.md +301 -0
  6. package/agents/merger.md +160 -0
  7. package/agents/monitor.md +214 -0
  8. package/agents/reviewer.md +140 -0
  9. package/agents/scout.md +119 -0
  10. package/agents/supervisor.md +423 -0
  11. package/package.json +47 -0
  12. package/src/agents/checkpoint.test.ts +88 -0
  13. package/src/agents/checkpoint.ts +101 -0
  14. package/src/agents/hooks-deployer.test.ts +2040 -0
  15. package/src/agents/hooks-deployer.ts +607 -0
  16. package/src/agents/identity.test.ts +603 -0
  17. package/src/agents/identity.ts +384 -0
  18. package/src/agents/lifecycle.test.ts +196 -0
  19. package/src/agents/lifecycle.ts +183 -0
  20. package/src/agents/manifest.test.ts +746 -0
  21. package/src/agents/manifest.ts +354 -0
  22. package/src/agents/overlay.test.ts +676 -0
  23. package/src/agents/overlay.ts +308 -0
  24. package/src/beads/client.test.ts +217 -0
  25. package/src/beads/client.ts +202 -0
  26. package/src/beads/molecules.test.ts +338 -0
  27. package/src/beads/molecules.ts +198 -0
  28. package/src/commands/agents.test.ts +322 -0
  29. package/src/commands/agents.ts +287 -0
  30. package/src/commands/clean.test.ts +670 -0
  31. package/src/commands/clean.ts +618 -0
  32. package/src/commands/completions.test.ts +342 -0
  33. package/src/commands/completions.ts +887 -0
  34. package/src/commands/coordinator.test.ts +1530 -0
  35. package/src/commands/coordinator.ts +733 -0
  36. package/src/commands/costs.test.ts +1119 -0
  37. package/src/commands/costs.ts +564 -0
  38. package/src/commands/dashboard.test.ts +308 -0
  39. package/src/commands/dashboard.ts +838 -0
  40. package/src/commands/doctor.test.ts +294 -0
  41. package/src/commands/doctor.ts +213 -0
  42. package/src/commands/errors.test.ts +647 -0
  43. package/src/commands/errors.ts +248 -0
  44. package/src/commands/feed.test.ts +578 -0
  45. package/src/commands/feed.ts +361 -0
  46. package/src/commands/group.test.ts +262 -0
  47. package/src/commands/group.ts +511 -0
  48. package/src/commands/hooks.test.ts +458 -0
  49. package/src/commands/hooks.ts +253 -0
  50. package/src/commands/init.test.ts +347 -0
  51. package/src/commands/init.ts +650 -0
  52. package/src/commands/inspect.test.ts +670 -0
  53. package/src/commands/inspect.ts +431 -0
  54. package/src/commands/log.test.ts +1454 -0
  55. package/src/commands/log.ts +724 -0
  56. package/src/commands/logs.test.ts +379 -0
  57. package/src/commands/logs.ts +546 -0
  58. package/src/commands/mail.test.ts +1270 -0
  59. package/src/commands/mail.ts +771 -0
  60. package/src/commands/merge.test.ts +670 -0
  61. package/src/commands/merge.ts +355 -0
  62. package/src/commands/metrics.test.ts +444 -0
  63. package/src/commands/metrics.ts +143 -0
  64. package/src/commands/monitor.test.ts +191 -0
  65. package/src/commands/monitor.ts +390 -0
  66. package/src/commands/nudge.test.ts +230 -0
  67. package/src/commands/nudge.ts +372 -0
  68. package/src/commands/prime.test.ts +470 -0
  69. package/src/commands/prime.ts +381 -0
  70. package/src/commands/replay.test.ts +741 -0
  71. package/src/commands/replay.ts +360 -0
  72. package/src/commands/run.test.ts +431 -0
  73. package/src/commands/run.ts +351 -0
  74. package/src/commands/sling.test.ts +657 -0
  75. package/src/commands/sling.ts +661 -0
  76. package/src/commands/spec.test.ts +203 -0
  77. package/src/commands/spec.ts +168 -0
  78. package/src/commands/status.test.ts +430 -0
  79. package/src/commands/status.ts +398 -0
  80. package/src/commands/stop.test.ts +420 -0
  81. package/src/commands/stop.ts +151 -0
  82. package/src/commands/supervisor.test.ts +187 -0
  83. package/src/commands/supervisor.ts +535 -0
  84. package/src/commands/trace.test.ts +745 -0
  85. package/src/commands/trace.ts +325 -0
  86. package/src/commands/watch.test.ts +145 -0
  87. package/src/commands/watch.ts +247 -0
  88. package/src/commands/worktree.test.ts +786 -0
  89. package/src/commands/worktree.ts +311 -0
  90. package/src/config.test.ts +822 -0
  91. package/src/config.ts +829 -0
  92. package/src/doctor/agents.test.ts +454 -0
  93. package/src/doctor/agents.ts +396 -0
  94. package/src/doctor/config-check.test.ts +190 -0
  95. package/src/doctor/config-check.ts +183 -0
  96. package/src/doctor/consistency.test.ts +651 -0
  97. package/src/doctor/consistency.ts +294 -0
  98. package/src/doctor/databases.test.ts +290 -0
  99. package/src/doctor/databases.ts +218 -0
  100. package/src/doctor/dependencies.test.ts +184 -0
  101. package/src/doctor/dependencies.ts +175 -0
  102. package/src/doctor/logs.test.ts +251 -0
  103. package/src/doctor/logs.ts +295 -0
  104. package/src/doctor/merge-queue.test.ts +216 -0
  105. package/src/doctor/merge-queue.ts +144 -0
  106. package/src/doctor/structure.test.ts +291 -0
  107. package/src/doctor/structure.ts +198 -0
  108. package/src/doctor/types.ts +37 -0
  109. package/src/doctor/version.test.ts +136 -0
  110. package/src/doctor/version.ts +129 -0
  111. package/src/e2e/init-sling-lifecycle.test.ts +277 -0
  112. package/src/errors.ts +217 -0
  113. package/src/events/store.test.ts +660 -0
  114. package/src/events/store.ts +369 -0
  115. package/src/events/tool-filter.test.ts +330 -0
  116. package/src/events/tool-filter.ts +126 -0
  117. package/src/index.ts +316 -0
  118. package/src/insights/analyzer.test.ts +466 -0
  119. package/src/insights/analyzer.ts +203 -0
  120. package/src/logging/color.test.ts +142 -0
  121. package/src/logging/color.ts +71 -0
  122. package/src/logging/logger.test.ts +813 -0
  123. package/src/logging/logger.ts +266 -0
  124. package/src/logging/reporter.test.ts +259 -0
  125. package/src/logging/reporter.ts +109 -0
  126. package/src/logging/sanitizer.test.ts +190 -0
  127. package/src/logging/sanitizer.ts +57 -0
  128. package/src/mail/broadcast.test.ts +203 -0
  129. package/src/mail/broadcast.ts +92 -0
  130. package/src/mail/client.test.ts +773 -0
  131. package/src/mail/client.ts +223 -0
  132. package/src/mail/store.test.ts +705 -0
  133. package/src/mail/store.ts +387 -0
  134. package/src/merge/queue.test.ts +359 -0
  135. package/src/merge/queue.ts +231 -0
  136. package/src/merge/resolver.test.ts +1345 -0
  137. package/src/merge/resolver.ts +645 -0
  138. package/src/metrics/store.test.ts +667 -0
  139. package/src/metrics/store.ts +445 -0
  140. package/src/metrics/summary.test.ts +398 -0
  141. package/src/metrics/summary.ts +178 -0
  142. package/src/metrics/transcript.test.ts +356 -0
  143. package/src/metrics/transcript.ts +175 -0
  144. package/src/mulch/client.test.ts +671 -0
  145. package/src/mulch/client.ts +332 -0
  146. package/src/sessions/compat.test.ts +280 -0
  147. package/src/sessions/compat.ts +104 -0
  148. package/src/sessions/store.test.ts +873 -0
  149. package/src/sessions/store.ts +494 -0
  150. package/src/test-helpers.test.ts +124 -0
  151. package/src/test-helpers.ts +126 -0
  152. package/src/tracker/beads.ts +56 -0
  153. package/src/tracker/factory.test.ts +80 -0
  154. package/src/tracker/factory.ts +64 -0
  155. package/src/tracker/seeds.ts +182 -0
  156. package/src/tracker/types.ts +52 -0
  157. package/src/types.ts +724 -0
  158. package/src/watchdog/daemon.test.ts +1975 -0
  159. package/src/watchdog/daemon.ts +671 -0
  160. package/src/watchdog/health.test.ts +431 -0
  161. package/src/watchdog/health.ts +264 -0
  162. package/src/watchdog/triage.test.ts +164 -0
  163. package/src/watchdog/triage.ts +179 -0
  164. package/src/worktree/manager.test.ts +439 -0
  165. package/src/worktree/manager.ts +198 -0
  166. package/src/worktree/tmux.test.ts +1009 -0
  167. package/src/worktree/tmux.ts +509 -0
  168. package/templates/CLAUDE.md.tmpl +89 -0
  169. package/templates/hooks.json.tmpl +105 -0
  170. package/templates/overlay.md.tmpl +81 -0
@@ -0,0 +1,660 @@
1
+ /**
2
+ * Tests for EventStore (SQLite-backed event observability storage).
3
+ *
4
+ * Uses real bun:sqlite with :memory: databases. No mocks.
5
+ * Philosophy: "never mock what you can use for real".
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
+ import type { EventStore, InsertEvent, StoredEvent, ToolStats } from "../types.ts";
10
+ import { createEventStore } from "./store.ts";
11
+
12
+ let store: EventStore;
13
+
14
+ beforeEach(() => {
15
+ store = createEventStore(":memory:");
16
+ });
17
+
18
+ afterEach(() => {
19
+ store.close();
20
+ });
21
+
22
+ /** Helper to create an InsertEvent with sensible defaults. */
23
+ function makeEvent(overrides: Partial<InsertEvent> = {}): InsertEvent {
24
+ return {
25
+ runId: "run-001",
26
+ agentName: "builder-1",
27
+ sessionId: "sess-abc",
28
+ eventType: "tool_start",
29
+ toolName: "Read",
30
+ toolArgs: '{"file": "src/index.ts"}',
31
+ toolDurationMs: null,
32
+ level: "info",
33
+ data: null,
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ // === insert ===
39
+
40
+ describe("insert", () => {
41
+ test("inserts an event and returns the auto-generated id", () => {
42
+ const id = store.insert(makeEvent());
43
+ expect(id).toBe(1);
44
+ });
45
+
46
+ test("sequential inserts return incrementing ids", () => {
47
+ const id1 = store.insert(makeEvent());
48
+ const id2 = store.insert(makeEvent({ agentName: "builder-2" }));
49
+ const id3 = store.insert(makeEvent({ agentName: "builder-3" }));
50
+
51
+ expect(id1).toBe(1);
52
+ expect(id2).toBe(2);
53
+ expect(id3).toBe(3);
54
+ });
55
+
56
+ test("all fields roundtrip correctly", () => {
57
+ const event = makeEvent({
58
+ runId: "run-xyz",
59
+ agentName: "scout-1",
60
+ sessionId: "sess-999",
61
+ eventType: "session_start",
62
+ toolName: null,
63
+ toolArgs: null,
64
+ toolDurationMs: null,
65
+ level: "warn",
66
+ data: '{"reason": "something happened"}',
67
+ });
68
+
69
+ const id = store.insert(event);
70
+ const retrieved = store.getByAgent("scout-1");
71
+
72
+ expect(retrieved).toHaveLength(1);
73
+ const stored = retrieved[0] as StoredEvent;
74
+ expect(stored.id).toBe(id);
75
+ expect(stored.runId).toBe("run-xyz");
76
+ expect(stored.agentName).toBe("scout-1");
77
+ expect(stored.sessionId).toBe("sess-999");
78
+ expect(stored.eventType).toBe("session_start");
79
+ expect(stored.toolName).toBeNull();
80
+ expect(stored.toolArgs).toBeNull();
81
+ expect(stored.toolDurationMs).toBeNull();
82
+ expect(stored.level).toBe("warn");
83
+ expect(stored.data).toBe('{"reason": "something happened"}');
84
+ expect(stored.createdAt).toBeTruthy();
85
+ });
86
+
87
+ test("null fields stored and retrieved as null", () => {
88
+ const id = store.insert(
89
+ makeEvent({
90
+ runId: null,
91
+ sessionId: null,
92
+ toolName: null,
93
+ toolArgs: null,
94
+ toolDurationMs: null,
95
+ data: null,
96
+ }),
97
+ );
98
+
99
+ const events = store.getByAgent("builder-1");
100
+ expect(events).toHaveLength(1);
101
+ const stored = events[0] as StoredEvent;
102
+ expect(stored.id).toBe(id);
103
+ expect(stored.runId).toBeNull();
104
+ expect(stored.sessionId).toBeNull();
105
+ expect(stored.toolName).toBeNull();
106
+ expect(stored.toolArgs).toBeNull();
107
+ expect(stored.toolDurationMs).toBeNull();
108
+ expect(stored.data).toBeNull();
109
+ });
110
+
111
+ test("rejects invalid level at DB level", () => {
112
+ expect(() => store.insert(makeEvent({ level: "critical" as InsertEvent["level"] }))).toThrow();
113
+ });
114
+
115
+ test("createdAt is auto-populated by SQLite", () => {
116
+ store.insert(makeEvent());
117
+ const events = store.getByAgent("builder-1");
118
+ expect(events).toHaveLength(1);
119
+ const stored = events[0] as StoredEvent;
120
+ // Should be a valid ISO-ish timestamp
121
+ expect(stored.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
122
+ });
123
+ });
124
+
125
+ // === correlateToolEnd ===
126
+
127
+ describe("correlateToolEnd", () => {
128
+ test("finds matching tool_start and returns duration", () => {
129
+ store.insert(
130
+ makeEvent({
131
+ eventType: "tool_start",
132
+ toolName: "Bash",
133
+ toolDurationMs: null,
134
+ }),
135
+ );
136
+
137
+ // Small delay to ensure measurable duration
138
+ const result = store.correlateToolEnd("builder-1", "Bash");
139
+ expect(result).not.toBeNull();
140
+ expect(result?.startId).toBe(1);
141
+ expect(result?.durationMs).toBeGreaterThanOrEqual(0);
142
+ });
143
+
144
+ test("returns null when no matching tool_start exists", () => {
145
+ const result = store.correlateToolEnd("builder-1", "Bash");
146
+ expect(result).toBeNull();
147
+ });
148
+
149
+ test("returns null when tool_start is for a different agent", () => {
150
+ store.insert(
151
+ makeEvent({
152
+ agentName: "scout-1",
153
+ eventType: "tool_start",
154
+ toolName: "Bash",
155
+ }),
156
+ );
157
+
158
+ const result = store.correlateToolEnd("builder-1", "Bash");
159
+ expect(result).toBeNull();
160
+ });
161
+
162
+ test("returns null when tool_start is for a different tool", () => {
163
+ store.insert(
164
+ makeEvent({
165
+ eventType: "tool_start",
166
+ toolName: "Read",
167
+ }),
168
+ );
169
+
170
+ const result = store.correlateToolEnd("builder-1", "Bash");
171
+ expect(result).toBeNull();
172
+ });
173
+
174
+ test("does not match already-correlated tool_start (has duration)", () => {
175
+ store.insert(
176
+ makeEvent({
177
+ eventType: "tool_start",
178
+ toolName: "Bash",
179
+ toolDurationMs: 500, // already has duration
180
+ }),
181
+ );
182
+
183
+ const result = store.correlateToolEnd("builder-1", "Bash");
184
+ expect(result).toBeNull();
185
+ });
186
+
187
+ test("correlates with the most recent unmatched tool_start", () => {
188
+ // Insert two tool_starts for the same tool
189
+ store.insert(
190
+ makeEvent({
191
+ eventType: "tool_start",
192
+ toolName: "Bash",
193
+ toolDurationMs: null,
194
+ }),
195
+ );
196
+ store.insert(
197
+ makeEvent({
198
+ eventType: "tool_start",
199
+ toolName: "Bash",
200
+ toolDurationMs: null,
201
+ }),
202
+ );
203
+
204
+ const result = store.correlateToolEnd("builder-1", "Bash");
205
+ expect(result).not.toBeNull();
206
+ // Should match the second (most recent) tool_start
207
+ expect(result?.startId).toBe(2);
208
+ });
209
+
210
+ test("updates tool_duration_ms on the start event after correlation", () => {
211
+ store.insert(
212
+ makeEvent({
213
+ eventType: "tool_start",
214
+ toolName: "Bash",
215
+ toolDurationMs: null,
216
+ }),
217
+ );
218
+
219
+ const result = store.correlateToolEnd("builder-1", "Bash");
220
+ expect(result).not.toBeNull();
221
+
222
+ // After correlation, the start event should have a duration
223
+ const events = store.getByAgent("builder-1");
224
+ expect(events).toHaveLength(1);
225
+ const updated = events[0] as StoredEvent;
226
+ expect(updated.toolDurationMs).toBeGreaterThanOrEqual(0);
227
+
228
+ // A second correlation should return null (already matched)
229
+ const secondResult = store.correlateToolEnd("builder-1", "Bash");
230
+ expect(secondResult).toBeNull();
231
+ });
232
+ });
233
+
234
+ // === getByAgent ===
235
+
236
+ describe("getByAgent", () => {
237
+ test("returns events for a specific agent", () => {
238
+ store.insert(makeEvent({ agentName: "builder-1" }));
239
+ store.insert(makeEvent({ agentName: "scout-1" }));
240
+ store.insert(makeEvent({ agentName: "builder-1", eventType: "tool_end" }));
241
+
242
+ const events = store.getByAgent("builder-1");
243
+ expect(events).toHaveLength(2);
244
+ for (const e of events) {
245
+ expect(e.agentName).toBe("builder-1");
246
+ }
247
+ });
248
+
249
+ test("returns events in chronological order (ASC)", () => {
250
+ store.insert(makeEvent({ agentName: "builder-1", eventType: "session_start" }));
251
+ store.insert(makeEvent({ agentName: "builder-1", eventType: "tool_start" }));
252
+ store.insert(makeEvent({ agentName: "builder-1", eventType: "session_end" }));
253
+
254
+ const events = store.getByAgent("builder-1");
255
+ expect(events).toHaveLength(3);
256
+ expect(events[0]?.eventType).toBe("session_start");
257
+ expect(events[1]?.eventType).toBe("tool_start");
258
+ expect(events[2]?.eventType).toBe("session_end");
259
+ });
260
+
261
+ test("returns empty array for unknown agent", () => {
262
+ store.insert(makeEvent({ agentName: "builder-1" }));
263
+ const events = store.getByAgent("unknown-agent");
264
+ expect(events).toEqual([]);
265
+ });
266
+
267
+ test("respects limit option", () => {
268
+ store.insert(makeEvent({ agentName: "builder-1", eventType: "tool_start" }));
269
+ store.insert(makeEvent({ agentName: "builder-1", eventType: "tool_end" }));
270
+ store.insert(makeEvent({ agentName: "builder-1", eventType: "session_end" }));
271
+
272
+ const events = store.getByAgent("builder-1", { limit: 2 });
273
+ expect(events).toHaveLength(2);
274
+ });
275
+
276
+ test("respects level filter", () => {
277
+ store.insert(makeEvent({ agentName: "builder-1", level: "info" }));
278
+ store.insert(makeEvent({ agentName: "builder-1", level: "error" }));
279
+ store.insert(makeEvent({ agentName: "builder-1", level: "info" }));
280
+
281
+ const events = store.getByAgent("builder-1", { level: "error" });
282
+ expect(events).toHaveLength(1);
283
+ expect(events[0]?.level).toBe("error");
284
+ });
285
+ });
286
+
287
+ // === getByRun ===
288
+
289
+ describe("getByRun", () => {
290
+ test("returns events for a specific run", () => {
291
+ store.insert(makeEvent({ runId: "run-001" }));
292
+ store.insert(makeEvent({ runId: "run-002" }));
293
+ store.insert(makeEvent({ runId: "run-001", eventType: "session_end" }));
294
+
295
+ const events = store.getByRun("run-001");
296
+ expect(events).toHaveLength(2);
297
+ for (const e of events) {
298
+ expect(e.runId).toBe("run-001");
299
+ }
300
+ });
301
+
302
+ test("returns events in chronological order (ASC)", () => {
303
+ store.insert(makeEvent({ runId: "run-001", eventType: "session_start" }));
304
+ store.insert(makeEvent({ runId: "run-001", eventType: "tool_start" }));
305
+ store.insert(makeEvent({ runId: "run-001", eventType: "session_end" }));
306
+
307
+ const events = store.getByRun("run-001");
308
+ expect(events).toHaveLength(3);
309
+ expect(events[0]?.eventType).toBe("session_start");
310
+ expect(events[2]?.eventType).toBe("session_end");
311
+ });
312
+
313
+ test("returns empty array for unknown run", () => {
314
+ const events = store.getByRun("nonexistent-run");
315
+ expect(events).toEqual([]);
316
+ });
317
+
318
+ test("respects limit option", () => {
319
+ store.insert(makeEvent({ runId: "run-001" }));
320
+ store.insert(makeEvent({ runId: "run-001" }));
321
+ store.insert(makeEvent({ runId: "run-001" }));
322
+
323
+ const events = store.getByRun("run-001", { limit: 1 });
324
+ expect(events).toHaveLength(1);
325
+ });
326
+ });
327
+
328
+ // === getErrors ===
329
+
330
+ describe("getErrors", () => {
331
+ test("returns only error-level events", () => {
332
+ store.insert(makeEvent({ level: "info" }));
333
+ store.insert(makeEvent({ level: "error", eventType: "error", data: '{"msg": "fail1"}' }));
334
+ store.insert(makeEvent({ level: "warn" }));
335
+ store.insert(makeEvent({ level: "error", eventType: "error", data: '{"msg": "fail2"}' }));
336
+
337
+ const errors = store.getErrors();
338
+ expect(errors).toHaveLength(2);
339
+ for (const e of errors) {
340
+ expect(e.level).toBe("error");
341
+ }
342
+ });
343
+
344
+ test("returns errors in reverse chronological order (most recent first)", () => {
345
+ store.insert(
346
+ makeEvent({
347
+ level: "error",
348
+ agentName: "agent-a",
349
+ eventType: "error",
350
+ }),
351
+ );
352
+ store.insert(
353
+ makeEvent({
354
+ level: "error",
355
+ agentName: "agent-b",
356
+ eventType: "error",
357
+ }),
358
+ );
359
+
360
+ const errors = store.getErrors();
361
+ expect(errors).toHaveLength(2);
362
+ // Both are error level; verify they are returned (order depends on
363
+ // sub-millisecond timestamps which may tie, so just verify content)
364
+ const names = errors.map((e) => e.agentName).sort();
365
+ expect(names).toEqual(["agent-a", "agent-b"]);
366
+ });
367
+
368
+ test("returns empty array when no errors exist", () => {
369
+ store.insert(makeEvent({ level: "info" }));
370
+ store.insert(makeEvent({ level: "warn" }));
371
+
372
+ const errors = store.getErrors();
373
+ expect(errors).toEqual([]);
374
+ });
375
+
376
+ test("respects limit option", () => {
377
+ for (let i = 0; i < 5; i++) {
378
+ store.insert(makeEvent({ level: "error", eventType: "error" }));
379
+ }
380
+
381
+ const errors = store.getErrors({ limit: 3 });
382
+ expect(errors).toHaveLength(3);
383
+ });
384
+ });
385
+
386
+ // === getTimeline ===
387
+
388
+ describe("getTimeline", () => {
389
+ test("returns events since a given timestamp", () => {
390
+ // Insert events with default timestamps (all "now")
391
+ store.insert(makeEvent({ agentName: "builder-1" }));
392
+ store.insert(makeEvent({ agentName: "scout-1" }));
393
+
394
+ // Use a past timestamp to capture all events
395
+ const events = store.getTimeline({ since: "2020-01-01T00:00:00Z" });
396
+ expect(events).toHaveLength(2);
397
+ });
398
+
399
+ test("returns events in chronological order (ASC)", () => {
400
+ store.insert(makeEvent({ agentName: "builder-1", eventType: "session_start" }));
401
+ store.insert(makeEvent({ agentName: "builder-1", eventType: "tool_start" }));
402
+
403
+ const events = store.getTimeline({ since: "2020-01-01T00:00:00Z" });
404
+ expect(events).toHaveLength(2);
405
+ expect(events[0]?.eventType).toBe("session_start");
406
+ expect(events[1]?.eventType).toBe("tool_start");
407
+ });
408
+
409
+ test("respects limit option", () => {
410
+ for (let i = 0; i < 10; i++) {
411
+ store.insert(makeEvent());
412
+ }
413
+
414
+ const events = store.getTimeline({ since: "2020-01-01T00:00:00Z", limit: 5 });
415
+ expect(events).toHaveLength(5);
416
+ });
417
+
418
+ test("returns empty array when no events match the time range", () => {
419
+ store.insert(makeEvent());
420
+
421
+ // Use a future timestamp -- no events should match
422
+ const events = store.getTimeline({ since: "2099-01-01T00:00:00Z" });
423
+ expect(events).toEqual([]);
424
+ });
425
+
426
+ test("respects level filter", () => {
427
+ store.insert(makeEvent({ level: "info" }));
428
+ store.insert(makeEvent({ level: "error" }));
429
+ store.insert(makeEvent({ level: "info" }));
430
+
431
+ const events = store.getTimeline({
432
+ since: "2020-01-01T00:00:00Z",
433
+ level: "error",
434
+ });
435
+ expect(events).toHaveLength(1);
436
+ expect(events[0]?.level).toBe("error");
437
+ });
438
+ });
439
+
440
+ // === getToolStats ===
441
+
442
+ describe("getToolStats", () => {
443
+ test("aggregates tool usage counts", () => {
444
+ store.insert(makeEvent({ toolName: "Read", eventType: "tool_start" }));
445
+ store.insert(makeEvent({ toolName: "Read", eventType: "tool_start" }));
446
+ store.insert(makeEvent({ toolName: "Bash", eventType: "tool_start" }));
447
+
448
+ const stats = store.getToolStats();
449
+ expect(stats).toHaveLength(2);
450
+
451
+ const readStats = stats.find((s) => s.toolName === "Read");
452
+ const bashStats = stats.find((s) => s.toolName === "Bash");
453
+
454
+ expect(readStats?.count).toBe(2);
455
+ expect(bashStats?.count).toBe(1);
456
+ });
457
+
458
+ test("computes average and max duration", () => {
459
+ store.insert(
460
+ makeEvent({
461
+ toolName: "Read",
462
+ eventType: "tool_start",
463
+ toolDurationMs: 100,
464
+ }),
465
+ );
466
+ store.insert(
467
+ makeEvent({
468
+ toolName: "Read",
469
+ eventType: "tool_start",
470
+ toolDurationMs: 300,
471
+ }),
472
+ );
473
+
474
+ const stats = store.getToolStats();
475
+ expect(stats).toHaveLength(1);
476
+
477
+ const readStats = stats[0] as ToolStats;
478
+ expect(readStats.toolName).toBe("Read");
479
+ expect(readStats.count).toBe(2);
480
+ expect(readStats.avgDurationMs).toBe(200);
481
+ expect(readStats.maxDurationMs).toBe(300);
482
+ });
483
+
484
+ test("returns stats ordered by count DESC", () => {
485
+ store.insert(makeEvent({ toolName: "Bash", eventType: "tool_start" }));
486
+ store.insert(makeEvent({ toolName: "Read", eventType: "tool_start" }));
487
+ store.insert(makeEvent({ toolName: "Read", eventType: "tool_start" }));
488
+ store.insert(makeEvent({ toolName: "Read", eventType: "tool_start" }));
489
+ store.insert(makeEvent({ toolName: "Bash", eventType: "tool_start" }));
490
+
491
+ const stats = store.getToolStats();
492
+ expect(stats).toHaveLength(2);
493
+ expect(stats[0]?.toolName).toBe("Read"); // 3 uses
494
+ expect(stats[1]?.toolName).toBe("Bash"); // 2 uses
495
+ });
496
+
497
+ test("filters by agent name", () => {
498
+ store.insert(makeEvent({ agentName: "builder-1", toolName: "Read", eventType: "tool_start" }));
499
+ store.insert(makeEvent({ agentName: "scout-1", toolName: "Read", eventType: "tool_start" }));
500
+ store.insert(makeEvent({ agentName: "builder-1", toolName: "Bash", eventType: "tool_start" }));
501
+
502
+ const stats = store.getToolStats({ agentName: "builder-1" });
503
+ expect(stats).toHaveLength(2);
504
+ // Only builder-1's tools
505
+ const total = stats.reduce((sum, s) => sum + s.count, 0);
506
+ expect(total).toBe(2);
507
+ });
508
+
509
+ test("returns empty array when no tool events exist", () => {
510
+ store.insert(makeEvent({ toolName: null, eventType: "session_start" }));
511
+ const stats = store.getToolStats();
512
+ expect(stats).toEqual([]);
513
+ });
514
+
515
+ test("only counts tool_start events (not tool_end)", () => {
516
+ store.insert(makeEvent({ toolName: "Read", eventType: "tool_start" }));
517
+ store.insert(makeEvent({ toolName: "Read", eventType: "tool_end" }));
518
+
519
+ const stats = store.getToolStats();
520
+ expect(stats).toHaveLength(1);
521
+ expect(stats[0]?.count).toBe(1);
522
+ });
523
+
524
+ test("handles null toolDurationMs in averages", () => {
525
+ store.insert(
526
+ makeEvent({
527
+ toolName: "Read",
528
+ eventType: "tool_start",
529
+ toolDurationMs: null,
530
+ }),
531
+ );
532
+ store.insert(
533
+ makeEvent({
534
+ toolName: "Read",
535
+ eventType: "tool_start",
536
+ toolDurationMs: 200,
537
+ }),
538
+ );
539
+
540
+ const stats = store.getToolStats();
541
+ expect(stats).toHaveLength(1);
542
+ // AVG of (NULL, 200) -- SQLite AVG ignores NULL, so result is 200
543
+ expect(stats[0]?.avgDurationMs).toBe(200);
544
+ });
545
+ });
546
+
547
+ // === purge ===
548
+
549
+ describe("purge", () => {
550
+ test("purge all deletes everything and returns count", () => {
551
+ store.insert(makeEvent({ agentName: "builder-1" }));
552
+ store.insert(makeEvent({ agentName: "scout-1" }));
553
+ store.insert(makeEvent({ agentName: "builder-2" }));
554
+
555
+ const count = store.purge({ all: true });
556
+ expect(count).toBe(3);
557
+
558
+ const remaining = store.getByAgent("builder-1");
559
+ expect(remaining).toEqual([]);
560
+ });
561
+
562
+ test("purge by agent name deletes only that agent's events", () => {
563
+ store.insert(makeEvent({ agentName: "builder-1" }));
564
+ store.insert(makeEvent({ agentName: "scout-1" }));
565
+ store.insert(makeEvent({ agentName: "builder-1", eventType: "tool_end" }));
566
+
567
+ const count = store.purge({ agentName: "builder-1" });
568
+ expect(count).toBe(2);
569
+
570
+ const remaining = store.getByAgent("scout-1");
571
+ expect(remaining).toHaveLength(1);
572
+ });
573
+
574
+ test("purge on empty DB returns 0", () => {
575
+ const count = store.purge({ all: true });
576
+ expect(count).toBe(0);
577
+ });
578
+
579
+ test("purge with no options returns 0 without deleting", () => {
580
+ store.insert(makeEvent());
581
+ const count = store.purge({});
582
+ expect(count).toBe(0);
583
+
584
+ const events = store.getByAgent("builder-1");
585
+ expect(events).toHaveLength(1);
586
+ });
587
+
588
+ test("purge by olderThanMs deletes old events", () => {
589
+ // Insert an event (created_at is "now")
590
+ store.insert(makeEvent({ agentName: "builder-1" }));
591
+
592
+ // Purging events older than 1 hour should delete nothing (events are fresh)
593
+ const count = store.purge({ olderThanMs: 3_600_000 });
594
+ expect(count).toBe(0);
595
+
596
+ const events = store.getByAgent("builder-1");
597
+ expect(events).toHaveLength(1);
598
+ });
599
+
600
+ test("purge combines agentName and olderThanMs", () => {
601
+ store.insert(makeEvent({ agentName: "builder-1" }));
602
+ store.insert(makeEvent({ agentName: "scout-1" }));
603
+
604
+ // Both agents' events are fresh, so nothing should be deleted
605
+ const count = store.purge({ agentName: "builder-1", olderThanMs: 3_600_000 });
606
+ expect(count).toBe(0);
607
+ });
608
+ });
609
+
610
+ // === close ===
611
+
612
+ describe("close", () => {
613
+ test("calling close does not throw", () => {
614
+ expect(() => store.close()).not.toThrow();
615
+ });
616
+ });
617
+
618
+ // === CHECK constraints ===
619
+
620
+ describe("CHECK constraints", () => {
621
+ test("accepts all valid level values", () => {
622
+ const levels: InsertEvent["level"][] = ["debug", "info", "warn", "error"];
623
+ for (const level of levels) {
624
+ const id = store.insert(makeEvent({ level }));
625
+ expect(id).toBeGreaterThan(0);
626
+ }
627
+ });
628
+
629
+ test("rejects invalid level value", () => {
630
+ expect(() => store.insert(makeEvent({ level: "fatal" as InsertEvent["level"] }))).toThrow();
631
+ });
632
+ });
633
+
634
+ // === concurrent access ===
635
+
636
+ describe("concurrent access", () => {
637
+ test("second store instance can read events written by first", () => {
638
+ // Use a temp file for this test since :memory: databases are isolated
639
+ const { mkdtempSync } = require("node:fs");
640
+ const { tmpdir } = require("node:os");
641
+ const { join } = require("node:path");
642
+ const { rmSync } = require("node:fs");
643
+
644
+ const tempDir = mkdtempSync(join(tmpdir(), "overstory-events-test-"));
645
+ const dbPath = join(tempDir, "events.db");
646
+
647
+ const store1 = createEventStore(dbPath);
648
+ const store2 = createEventStore(dbPath);
649
+
650
+ store1.insert(makeEvent({ agentName: "builder-1" }));
651
+
652
+ const events = store2.getByAgent("builder-1");
653
+ expect(events).toHaveLength(1);
654
+ expect(events[0]?.agentName).toBe("builder-1");
655
+
656
+ store1.close();
657
+ store2.close();
658
+ rmSync(tempDir, { recursive: true, force: true });
659
+ });
660
+ });