@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,873 @@
1
+ /**
2
+ * Tests for SessionStore (SQLite-backed agent session tracking).
3
+ *
4
+ * Uses real bun:sqlite with temp files. No mocks.
5
+ * Temp files (not :memory:) because file-based migration must be tested.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
+ import { mkdtemp, rm } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import type { AgentSession, AgentState, InsertRun, Run, RunStore } from "../types.ts";
13
+ import { createRunStore, createSessionStore, type SessionStore } from "./store.ts";
14
+
15
+ let tempDir: string;
16
+ let dbPath: string;
17
+ let store: SessionStore;
18
+
19
+ beforeEach(async () => {
20
+ tempDir = await mkdtemp(join(tmpdir(), "overstory-sessions-test-"));
21
+ dbPath = join(tempDir, "sessions.db");
22
+ store = createSessionStore(dbPath);
23
+ });
24
+
25
+ afterEach(async () => {
26
+ store.close();
27
+ await rm(tempDir, { recursive: true, force: true });
28
+ });
29
+
30
+ /** Helper to create an AgentSession with optional overrides. */
31
+ function makeSession(overrides: Partial<AgentSession> = {}): AgentSession {
32
+ return {
33
+ id: "session-001-test-agent",
34
+ agentName: "test-agent",
35
+ capability: "builder",
36
+ worktreePath: "/tmp/worktrees/test-agent",
37
+ branchName: "overstory/test-agent/task-1",
38
+ beadId: "task-1",
39
+ tmuxSession: "overstory-test-agent",
40
+ state: "booting",
41
+ pid: 12345,
42
+ parentAgent: null,
43
+ depth: 0,
44
+ runId: null,
45
+ startedAt: "2026-01-15T10:00:00.000Z",
46
+ lastActivity: "2026-01-15T10:00:00.000Z",
47
+ escalationLevel: 0,
48
+ stalledSince: null,
49
+ ...overrides,
50
+ };
51
+ }
52
+
53
+ // === upsert ===
54
+
55
+ describe("upsert", () => {
56
+ test("inserts a new session", () => {
57
+ const session = makeSession();
58
+ store.upsert(session);
59
+
60
+ const result = store.getByName("test-agent");
61
+ expect(result).not.toBeNull();
62
+ expect(result).toEqual(session);
63
+ });
64
+
65
+ test("updates an existing session with the same agent name", () => {
66
+ store.upsert(makeSession({ state: "booting" }));
67
+ store.upsert(makeSession({ id: "session-002-test-agent", state: "working" }));
68
+
69
+ const all = store.getAll();
70
+ expect(all).toHaveLength(1);
71
+
72
+ const result = store.getByName("test-agent");
73
+ expect(result?.state).toBe("working");
74
+ expect(result?.id).toBe("session-002-test-agent");
75
+ });
76
+
77
+ test("all fields roundtrip correctly (camelCase TS -> snake_case SQLite -> camelCase TS)", () => {
78
+ const session = makeSession({
79
+ id: "session-full-roundtrip",
80
+ agentName: "roundtrip-agent",
81
+ capability: "scout",
82
+ worktreePath: "/tmp/worktrees/roundtrip",
83
+ branchName: "overstory/roundtrip-agent/task-42",
84
+ beadId: "task-42",
85
+ tmuxSession: "overstory-roundtrip-agent",
86
+ state: "working",
87
+ pid: 99999,
88
+ parentAgent: "lead-agent",
89
+ depth: 2,
90
+ runId: "run-abc-123",
91
+ startedAt: "2026-02-01T08:30:00.000Z",
92
+ lastActivity: "2026-02-01T09:00:00.000Z",
93
+ escalationLevel: 2,
94
+ stalledSince: "2026-02-01T08:50:00.000Z",
95
+ });
96
+
97
+ store.upsert(session);
98
+ const result = store.getByName("roundtrip-agent");
99
+ expect(result).toEqual(session);
100
+ });
101
+
102
+ test("handles null pid", () => {
103
+ const session = makeSession({ pid: null });
104
+ store.upsert(session);
105
+
106
+ const result = store.getByName("test-agent");
107
+ expect(result?.pid).toBeNull();
108
+ });
109
+
110
+ test("handles null parentAgent", () => {
111
+ const session = makeSession({ parentAgent: null });
112
+ store.upsert(session);
113
+
114
+ const result = store.getByName("test-agent");
115
+ expect(result?.parentAgent).toBeNull();
116
+ });
117
+
118
+ test("handles null runId", () => {
119
+ const session = makeSession({ runId: null });
120
+ store.upsert(session);
121
+
122
+ const result = store.getByName("test-agent");
123
+ expect(result?.runId).toBeNull();
124
+ });
125
+
126
+ test("handles null stalledSince", () => {
127
+ const session = makeSession({ stalledSince: null });
128
+ store.upsert(session);
129
+
130
+ const result = store.getByName("test-agent");
131
+ expect(result?.stalledSince).toBeNull();
132
+ });
133
+
134
+ test("rejects invalid state values via CHECK constraint", () => {
135
+ const session = makeSession();
136
+ // Force an invalid state to test the CHECK constraint
137
+ const badSession = { ...session, state: "invalid" as AgentState };
138
+ expect(() => store.upsert(badSession)).toThrow();
139
+ });
140
+ });
141
+
142
+ // === getByName ===
143
+
144
+ describe("getByName", () => {
145
+ test("returns null for nonexistent agent", () => {
146
+ const result = store.getByName("nonexistent");
147
+ expect(result).toBeNull();
148
+ });
149
+
150
+ test("returns the correct session when multiple exist", () => {
151
+ store.upsert(makeSession({ agentName: "agent-a", id: "s-a" }));
152
+ store.upsert(makeSession({ agentName: "agent-b", id: "s-b" }));
153
+ store.upsert(makeSession({ agentName: "agent-c", id: "s-c" }));
154
+
155
+ const result = store.getByName("agent-b");
156
+ expect(result?.id).toBe("s-b");
157
+ expect(result?.agentName).toBe("agent-b");
158
+ });
159
+ });
160
+
161
+ // === getActive ===
162
+
163
+ describe("getActive", () => {
164
+ test("returns empty array when no sessions exist", () => {
165
+ const result = store.getActive();
166
+ expect(result).toEqual([]);
167
+ });
168
+
169
+ test("returns booting, working, and stalled sessions", () => {
170
+ store.upsert(makeSession({ agentName: "booting-1", id: "s-1", state: "booting" }));
171
+ store.upsert(makeSession({ agentName: "working-1", id: "s-2", state: "working" }));
172
+ store.upsert(makeSession({ agentName: "stalled-1", id: "s-3", state: "stalled" }));
173
+
174
+ const result = store.getActive();
175
+ expect(result).toHaveLength(3);
176
+
177
+ const states = result.map((s) => s.state);
178
+ expect(states).toContain("booting");
179
+ expect(states).toContain("working");
180
+ expect(states).toContain("stalled");
181
+ });
182
+
183
+ test("excludes completed and zombie sessions", () => {
184
+ store.upsert(makeSession({ agentName: "working-1", id: "s-1", state: "working" }));
185
+ store.upsert(makeSession({ agentName: "completed-1", id: "s-2", state: "completed" }));
186
+ store.upsert(makeSession({ agentName: "zombie-1", id: "s-3", state: "zombie" }));
187
+
188
+ const result = store.getActive();
189
+ expect(result).toHaveLength(1);
190
+ expect(result[0]?.agentName).toBe("working-1");
191
+ });
192
+
193
+ test("results are ordered by started_at ascending", () => {
194
+ store.upsert(
195
+ makeSession({
196
+ agentName: "late",
197
+ id: "s-2",
198
+ state: "working",
199
+ startedAt: "2026-01-15T12:00:00.000Z",
200
+ }),
201
+ );
202
+ store.upsert(
203
+ makeSession({
204
+ agentName: "early",
205
+ id: "s-1",
206
+ state: "working",
207
+ startedAt: "2026-01-15T10:00:00.000Z",
208
+ }),
209
+ );
210
+
211
+ const result = store.getActive();
212
+ expect(result[0]?.agentName).toBe("early");
213
+ expect(result[1]?.agentName).toBe("late");
214
+ });
215
+ });
216
+
217
+ // === getAll ===
218
+
219
+ describe("getAll", () => {
220
+ test("returns empty array when no sessions exist", () => {
221
+ expect(store.getAll()).toEqual([]);
222
+ });
223
+
224
+ test("returns all sessions regardless of state", () => {
225
+ store.upsert(makeSession({ agentName: "a1", id: "s-1", state: "booting" }));
226
+ store.upsert(makeSession({ agentName: "a2", id: "s-2", state: "completed" }));
227
+ store.upsert(makeSession({ agentName: "a3", id: "s-3", state: "zombie" }));
228
+
229
+ const result = store.getAll();
230
+ expect(result).toHaveLength(3);
231
+ });
232
+ });
233
+
234
+ // === getByRun ===
235
+
236
+ describe("getByRun", () => {
237
+ test("returns empty array for unknown run", () => {
238
+ expect(store.getByRun("nonexistent-run")).toEqual([]);
239
+ });
240
+
241
+ test("returns only sessions with matching runId", () => {
242
+ store.upsert(makeSession({ agentName: "a1", id: "s-1", runId: "run-1" }));
243
+ store.upsert(makeSession({ agentName: "a2", id: "s-2", runId: "run-1" }));
244
+ store.upsert(makeSession({ agentName: "a3", id: "s-3", runId: "run-2" }));
245
+ store.upsert(makeSession({ agentName: "a4", id: "s-4", runId: null }));
246
+
247
+ const result = store.getByRun("run-1");
248
+ expect(result).toHaveLength(2);
249
+ expect(result.map((s) => s.agentName).sort()).toEqual(["a1", "a2"]);
250
+ });
251
+ });
252
+
253
+ // === updateState ===
254
+
255
+ describe("updateState", () => {
256
+ test("updates state of an existing session", () => {
257
+ store.upsert(makeSession({ state: "booting" }));
258
+
259
+ store.updateState("test-agent", "working");
260
+
261
+ const result = store.getByName("test-agent");
262
+ expect(result?.state).toBe("working");
263
+ });
264
+
265
+ test("is a no-op for nonexistent agent (does not throw)", () => {
266
+ // Should not throw
267
+ store.updateState("nonexistent", "completed");
268
+ });
269
+
270
+ test("rejects invalid state via CHECK constraint", () => {
271
+ store.upsert(makeSession());
272
+ expect(() => store.updateState("test-agent", "invalid" as AgentState)).toThrow();
273
+ });
274
+ });
275
+
276
+ // === updateLastActivity ===
277
+
278
+ describe("updateLastActivity", () => {
279
+ test("updates lastActivity to a recent ISO timestamp", () => {
280
+ const oldTime = "2026-01-01T00:00:00.000Z";
281
+ store.upsert(makeSession({ lastActivity: oldTime }));
282
+
283
+ const before = new Date().toISOString();
284
+ store.updateLastActivity("test-agent");
285
+ const after = new Date().toISOString();
286
+
287
+ const result = store.getByName("test-agent");
288
+ expect(result).not.toBeNull();
289
+ const updatedActivity = result?.lastActivity ?? "";
290
+ // The updated timestamp should be between before and after
291
+ expect(updatedActivity >= before).toBe(true);
292
+ expect(updatedActivity <= after).toBe(true);
293
+ });
294
+
295
+ test("does not modify other fields", () => {
296
+ const original = makeSession({ state: "working", escalationLevel: 2 });
297
+ store.upsert(original);
298
+
299
+ store.updateLastActivity("test-agent");
300
+
301
+ const result = store.getByName("test-agent");
302
+ expect(result?.state).toBe("working");
303
+ expect(result?.escalationLevel).toBe(2);
304
+ expect(result?.id).toBe(original.id);
305
+ });
306
+ });
307
+
308
+ // === updateEscalation ===
309
+
310
+ describe("updateEscalation", () => {
311
+ test("updates escalation level and stalled timestamp", () => {
312
+ store.upsert(makeSession({ escalationLevel: 0, stalledSince: null }));
313
+
314
+ const stalledTime = "2026-01-15T10:30:00.000Z";
315
+ store.updateEscalation("test-agent", 2, stalledTime);
316
+
317
+ const result = store.getByName("test-agent");
318
+ expect(result?.escalationLevel).toBe(2);
319
+ expect(result?.stalledSince).toBe(stalledTime);
320
+ });
321
+
322
+ test("can clear stalledSince by passing null", () => {
323
+ const stalledTime = "2026-01-15T10:30:00.000Z";
324
+ store.upsert(makeSession({ escalationLevel: 2, stalledSince: stalledTime }));
325
+
326
+ store.updateEscalation("test-agent", 0, null);
327
+
328
+ const result = store.getByName("test-agent");
329
+ expect(result?.escalationLevel).toBe(0);
330
+ expect(result?.stalledSince).toBeNull();
331
+ });
332
+ });
333
+
334
+ // === remove ===
335
+
336
+ describe("remove", () => {
337
+ test("removes an existing session", () => {
338
+ store.upsert(makeSession());
339
+
340
+ store.remove("test-agent");
341
+
342
+ const result = store.getByName("test-agent");
343
+ expect(result).toBeNull();
344
+ });
345
+
346
+ test("is a no-op for nonexistent agent", () => {
347
+ // Should not throw
348
+ store.remove("nonexistent");
349
+ expect(store.getAll()).toEqual([]);
350
+ });
351
+
352
+ test("does not affect other sessions", () => {
353
+ store.upsert(makeSession({ agentName: "keep-me", id: "s-1" }));
354
+ store.upsert(makeSession({ agentName: "remove-me", id: "s-2" }));
355
+
356
+ store.remove("remove-me");
357
+
358
+ expect(store.getAll()).toHaveLength(1);
359
+ expect(store.getByName("keep-me")).not.toBeNull();
360
+ });
361
+ });
362
+
363
+ // === purge ===
364
+
365
+ describe("purge", () => {
366
+ test("purge({ all: true }) removes all sessions and returns count", () => {
367
+ store.upsert(makeSession({ agentName: "a1", id: "s-1" }));
368
+ store.upsert(makeSession({ agentName: "a2", id: "s-2" }));
369
+ store.upsert(makeSession({ agentName: "a3", id: "s-3" }));
370
+
371
+ const count = store.purge({ all: true });
372
+ expect(count).toBe(3);
373
+ expect(store.getAll()).toEqual([]);
374
+ });
375
+
376
+ test("purge({ state }) removes only sessions with that state", () => {
377
+ store.upsert(makeSession({ agentName: "a1", id: "s-1", state: "completed" }));
378
+ store.upsert(makeSession({ agentName: "a2", id: "s-2", state: "working" }));
379
+ store.upsert(makeSession({ agentName: "a3", id: "s-3", state: "completed" }));
380
+
381
+ const count = store.purge({ state: "completed" });
382
+ expect(count).toBe(2);
383
+ expect(store.getAll()).toHaveLength(1);
384
+ expect(store.getByName("a2")?.state).toBe("working");
385
+ });
386
+
387
+ test("purge({ agent }) removes only the specified agent", () => {
388
+ store.upsert(makeSession({ agentName: "target", id: "s-1" }));
389
+ store.upsert(makeSession({ agentName: "bystander", id: "s-2" }));
390
+
391
+ const count = store.purge({ agent: "target" });
392
+ expect(count).toBe(1);
393
+ expect(store.getByName("target")).toBeNull();
394
+ expect(store.getByName("bystander")).not.toBeNull();
395
+ });
396
+
397
+ test("purge({ state, agent }) combines filters with AND", () => {
398
+ store.upsert(makeSession({ agentName: "a1", id: "s-1", state: "completed" }));
399
+ store.upsert(makeSession({ agentName: "a2", id: "s-2", state: "working" }));
400
+
401
+ // Only purge if agent is "a1" AND state is "completed"
402
+ const count = store.purge({ state: "completed", agent: "a1" });
403
+ expect(count).toBe(1);
404
+ expect(store.getAll()).toHaveLength(1);
405
+ });
406
+
407
+ test("purge with no matching criteria returns 0", () => {
408
+ store.upsert(makeSession());
409
+ const count = store.purge({});
410
+ expect(count).toBe(0);
411
+ expect(store.getAll()).toHaveLength(1);
412
+ });
413
+
414
+ test("purge({ all: true }) returns 0 on empty database", () => {
415
+ const count = store.purge({ all: true });
416
+ expect(count).toBe(0);
417
+ });
418
+ });
419
+
420
+ // === close ===
421
+
422
+ describe("close", () => {
423
+ test("close does not throw when called on open store", () => {
424
+ store.upsert(makeSession());
425
+ // Should not throw
426
+ expect(() => store.close()).not.toThrow();
427
+ });
428
+ });
429
+
430
+ // === concurrent access / file-based behavior ===
431
+
432
+ describe("file-based database", () => {
433
+ test("data persists across separate store instances", () => {
434
+ const session = makeSession();
435
+ store.upsert(session);
436
+ store.close();
437
+
438
+ // Open a new store on the same file
439
+ const store2 = createSessionStore(dbPath);
440
+ const result = store2.getByName("test-agent");
441
+ expect(result).toEqual(session);
442
+ store2.close();
443
+
444
+ // Re-assign store so afterEach cleanup does not double-close
445
+ store = createSessionStore(join(tempDir, "unused.db"));
446
+ });
447
+
448
+ test("schema is created idempotently (opening same db twice is safe)", () => {
449
+ store.upsert(makeSession());
450
+ store.close();
451
+
452
+ // Re-open -- should not fail even though table/indexes already exist
453
+ const store2 = createSessionStore(dbPath);
454
+ const result = store2.getAll();
455
+ expect(result).toHaveLength(1);
456
+ store2.close();
457
+
458
+ store = createSessionStore(join(tempDir, "unused.db"));
459
+ });
460
+ });
461
+
462
+ // === agent_name UNIQUE constraint ===
463
+
464
+ describe("agent_name uniqueness", () => {
465
+ test("UNIQUE constraint on agent_name allows upsert to work correctly", () => {
466
+ store.upsert(makeSession({ agentName: "unique-agent", id: "s-1", state: "booting" }));
467
+ store.upsert(makeSession({ agentName: "unique-agent", id: "s-2", state: "working" }));
468
+
469
+ const all = store.getAll();
470
+ expect(all).toHaveLength(1);
471
+ expect(all[0]?.id).toBe("s-2");
472
+ expect(all[0]?.state).toBe("working");
473
+ });
474
+ });
475
+
476
+ // === edge cases ===
477
+
478
+ describe("edge cases", () => {
479
+ test("handles many sessions efficiently", () => {
480
+ for (let i = 0; i < 100; i++) {
481
+ store.upsert(
482
+ makeSession({
483
+ agentName: `agent-${i}`,
484
+ id: `session-${i}`,
485
+ state: i % 3 === 0 ? "completed" : "working",
486
+ }),
487
+ );
488
+ }
489
+
490
+ const all = store.getAll();
491
+ expect(all).toHaveLength(100);
492
+
493
+ const active = store.getActive();
494
+ // 34 completed (0,3,6,...,99) + 66 working. Active = working only since no booting/stalled
495
+ expect(active.length).toBeGreaterThan(0);
496
+ expect(active.every((s) => s.state !== "completed" && s.state !== "zombie")).toBe(true);
497
+ });
498
+
499
+ test("special characters in agent name and worktree path", () => {
500
+ const session = makeSession({
501
+ agentName: "agent-with-special_chars.v2",
502
+ worktreePath: "/tmp/path with spaces/worktree",
503
+ branchName: "overstory/agent-with-special_chars.v2/task-1",
504
+ });
505
+
506
+ store.upsert(session);
507
+
508
+ const result = store.getByName("agent-with-special_chars.v2");
509
+ expect(result?.worktreePath).toBe("/tmp/path with spaces/worktree");
510
+ });
511
+
512
+ test("empty string fields are stored correctly", () => {
513
+ const session = makeSession({ beadId: "", capability: "builder" });
514
+ store.upsert(session);
515
+
516
+ const result = store.getByName("test-agent");
517
+ expect(result?.beadId).toBe("");
518
+ });
519
+ });
520
+
521
+ // ============================================================
522
+ // RunStore Tests
523
+ // ============================================================
524
+
525
+ describe("RunStore", () => {
526
+ let runStore: RunStore;
527
+
528
+ beforeEach(async () => {
529
+ // Reuse the same dbPath so RunStore shares sessions.db with SessionStore
530
+ runStore = createRunStore(dbPath);
531
+ });
532
+
533
+ afterEach(() => {
534
+ runStore.close();
535
+ });
536
+
537
+ /** Helper to create an InsertRun with optional overrides. */
538
+ function makeRun(overrides: Partial<InsertRun> = {}): InsertRun {
539
+ return {
540
+ id: "run-2026-02-13T10:00:00.000Z",
541
+ startedAt: "2026-02-13T10:00:00.000Z",
542
+ coordinatorSessionId: "coord-session-001",
543
+ status: "active",
544
+ ...overrides,
545
+ };
546
+ }
547
+
548
+ // === createRun + getRun ===
549
+
550
+ describe("createRun and getRun", () => {
551
+ test("creates and retrieves a run", () => {
552
+ runStore.createRun(makeRun());
553
+
554
+ const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
555
+ expect(result).not.toBeNull();
556
+ expect(result?.id).toBe("run-2026-02-13T10:00:00.000Z");
557
+ expect(result?.startedAt).toBe("2026-02-13T10:00:00.000Z");
558
+ expect(result?.completedAt).toBeNull();
559
+ expect(result?.agentCount).toBe(0);
560
+ expect(result?.coordinatorSessionId).toBe("coord-session-001");
561
+ expect(result?.status).toBe("active");
562
+ });
563
+
564
+ test("returns null for nonexistent run", () => {
565
+ const result = runStore.getRun("nonexistent-run");
566
+ expect(result).toBeNull();
567
+ });
568
+
569
+ test("creates a run with explicit agentCount", () => {
570
+ runStore.createRun(makeRun({ agentCount: 5 }));
571
+
572
+ const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
573
+ expect(result?.agentCount).toBe(5);
574
+ });
575
+
576
+ test("creates a run with null coordinatorSessionId", () => {
577
+ runStore.createRun(makeRun({ coordinatorSessionId: null }));
578
+
579
+ const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
580
+ expect(result?.coordinatorSessionId).toBeNull();
581
+ });
582
+
583
+ test("rejects invalid status via CHECK constraint", () => {
584
+ const badRun = makeRun({ status: "invalid" as Run["status"] });
585
+ expect(() => runStore.createRun(badRun)).toThrow();
586
+ });
587
+
588
+ test("rejects duplicate run IDs via PRIMARY KEY constraint", () => {
589
+ runStore.createRun(makeRun());
590
+ expect(() => runStore.createRun(makeRun())).toThrow();
591
+ });
592
+ });
593
+
594
+ // === getActiveRun ===
595
+
596
+ describe("getActiveRun", () => {
597
+ test("returns null when no runs exist", () => {
598
+ const result = runStore.getActiveRun();
599
+ expect(result).toBeNull();
600
+ });
601
+
602
+ test("returns the most recently started active run", () => {
603
+ runStore.createRun(
604
+ makeRun({
605
+ id: "run-early",
606
+ startedAt: "2026-02-13T08:00:00.000Z",
607
+ status: "active",
608
+ }),
609
+ );
610
+ runStore.createRun(
611
+ makeRun({
612
+ id: "run-late",
613
+ startedAt: "2026-02-13T12:00:00.000Z",
614
+ status: "active",
615
+ }),
616
+ );
617
+
618
+ const result = runStore.getActiveRun();
619
+ expect(result?.id).toBe("run-late");
620
+ });
621
+
622
+ test("ignores completed and failed runs", () => {
623
+ runStore.createRun(makeRun({ id: "run-completed", status: "active" }));
624
+ runStore.completeRun("run-completed", "completed");
625
+
626
+ runStore.createRun(
627
+ makeRun({
628
+ id: "run-failed",
629
+ startedAt: "2026-02-13T11:00:00.000Z",
630
+ status: "active",
631
+ }),
632
+ );
633
+ runStore.completeRun("run-failed", "failed");
634
+
635
+ const result = runStore.getActiveRun();
636
+ expect(result).toBeNull();
637
+ });
638
+ });
639
+
640
+ // === listRuns ===
641
+
642
+ describe("listRuns", () => {
643
+ test("returns empty array when no runs exist", () => {
644
+ const result = runStore.listRuns();
645
+ expect(result).toEqual([]);
646
+ });
647
+
648
+ test("returns all runs ordered by started_at descending", () => {
649
+ runStore.createRun(
650
+ makeRun({
651
+ id: "run-1",
652
+ startedAt: "2026-02-13T08:00:00.000Z",
653
+ }),
654
+ );
655
+ runStore.createRun(
656
+ makeRun({
657
+ id: "run-2",
658
+ startedAt: "2026-02-13T12:00:00.000Z",
659
+ }),
660
+ );
661
+ runStore.createRun(
662
+ makeRun({
663
+ id: "run-3",
664
+ startedAt: "2026-02-13T10:00:00.000Z",
665
+ }),
666
+ );
667
+
668
+ const result = runStore.listRuns();
669
+ expect(result).toHaveLength(3);
670
+ expect(result[0]?.id).toBe("run-2");
671
+ expect(result[1]?.id).toBe("run-3");
672
+ expect(result[2]?.id).toBe("run-1");
673
+ });
674
+
675
+ test("filters by status", () => {
676
+ runStore.createRun(makeRun({ id: "run-active", status: "active" }));
677
+ runStore.createRun(
678
+ makeRun({
679
+ id: "run-to-complete",
680
+ startedAt: "2026-02-13T11:00:00.000Z",
681
+ status: "active",
682
+ }),
683
+ );
684
+ runStore.completeRun("run-to-complete", "completed");
685
+
686
+ const activeRuns = runStore.listRuns({ status: "active" });
687
+ expect(activeRuns).toHaveLength(1);
688
+ expect(activeRuns[0]?.id).toBe("run-active");
689
+
690
+ const completedRuns = runStore.listRuns({ status: "completed" });
691
+ expect(completedRuns).toHaveLength(1);
692
+ expect(completedRuns[0]?.id).toBe("run-to-complete");
693
+ });
694
+
695
+ test("respects limit option", () => {
696
+ for (let i = 0; i < 5; i++) {
697
+ runStore.createRun(
698
+ makeRun({
699
+ id: `run-${i}`,
700
+ startedAt: `2026-02-13T${String(10 + i).padStart(2, "0")}:00:00.000Z`,
701
+ }),
702
+ );
703
+ }
704
+
705
+ const result = runStore.listRuns({ limit: 2 });
706
+ expect(result).toHaveLength(2);
707
+ });
708
+
709
+ test("combines status and limit filters", () => {
710
+ for (let i = 0; i < 5; i++) {
711
+ runStore.createRun(
712
+ makeRun({
713
+ id: `run-${i}`,
714
+ startedAt: `2026-02-13T${String(10 + i).padStart(2, "0")}:00:00.000Z`,
715
+ status: "active",
716
+ }),
717
+ );
718
+ }
719
+ runStore.completeRun("run-0", "completed");
720
+ runStore.completeRun("run-1", "completed");
721
+
722
+ const result = runStore.listRuns({ status: "active", limit: 2 });
723
+ expect(result).toHaveLength(2);
724
+ // All returned runs should be active
725
+ for (const run of result) {
726
+ expect(run.status).toBe("active");
727
+ }
728
+ });
729
+ });
730
+
731
+ // === incrementAgentCount ===
732
+
733
+ describe("incrementAgentCount", () => {
734
+ test("increments agent count by 1", () => {
735
+ runStore.createRun(makeRun());
736
+
737
+ runStore.incrementAgentCount("run-2026-02-13T10:00:00.000Z");
738
+ let result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
739
+ expect(result?.agentCount).toBe(1);
740
+
741
+ runStore.incrementAgentCount("run-2026-02-13T10:00:00.000Z");
742
+ result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
743
+ expect(result?.agentCount).toBe(2);
744
+ });
745
+
746
+ test("is a no-op for nonexistent run (does not throw)", () => {
747
+ // Should not throw
748
+ runStore.incrementAgentCount("nonexistent-run");
749
+ });
750
+
751
+ test("does not affect other run fields", () => {
752
+ runStore.createRun(makeRun());
753
+ runStore.incrementAgentCount("run-2026-02-13T10:00:00.000Z");
754
+
755
+ const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
756
+ expect(result?.status).toBe("active");
757
+ expect(result?.completedAt).toBeNull();
758
+ expect(result?.coordinatorSessionId).toBe("coord-session-001");
759
+ });
760
+ });
761
+
762
+ // === completeRun ===
763
+
764
+ describe("completeRun", () => {
765
+ test("sets status to completed and records completedAt", () => {
766
+ runStore.createRun(makeRun());
767
+
768
+ const before = new Date().toISOString();
769
+ runStore.completeRun("run-2026-02-13T10:00:00.000Z", "completed");
770
+ const after = new Date().toISOString();
771
+
772
+ const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
773
+ expect(result?.status).toBe("completed");
774
+ expect(result?.completedAt).not.toBeNull();
775
+ const completedAt = result?.completedAt ?? "";
776
+ expect(completedAt >= before).toBe(true);
777
+ expect(completedAt <= after).toBe(true);
778
+ });
779
+
780
+ test("sets status to failed", () => {
781
+ runStore.createRun(makeRun());
782
+ runStore.completeRun("run-2026-02-13T10:00:00.000Z", "failed");
783
+
784
+ const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
785
+ expect(result?.status).toBe("failed");
786
+ expect(result?.completedAt).not.toBeNull();
787
+ });
788
+
789
+ test("is a no-op for nonexistent run (does not throw)", () => {
790
+ // Should not throw
791
+ runStore.completeRun("nonexistent-run", "completed");
792
+ });
793
+
794
+ test("preserves agent count when completing", () => {
795
+ runStore.createRun(makeRun());
796
+ runStore.incrementAgentCount("run-2026-02-13T10:00:00.000Z");
797
+ runStore.incrementAgentCount("run-2026-02-13T10:00:00.000Z");
798
+ runStore.incrementAgentCount("run-2026-02-13T10:00:00.000Z");
799
+
800
+ runStore.completeRun("run-2026-02-13T10:00:00.000Z", "completed");
801
+
802
+ const result = runStore.getRun("run-2026-02-13T10:00:00.000Z");
803
+ expect(result?.agentCount).toBe(3);
804
+ expect(result?.status).toBe("completed");
805
+ });
806
+ });
807
+
808
+ // === shared database ===
809
+
810
+ describe("shared database with SessionStore", () => {
811
+ test("RunStore and SessionStore can share the same database file", () => {
812
+ // SessionStore was already opened on dbPath in the outer beforeEach.
813
+ // RunStore was opened on dbPath in the inner beforeEach.
814
+ // Both should work without conflicts.
815
+ runStore.createRun(makeRun());
816
+ store.upsert(makeSession({ runId: "run-2026-02-13T10:00:00.000Z" }));
817
+
818
+ const run = runStore.getRun("run-2026-02-13T10:00:00.000Z");
819
+ expect(run).not.toBeNull();
820
+
821
+ const sessions = store.getByRun("run-2026-02-13T10:00:00.000Z");
822
+ expect(sessions).toHaveLength(1);
823
+ });
824
+ });
825
+
826
+ // === close ===
827
+
828
+ describe("close", () => {
829
+ test("close does not throw when called on open store", () => {
830
+ runStore.createRun(makeRun());
831
+ expect(() => runStore.close()).not.toThrow();
832
+ });
833
+ });
834
+
835
+ // === edge cases ===
836
+
837
+ describe("edge cases", () => {
838
+ test("handles many runs efficiently", () => {
839
+ for (let i = 0; i < 50; i++) {
840
+ runStore.createRun(
841
+ makeRun({
842
+ id: `run-${i}`,
843
+ startedAt: `2026-02-13T${String(i).padStart(2, "0")}:00:00.000Z`,
844
+ }),
845
+ );
846
+ }
847
+
848
+ const all = runStore.listRuns();
849
+ expect(all).toHaveLength(50);
850
+ });
851
+
852
+ test("all fields roundtrip correctly", () => {
853
+ const run: InsertRun = {
854
+ id: "run-roundtrip-test",
855
+ startedAt: "2026-02-13T15:30:00.000Z",
856
+ coordinatorSessionId: "coord-session-roundtrip",
857
+ status: "active",
858
+ agentCount: 7,
859
+ };
860
+
861
+ runStore.createRun(run);
862
+ const result = runStore.getRun("run-roundtrip-test");
863
+
864
+ expect(result).not.toBeNull();
865
+ expect(result?.id).toBe("run-roundtrip-test");
866
+ expect(result?.startedAt).toBe("2026-02-13T15:30:00.000Z");
867
+ expect(result?.completedAt).toBeNull();
868
+ expect(result?.agentCount).toBe(7);
869
+ expect(result?.coordinatorSessionId).toBe("coord-session-roundtrip");
870
+ expect(result?.status).toBe("active");
871
+ });
872
+ });
873
+ });