@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,480 @@
1
+ /**
2
+ * SQLite-backed session store for agent lifecycle tracking.
3
+ *
4
+ * Replaces the flat-file sessions.json with a proper database.
5
+ * Uses better-sqlite3 for zero-dependency, synchronous database access.
6
+ * WAL mode enables concurrent reads from multiple agent processes.
7
+ */
8
+
9
+ import Database from "better-sqlite3";
10
+ import type { AgentSession, AgentState, InsertRun, Run, RunStatus, RunStore } from "../types.ts";
11
+
12
+ export interface SessionStore {
13
+ /** Insert or update a session. Uses agent_name as the unique key. */
14
+ upsert(session: AgentSession): void;
15
+ /** Get a session by agent name, or null if not found. */
16
+ getByName(agentName: string): AgentSession | null;
17
+ /** Get all active sessions (state IN ('booting', 'working')). */
18
+ getActive(): AgentSession[];
19
+ /** Get all sessions regardless of state. */
20
+ getAll(): AgentSession[];
21
+ /** Get sessions belonging to a specific run. */
22
+ getByRun(runId: string): AgentSession[];
23
+ /** Get sessions belonging to a specific run, including sessions with null runId (orphan persistent agents). */
24
+ getByRunIncludeOrphans(runId: string): AgentSession[];
25
+ /** Update only the state of a session. */
26
+ updateState(agentName: string, state: AgentState): void;
27
+ /** Update lastActivity to current ISO timestamp. */
28
+ updateLastActivity(agentName: string): void;
29
+ /** Update escalation level and stalled timestamp. */
30
+ updateEscalation(agentName: string, level: number, stalledSince: string | null): void;
31
+ /** Remove a session by agent name. */
32
+ remove(agentName: string): void;
33
+ /** Purge sessions matching criteria. Returns count of deleted rows. */
34
+ purge(opts: { all?: boolean; state?: AgentState; agent?: string }): number;
35
+ /** Close the database connection. */
36
+ close(): void;
37
+ }
38
+
39
+ /** Row shape as stored in SQLite (snake_case columns). */
40
+ interface SessionRow {
41
+ id: string;
42
+ agent_name: string;
43
+ capability: string;
44
+ worktree_path: string;
45
+ branch_name: string;
46
+ bead_id: string;
47
+ tmux_session: string;
48
+ state: string;
49
+ pid: number | null;
50
+ parent_agent: string | null;
51
+ depth: number;
52
+ run_id: string | null;
53
+ started_at: string;
54
+ last_activity: string;
55
+ escalation_level: number;
56
+ stalled_since: string | null;
57
+ terminal_log_path: string | null;
58
+ }
59
+
60
+ /** Row shape for runs table as stored in SQLite (snake_case columns). */
61
+ interface RunRow {
62
+ id: string;
63
+ started_at: string;
64
+ completed_at: string | null;
65
+ agent_count: number;
66
+ coordinator_session_id: string | null;
67
+ status: string;
68
+ }
69
+
70
+ const CREATE_TABLE = `
71
+ CREATE TABLE IF NOT EXISTS sessions (
72
+ id TEXT PRIMARY KEY,
73
+ agent_name TEXT NOT NULL UNIQUE,
74
+ capability TEXT NOT NULL,
75
+ worktree_path TEXT NOT NULL,
76
+ branch_name TEXT NOT NULL,
77
+ bead_id TEXT NOT NULL,
78
+ tmux_session TEXT NOT NULL,
79
+ state TEXT NOT NULL DEFAULT 'booting'
80
+ CHECK(state IN ('booting','working','completed','zombie')),
81
+ pid INTEGER,
82
+ parent_agent TEXT,
83
+ depth INTEGER NOT NULL DEFAULT 0,
84
+ run_id TEXT,
85
+ started_at TEXT NOT NULL,
86
+ last_activity TEXT NOT NULL,
87
+ escalation_level INTEGER NOT NULL DEFAULT 0,
88
+ stalled_since TEXT,
89
+ terminal_log_path TEXT
90
+ )`;
91
+
92
+ const MIGRATE_TERMINAL_LOG_PATH = `
93
+ ALTER TABLE sessions ADD COLUMN terminal_log_path TEXT`;
94
+
95
+ const CREATE_INDEXES = `
96
+ CREATE INDEX IF NOT EXISTS idx_sessions_state ON sessions(state);
97
+ CREATE INDEX IF NOT EXISTS idx_sessions_run ON sessions(run_id)`;
98
+
99
+ const CREATE_RUNS_TABLE = `
100
+ CREATE TABLE IF NOT EXISTS runs (
101
+ id TEXT PRIMARY KEY,
102
+ started_at TEXT NOT NULL,
103
+ completed_at TEXT,
104
+ agent_count INTEGER NOT NULL DEFAULT 0,
105
+ coordinator_session_id TEXT,
106
+ status TEXT NOT NULL DEFAULT 'active'
107
+ CHECK(status IN ('active','completed','failed'))
108
+ )`;
109
+
110
+ const CREATE_RUNS_INDEXES = `
111
+ CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status)`;
112
+
113
+ /** Convert a database row (snake_case) to an AgentSession object (camelCase). */
114
+ function rowToSession(row: SessionRow): AgentSession {
115
+ return {
116
+ id: row.id,
117
+ agentName: row.agent_name,
118
+ capability: row.capability,
119
+ worktreePath: row.worktree_path,
120
+ branchName: row.branch_name,
121
+ beadId: row.bead_id,
122
+ tmuxSession: row.tmux_session,
123
+ state: row.state as AgentState,
124
+ pid: row.pid,
125
+ parentAgent: row.parent_agent,
126
+ depth: row.depth,
127
+ runId: row.run_id,
128
+ startedAt: row.started_at,
129
+ lastActivity: row.last_activity,
130
+ escalationLevel: row.escalation_level,
131
+ stalledSince: row.stalled_since,
132
+ // Only include terminalLogPath when set — omitting it when null preserves
133
+ // backward compatibility with existing tests that compare AgentSession objects.
134
+ ...(row.terminal_log_path !== null ? { terminalLogPath: row.terminal_log_path } : {}),
135
+ };
136
+ }
137
+
138
+ /** Convert a database row (snake_case) to a Run object (camelCase). */
139
+ function rowToRun(row: RunRow): Run {
140
+ return {
141
+ id: row.id,
142
+ startedAt: row.started_at,
143
+ completedAt: row.completed_at,
144
+ agentCount: row.agent_count,
145
+ coordinatorSessionId: row.coordinator_session_id,
146
+ status: row.status as RunStatus,
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Create a new SessionStore backed by a SQLite database at the given path.
152
+ *
153
+ * Initializes the database with WAL mode and a 5-second busy timeout.
154
+ * Creates the sessions table and indexes if they do not already exist.
155
+ */
156
+ export function createSessionStore(dbPath: string): SessionStore {
157
+ const db = new Database(dbPath);
158
+
159
+ // Configure for concurrent access from multiple agent processes.
160
+ db.exec("PRAGMA journal_mode = WAL");
161
+ db.exec("PRAGMA synchronous = NORMAL");
162
+ db.exec("PRAGMA busy_timeout = 5000");
163
+
164
+ // Create schema
165
+ db.exec(CREATE_TABLE);
166
+ // Migration: add terminal_log_path for existing DBs (no-op if column already exists)
167
+ try {
168
+ db.exec(MIGRATE_TERMINAL_LOG_PATH);
169
+ } catch {
170
+ // Column already exists — safe to ignore
171
+ }
172
+ db.exec(CREATE_INDEXES);
173
+ db.exec(CREATE_RUNS_TABLE);
174
+ db.exec(CREATE_RUNS_INDEXES);
175
+
176
+ // Prepare statements for frequent operations
177
+ const upsertStmt = db.prepare(`
178
+ INSERT INTO sessions
179
+ (id, agent_name, capability, worktree_path, branch_name, bead_id,
180
+ tmux_session, state, pid, parent_agent, depth, run_id,
181
+ started_at, last_activity, escalation_level, stalled_since, terminal_log_path)
182
+ VALUES
183
+ ($id, $agent_name, $capability, $worktree_path, $branch_name, $bead_id,
184
+ $tmux_session, $state, $pid, $parent_agent, $depth, $run_id,
185
+ $started_at, $last_activity, $escalation_level, $stalled_since, $terminal_log_path)
186
+ ON CONFLICT(agent_name) DO UPDATE SET
187
+ id = excluded.id,
188
+ capability = excluded.capability,
189
+ worktree_path = excluded.worktree_path,
190
+ branch_name = excluded.branch_name,
191
+ bead_id = excluded.bead_id,
192
+ tmux_session = excluded.tmux_session,
193
+ state = excluded.state,
194
+ pid = excluded.pid,
195
+ parent_agent = excluded.parent_agent,
196
+ depth = excluded.depth,
197
+ run_id = excluded.run_id,
198
+ started_at = excluded.started_at,
199
+ last_activity = excluded.last_activity,
200
+ escalation_level = excluded.escalation_level,
201
+ stalled_since = excluded.stalled_since,
202
+ terminal_log_path = excluded.terminal_log_path
203
+ `);
204
+
205
+ const getByNameStmt = db.prepare(`
206
+ SELECT * FROM sessions WHERE agent_name = $agent_name
207
+ `);
208
+
209
+ const getActiveStmt = db.prepare(`
210
+ SELECT * FROM sessions WHERE state IN ('booting', 'working')
211
+ ORDER BY started_at ASC
212
+ `);
213
+
214
+ const getAllStmt = db.prepare(`
215
+ SELECT * FROM sessions ORDER BY started_at ASC
216
+ `);
217
+
218
+ const getByRunStmt = db.prepare(`
219
+ SELECT * FROM sessions WHERE run_id = $run_id ORDER BY started_at ASC
220
+ `);
221
+
222
+ const getByRunIncludeOrphansStmt = db.prepare(`
223
+ SELECT * FROM sessions WHERE run_id = $run_id OR run_id IS NULL ORDER BY started_at ASC
224
+ `);
225
+
226
+ const updateStateStmt = db.prepare(`
227
+ UPDATE sessions SET state = $state WHERE agent_name = $agent_name
228
+ `);
229
+
230
+ const updateLastActivityStmt = db.prepare(`
231
+ UPDATE sessions
232
+ SET last_activity = $last_activity,
233
+ state = CASE
234
+ WHEN state IN ('booting', 'zombie') THEN 'working'
235
+ ELSE state
236
+ END,
237
+ escalation_level = CASE
238
+ WHEN state IN ('booting', 'zombie') THEN 0
239
+ ELSE escalation_level
240
+ END,
241
+ stalled_since = CASE
242
+ WHEN state IN ('booting', 'zombie') THEN NULL
243
+ ELSE stalled_since
244
+ END
245
+ WHERE agent_name = $agent_name
246
+ `);
247
+
248
+ const updateEscalationStmt = db.prepare(`
249
+ UPDATE sessions
250
+ SET escalation_level = $escalation_level, stalled_since = $stalled_since
251
+ WHERE agent_name = $agent_name
252
+ `);
253
+
254
+ const removeStmt = db.prepare(`
255
+ DELETE FROM sessions WHERE agent_name = $agent_name
256
+ `);
257
+
258
+ return {
259
+ upsert(session: AgentSession): void {
260
+ upsertStmt.run({
261
+ id: session.id,
262
+ agent_name: session.agentName,
263
+ capability: session.capability,
264
+ worktree_path: session.worktreePath,
265
+ branch_name: session.branchName,
266
+ bead_id: session.beadId,
267
+ tmux_session: session.tmuxSession,
268
+ state: session.state,
269
+ pid: session.pid,
270
+ parent_agent: session.parentAgent,
271
+ depth: session.depth,
272
+ run_id: session.runId,
273
+ started_at: session.startedAt,
274
+ last_activity: session.lastActivity,
275
+ escalation_level: session.escalationLevel,
276
+ stalled_since: session.stalledSince,
277
+ terminal_log_path: session.terminalLogPath ?? null,
278
+ });
279
+ },
280
+
281
+ getByName(agentName: string): AgentSession | null {
282
+ const row = getByNameStmt.get({ agent_name: agentName }) as SessionRow | undefined;
283
+ return row ? rowToSession(row) : null;
284
+ },
285
+
286
+ getActive(): AgentSession[] {
287
+ const rows = getActiveStmt.all() as SessionRow[];
288
+ return rows.map(rowToSession);
289
+ },
290
+
291
+ getAll(): AgentSession[] {
292
+ const rows = getAllStmt.all() as SessionRow[];
293
+ return rows.map(rowToSession);
294
+ },
295
+
296
+ getByRun(runId: string): AgentSession[] {
297
+ const rows = getByRunStmt.all({ run_id: runId }) as SessionRow[];
298
+ return rows.map(rowToSession);
299
+ },
300
+
301
+ getByRunIncludeOrphans(runId: string): AgentSession[] {
302
+ const rows = getByRunIncludeOrphansStmt.all({ run_id: runId }) as SessionRow[];
303
+ return rows.map(rowToSession);
304
+ },
305
+
306
+ updateState(agentName: string, state: AgentState): void {
307
+ updateStateStmt.run({ agent_name: agentName, state: state });
308
+ },
309
+
310
+ updateLastActivity(agentName: string): void {
311
+ updateLastActivityStmt.run({
312
+ agent_name: agentName,
313
+ last_activity: new Date().toISOString(),
314
+ });
315
+ },
316
+
317
+ updateEscalation(agentName: string, level: number, stalledSince: string | null): void {
318
+ updateEscalationStmt.run({
319
+ agent_name: agentName,
320
+ escalation_level: level,
321
+ stalled_since: stalledSince,
322
+ });
323
+ },
324
+
325
+ remove(agentName: string): void {
326
+ removeStmt.run({ agent_name: agentName });
327
+ },
328
+
329
+ purge(opts: { all?: boolean; state?: AgentState; agent?: string }): number {
330
+ if (opts.all) {
331
+ const countRow = db.prepare("SELECT COUNT(*) as cnt FROM sessions").get() as
332
+ | { cnt: number }
333
+ | undefined;
334
+ const count = countRow?.cnt ?? 0;
335
+ db.prepare("DELETE FROM sessions").run();
336
+ return count;
337
+ }
338
+
339
+ const conditions: string[] = [];
340
+ const params: Record<string, string> = {};
341
+
342
+ if (opts.state !== undefined) {
343
+ conditions.push("state = $state");
344
+ params.state = opts.state;
345
+ }
346
+
347
+ if (opts.agent !== undefined) {
348
+ conditions.push("agent_name = $agent");
349
+ params.agent = opts.agent;
350
+ }
351
+
352
+ if (conditions.length === 0) {
353
+ return 0;
354
+ }
355
+
356
+ const whereClause = conditions.join(" AND ");
357
+ const countQuery = `SELECT COUNT(*) as cnt FROM sessions WHERE ${whereClause}`;
358
+ const countRow = db.prepare(countQuery).get(params) as { cnt: number } | undefined;
359
+ const count = countRow?.cnt ?? 0;
360
+
361
+ const deleteQuery = `DELETE FROM sessions WHERE ${whereClause}`;
362
+ db.prepare(deleteQuery).run(params);
363
+
364
+ return count;
365
+ },
366
+
367
+ close(): void {
368
+ try {
369
+ db.exec("PRAGMA wal_checkpoint(PASSIVE)");
370
+ } catch {
371
+ // Best effort -- checkpoint failure is non-fatal
372
+ }
373
+ db.close();
374
+ },
375
+ };
376
+ }
377
+
378
+ /**
379
+ * Create a new RunStore backed by a SQLite database at the given path.
380
+ *
381
+ * Shares the same sessions.db file as SessionStore. Initializes the runs
382
+ * table alongside sessions. Uses WAL mode for concurrent access.
383
+ */
384
+ export function createRunStore(dbPath: string): RunStore {
385
+ const db = new Database(dbPath);
386
+
387
+ // Configure for concurrent access from multiple agent processes.
388
+ db.exec("PRAGMA journal_mode = WAL");
389
+ db.exec("PRAGMA synchronous = NORMAL");
390
+ db.exec("PRAGMA busy_timeout = 5000");
391
+
392
+ // Create schema (idempotent — safe if SessionStore already created these)
393
+ db.exec(CREATE_RUNS_TABLE);
394
+ db.exec(CREATE_RUNS_INDEXES);
395
+
396
+ // Prepare statements for frequent operations
397
+ const insertRunStmt = db.prepare(`
398
+ INSERT INTO runs (id, started_at, completed_at, agent_count, coordinator_session_id, status)
399
+ VALUES ($id, $started_at, $completed_at, $agent_count, $coordinator_session_id, $status)
400
+ `);
401
+
402
+ const getRunStmt = db.prepare(`
403
+ SELECT * FROM runs WHERE id = $id
404
+ `);
405
+
406
+ const getActiveRunStmt = db.prepare(`
407
+ SELECT * FROM runs WHERE status = 'active'
408
+ ORDER BY started_at DESC
409
+ LIMIT 1
410
+ `);
411
+
412
+ const incrementAgentCountStmt = db.prepare(`
413
+ UPDATE runs SET agent_count = agent_count + 1 WHERE id = $id
414
+ `);
415
+
416
+ const completeRunStmt = db.prepare(`
417
+ UPDATE runs SET status = $status, completed_at = $completed_at WHERE id = $id
418
+ `);
419
+
420
+ return {
421
+ createRun(run: InsertRun): void {
422
+ insertRunStmt.run({
423
+ id: run.id,
424
+ started_at: run.startedAt,
425
+ completed_at: null,
426
+ agent_count: run.agentCount ?? 0,
427
+ coordinator_session_id: run.coordinatorSessionId,
428
+ status: run.status,
429
+ });
430
+ },
431
+
432
+ getRun(id: string): Run | null {
433
+ const row = getRunStmt.get({ id: id }) as RunRow | undefined;
434
+ return row ? rowToRun(row) : null;
435
+ },
436
+
437
+ getActiveRun(): Run | null {
438
+ const row = getActiveRunStmt.get() as RunRow | undefined;
439
+ return row ? rowToRun(row) : null;
440
+ },
441
+
442
+ listRuns(opts?: { limit?: number; status?: RunStatus }): Run[] {
443
+ const conditions: string[] = [];
444
+ const params: Record<string, string | number> = {};
445
+
446
+ if (opts?.status !== undefined) {
447
+ conditions.push("status = $status");
448
+ params.status = opts.status;
449
+ }
450
+
451
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
452
+ const limitClause = opts?.limit !== undefined ? `LIMIT ${opts.limit}` : "";
453
+ const query = `SELECT * FROM runs ${whereClause} ORDER BY started_at DESC ${limitClause}`;
454
+
455
+ const rows = db.prepare(query).all(params) as RunRow[];
456
+ return rows.map(rowToRun);
457
+ },
458
+
459
+ incrementAgentCount(runId: string): void {
460
+ incrementAgentCountStmt.run({ id: runId });
461
+ },
462
+
463
+ completeRun(runId: string, status: "completed" | "failed"): void {
464
+ completeRunStmt.run({
465
+ id: runId,
466
+ status: status,
467
+ completed_at: new Date().toISOString(),
468
+ });
469
+ },
470
+
471
+ close(): void {
472
+ try {
473
+ db.exec("PRAGMA wal_checkpoint(PASSIVE)");
474
+ } catch {
475
+ // Best effort -- checkpoint failure is non-fatal
476
+ }
477
+ db.close();
478
+ },
479
+ };
480
+ }
@@ -0,0 +1,97 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, test } from "vitest";
5
+ import { cleanupTempDir, commitFile, createTempGitRepo, runGitInDir } from "./test-helpers.ts";
6
+
7
+ describe("createTempGitRepo", () => {
8
+ let repoDir: string | undefined;
9
+
10
+ afterEach(async () => {
11
+ if (repoDir) {
12
+ await cleanupTempDir(repoDir);
13
+ repoDir = undefined;
14
+ }
15
+ });
16
+
17
+ test("creates a directory with an initialized git repo", async () => {
18
+ repoDir = await createTempGitRepo();
19
+
20
+ expect(existsSync(join(repoDir, ".git"))).toBe(true);
21
+ });
22
+
23
+ test("repo has at least one commit (HEAD exists)", async () => {
24
+ repoDir = await createTempGitRepo();
25
+
26
+ // runGitInDir throws on non-zero exit, resolving means exit code was 0
27
+ await runGitInDir(repoDir, ["rev-parse", "HEAD"]);
28
+ expect(true).toBe(true);
29
+ });
30
+
31
+ test("repo is on a branch (not detached HEAD)", async () => {
32
+ repoDir = await createTempGitRepo();
33
+
34
+ const stdout = await runGitInDir(repoDir, ["symbolic-ref", "HEAD"]);
35
+ expect(stdout.trim()).toMatch(/^refs\/heads\//);
36
+ });
37
+ });
38
+
39
+ describe("commitFile", () => {
40
+ let repoDir: string | undefined;
41
+
42
+ afterEach(async () => {
43
+ if (repoDir) {
44
+ await cleanupTempDir(repoDir);
45
+ repoDir = undefined;
46
+ }
47
+ });
48
+
49
+ test("creates file and commits it", async () => {
50
+ repoDir = await createTempGitRepo();
51
+
52
+ await commitFile(repoDir, "hello.txt", "world");
53
+
54
+ // File exists with correct content
55
+ const content = await readFile(join(repoDir, "hello.txt"), "utf-8");
56
+ expect(content).toBe("world");
57
+
58
+ // Git log shows the commit
59
+ const stdout = await runGitInDir(repoDir, ["log", "--oneline"]);
60
+ expect(stdout).toContain("add hello.txt");
61
+ });
62
+
63
+ test("creates nested directories as needed", async () => {
64
+ repoDir = await createTempGitRepo();
65
+
66
+ await commitFile(repoDir, "src/deep/nested/file.ts", "export const x = 1;");
67
+
68
+ expect(existsSync(join(repoDir, "src/deep/nested/file.ts"))).toBe(true);
69
+ });
70
+
71
+ test("uses custom commit message when provided", async () => {
72
+ repoDir = await createTempGitRepo();
73
+
74
+ await commitFile(repoDir, "readme.md", "# Hi", "docs: add readme");
75
+
76
+ const stdout = await runGitInDir(repoDir, ["log", "--oneline", "-1"]);
77
+ expect(stdout).toContain("docs: add readme");
78
+ });
79
+ });
80
+
81
+ describe("cleanupTempDir", () => {
82
+ test("removes directory and all contents", async () => {
83
+ const repoDir = await createTempGitRepo();
84
+ await commitFile(repoDir, "file.txt", "data");
85
+
86
+ expect(existsSync(repoDir)).toBe(true);
87
+
88
+ await cleanupTempDir(repoDir);
89
+
90
+ expect(existsSync(repoDir)).toBe(false);
91
+ });
92
+
93
+ test("does not throw when directory does not exist", async () => {
94
+ await cleanupTempDir("/tmp/legio-nonexistent-test-dir-12345");
95
+ // No error thrown = pass
96
+ });
97
+ });