@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.
- package/LICENSE +21 -0
- package/README.md +381 -0
- package/agents/builder.md +137 -0
- package/agents/coordinator.md +263 -0
- package/agents/lead.md +301 -0
- package/agents/merger.md +160 -0
- package/agents/monitor.md +214 -0
- package/agents/reviewer.md +140 -0
- package/agents/scout.md +119 -0
- package/agents/supervisor.md +423 -0
- package/package.json +47 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +101 -0
- package/src/agents/hooks-deployer.test.ts +2040 -0
- package/src/agents/hooks-deployer.ts +607 -0
- package/src/agents/identity.test.ts +603 -0
- package/src/agents/identity.ts +384 -0
- package/src/agents/lifecycle.test.ts +196 -0
- package/src/agents/lifecycle.ts +183 -0
- package/src/agents/manifest.test.ts +746 -0
- package/src/agents/manifest.ts +354 -0
- package/src/agents/overlay.test.ts +676 -0
- package/src/agents/overlay.ts +308 -0
- package/src/beads/client.test.ts +217 -0
- package/src/beads/client.ts +202 -0
- package/src/beads/molecules.test.ts +338 -0
- package/src/beads/molecules.ts +198 -0
- package/src/commands/agents.test.ts +322 -0
- package/src/commands/agents.ts +287 -0
- package/src/commands/clean.test.ts +670 -0
- package/src/commands/clean.ts +618 -0
- package/src/commands/completions.test.ts +342 -0
- package/src/commands/completions.ts +887 -0
- package/src/commands/coordinator.test.ts +1530 -0
- package/src/commands/coordinator.ts +733 -0
- package/src/commands/costs.test.ts +1119 -0
- package/src/commands/costs.ts +564 -0
- package/src/commands/dashboard.test.ts +308 -0
- package/src/commands/dashboard.ts +838 -0
- package/src/commands/doctor.test.ts +294 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/errors.test.ts +647 -0
- package/src/commands/errors.ts +248 -0
- package/src/commands/feed.test.ts +578 -0
- package/src/commands/feed.ts +361 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +511 -0
- package/src/commands/hooks.test.ts +458 -0
- package/src/commands/hooks.ts +253 -0
- package/src/commands/init.test.ts +347 -0
- package/src/commands/init.ts +650 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +431 -0
- package/src/commands/log.test.ts +1454 -0
- package/src/commands/log.ts +724 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +546 -0
- package/src/commands/mail.test.ts +1270 -0
- package/src/commands/mail.ts +771 -0
- package/src/commands/merge.test.ts +670 -0
- package/src/commands/merge.ts +355 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +143 -0
- package/src/commands/monitor.test.ts +191 -0
- package/src/commands/monitor.ts +390 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +372 -0
- package/src/commands/prime.test.ts +470 -0
- package/src/commands/prime.ts +381 -0
- package/src/commands/replay.test.ts +741 -0
- package/src/commands/replay.ts +360 -0
- package/src/commands/run.test.ts +431 -0
- package/src/commands/run.ts +351 -0
- package/src/commands/sling.test.ts +657 -0
- package/src/commands/sling.ts +661 -0
- package/src/commands/spec.test.ts +203 -0
- package/src/commands/spec.ts +168 -0
- package/src/commands/status.test.ts +430 -0
- package/src/commands/status.ts +398 -0
- package/src/commands/stop.test.ts +420 -0
- package/src/commands/stop.ts +151 -0
- package/src/commands/supervisor.test.ts +187 -0
- package/src/commands/supervisor.ts +535 -0
- package/src/commands/trace.test.ts +745 -0
- package/src/commands/trace.ts +325 -0
- package/src/commands/watch.test.ts +145 -0
- package/src/commands/watch.ts +247 -0
- package/src/commands/worktree.test.ts +786 -0
- package/src/commands/worktree.ts +311 -0
- package/src/config.test.ts +822 -0
- package/src/config.ts +829 -0
- package/src/doctor/agents.test.ts +454 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +190 -0
- package/src/doctor/config-check.ts +183 -0
- package/src/doctor/consistency.test.ts +651 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +290 -0
- package/src/doctor/databases.ts +218 -0
- package/src/doctor/dependencies.test.ts +184 -0
- package/src/doctor/dependencies.ts +175 -0
- package/src/doctor/logs.test.ts +251 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +216 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +291 -0
- package/src/doctor/structure.ts +198 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +136 -0
- package/src/doctor/version.ts +129 -0
- package/src/e2e/init-sling-lifecycle.test.ts +277 -0
- package/src/errors.ts +217 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +369 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/index.ts +316 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +142 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +813 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +259 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +773 -0
- package/src/mail/client.ts +223 -0
- package/src/mail/store.test.ts +705 -0
- package/src/mail/store.ts +387 -0
- package/src/merge/queue.test.ts +359 -0
- package/src/merge/queue.ts +231 -0
- package/src/merge/resolver.test.ts +1345 -0
- package/src/merge/resolver.ts +645 -0
- package/src/metrics/store.test.ts +667 -0
- package/src/metrics/store.ts +445 -0
- package/src/metrics/summary.test.ts +398 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +356 -0
- package/src/metrics/transcript.ts +175 -0
- package/src/mulch/client.test.ts +671 -0
- package/src/mulch/client.ts +332 -0
- package/src/sessions/compat.test.ts +280 -0
- package/src/sessions/compat.ts +104 -0
- package/src/sessions/store.test.ts +873 -0
- package/src/sessions/store.ts +494 -0
- package/src/test-helpers.test.ts +124 -0
- package/src/test-helpers.ts +126 -0
- package/src/tracker/beads.ts +56 -0
- package/src/tracker/factory.test.ts +80 -0
- package/src/tracker/factory.ts +64 -0
- package/src/tracker/seeds.ts +182 -0
- package/src/tracker/types.ts +52 -0
- package/src/types.ts +724 -0
- package/src/watchdog/daemon.test.ts +1975 -0
- package/src/watchdog/daemon.ts +671 -0
- package/src/watchdog/health.test.ts +431 -0
- package/src/watchdog/health.ts +264 -0
- package/src/watchdog/triage.test.ts +164 -0
- package/src/watchdog/triage.ts +179 -0
- package/src/worktree/manager.test.ts +439 -0
- package/src/worktree/manager.ts +198 -0
- package/src/worktree/tmux.test.ts +1009 -0
- package/src/worktree/tmux.ts +509 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +105 -0
- package/templates/overlay.md.tmpl +81 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite-backed session store for agent lifecycle tracking.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the flat-file sessions.json with a proper database.
|
|
5
|
+
* Uses bun:sqlite for zero-dependency, synchronous database access.
|
|
6
|
+
* WAL mode enables concurrent reads from multiple agent processes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Database } from "bun:sqlite";
|
|
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', 'stalled')). */
|
|
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
|
+
/** Update only the state of a session. */
|
|
24
|
+
updateState(agentName: string, state: AgentState): void;
|
|
25
|
+
/** Update lastActivity to current ISO timestamp. */
|
|
26
|
+
updateLastActivity(agentName: string): void;
|
|
27
|
+
/** Update escalation level and stalled timestamp. */
|
|
28
|
+
updateEscalation(agentName: string, level: number, stalledSince: string | null): void;
|
|
29
|
+
/** Remove a session by agent name. */
|
|
30
|
+
remove(agentName: string): void;
|
|
31
|
+
/** Purge sessions matching criteria. Returns count of deleted rows. */
|
|
32
|
+
purge(opts: { all?: boolean; state?: AgentState; agent?: string }): number;
|
|
33
|
+
/** Close the database connection. */
|
|
34
|
+
close(): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Row shape as stored in SQLite (snake_case columns). */
|
|
38
|
+
interface SessionRow {
|
|
39
|
+
id: string;
|
|
40
|
+
agent_name: string;
|
|
41
|
+
capability: string;
|
|
42
|
+
worktree_path: string;
|
|
43
|
+
branch_name: string;
|
|
44
|
+
task_id: string;
|
|
45
|
+
tmux_session: string;
|
|
46
|
+
state: string;
|
|
47
|
+
pid: number | null;
|
|
48
|
+
parent_agent: string | null;
|
|
49
|
+
depth: number;
|
|
50
|
+
run_id: string | null;
|
|
51
|
+
started_at: string;
|
|
52
|
+
last_activity: string;
|
|
53
|
+
escalation_level: number;
|
|
54
|
+
stalled_since: string | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Row shape for runs table as stored in SQLite (snake_case columns). */
|
|
58
|
+
interface RunRow {
|
|
59
|
+
id: string;
|
|
60
|
+
started_at: string;
|
|
61
|
+
completed_at: string | null;
|
|
62
|
+
agent_count: number;
|
|
63
|
+
coordinator_session_id: string | null;
|
|
64
|
+
status: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const CREATE_TABLE = `
|
|
68
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
69
|
+
id TEXT PRIMARY KEY,
|
|
70
|
+
agent_name TEXT NOT NULL UNIQUE,
|
|
71
|
+
capability TEXT NOT NULL,
|
|
72
|
+
worktree_path TEXT NOT NULL,
|
|
73
|
+
branch_name TEXT NOT NULL,
|
|
74
|
+
task_id TEXT NOT NULL,
|
|
75
|
+
tmux_session TEXT NOT NULL,
|
|
76
|
+
state TEXT NOT NULL DEFAULT 'booting'
|
|
77
|
+
CHECK(state IN ('booting','working','completed','stalled','zombie')),
|
|
78
|
+
pid INTEGER,
|
|
79
|
+
parent_agent TEXT,
|
|
80
|
+
depth INTEGER NOT NULL DEFAULT 0,
|
|
81
|
+
run_id TEXT,
|
|
82
|
+
started_at TEXT NOT NULL,
|
|
83
|
+
last_activity TEXT NOT NULL,
|
|
84
|
+
escalation_level INTEGER NOT NULL DEFAULT 0,
|
|
85
|
+
stalled_since TEXT
|
|
86
|
+
)`;
|
|
87
|
+
|
|
88
|
+
const CREATE_INDEXES = `
|
|
89
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_state ON sessions(state);
|
|
90
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_run ON sessions(run_id)`;
|
|
91
|
+
|
|
92
|
+
const CREATE_RUNS_TABLE = `
|
|
93
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
94
|
+
id TEXT PRIMARY KEY,
|
|
95
|
+
started_at TEXT NOT NULL,
|
|
96
|
+
completed_at TEXT,
|
|
97
|
+
agent_count INTEGER NOT NULL DEFAULT 0,
|
|
98
|
+
coordinator_session_id TEXT,
|
|
99
|
+
status TEXT NOT NULL DEFAULT 'active'
|
|
100
|
+
CHECK(status IN ('active','completed','failed'))
|
|
101
|
+
)`;
|
|
102
|
+
|
|
103
|
+
const CREATE_RUNS_INDEXES = `
|
|
104
|
+
CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status)`;
|
|
105
|
+
|
|
106
|
+
/** Convert a database row (snake_case) to an AgentSession object (camelCase). */
|
|
107
|
+
function rowToSession(row: SessionRow): AgentSession {
|
|
108
|
+
return {
|
|
109
|
+
id: row.id,
|
|
110
|
+
agentName: row.agent_name,
|
|
111
|
+
capability: row.capability,
|
|
112
|
+
worktreePath: row.worktree_path,
|
|
113
|
+
branchName: row.branch_name,
|
|
114
|
+
beadId: row.task_id,
|
|
115
|
+
tmuxSession: row.tmux_session,
|
|
116
|
+
state: row.state as AgentState,
|
|
117
|
+
pid: row.pid,
|
|
118
|
+
parentAgent: row.parent_agent,
|
|
119
|
+
depth: row.depth,
|
|
120
|
+
runId: row.run_id,
|
|
121
|
+
startedAt: row.started_at,
|
|
122
|
+
lastActivity: row.last_activity,
|
|
123
|
+
escalationLevel: row.escalation_level,
|
|
124
|
+
stalledSince: row.stalled_since,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Convert a database row (snake_case) to a Run object (camelCase). */
|
|
129
|
+
function rowToRun(row: RunRow): Run {
|
|
130
|
+
return {
|
|
131
|
+
id: row.id,
|
|
132
|
+
startedAt: row.started_at,
|
|
133
|
+
completedAt: row.completed_at,
|
|
134
|
+
agentCount: row.agent_count,
|
|
135
|
+
coordinatorSessionId: row.coordinator_session_id,
|
|
136
|
+
status: row.status as RunStatus,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Migrate an existing sessions table from bead_id to task_id column.
|
|
142
|
+
* Safe to call multiple times — only renames if bead_id exists and task_id does not.
|
|
143
|
+
*/
|
|
144
|
+
function migrateBeadIdToTaskId(db: Database): void {
|
|
145
|
+
const rows = db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name: string }>;
|
|
146
|
+
const existingColumns = new Set(rows.map((r) => r.name));
|
|
147
|
+
if (existingColumns.has("bead_id") && !existingColumns.has("task_id")) {
|
|
148
|
+
db.exec("ALTER TABLE sessions RENAME COLUMN bead_id TO task_id");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Create a new SessionStore backed by a SQLite database at the given path.
|
|
154
|
+
*
|
|
155
|
+
* Initializes the database with WAL mode and a 5-second busy timeout.
|
|
156
|
+
* Creates the sessions table and indexes if they do not already exist.
|
|
157
|
+
*/
|
|
158
|
+
export function createSessionStore(dbPath: string): SessionStore {
|
|
159
|
+
const db = new Database(dbPath);
|
|
160
|
+
|
|
161
|
+
// Configure for concurrent access from multiple agent processes.
|
|
162
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
163
|
+
db.exec("PRAGMA synchronous = NORMAL");
|
|
164
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
165
|
+
|
|
166
|
+
// Create schema
|
|
167
|
+
db.exec(CREATE_TABLE);
|
|
168
|
+
db.exec(CREATE_INDEXES);
|
|
169
|
+
db.exec(CREATE_RUNS_TABLE);
|
|
170
|
+
db.exec(CREATE_RUNS_INDEXES);
|
|
171
|
+
|
|
172
|
+
// Migrate: rename bead_id → task_id on existing tables
|
|
173
|
+
migrateBeadIdToTaskId(db);
|
|
174
|
+
|
|
175
|
+
// Prepare statements for frequent operations
|
|
176
|
+
const upsertStmt = db.prepare<
|
|
177
|
+
void,
|
|
178
|
+
{
|
|
179
|
+
$id: string;
|
|
180
|
+
$agent_name: string;
|
|
181
|
+
$capability: string;
|
|
182
|
+
$worktree_path: string;
|
|
183
|
+
$branch_name: string;
|
|
184
|
+
$task_id: string;
|
|
185
|
+
$tmux_session: string;
|
|
186
|
+
$state: string;
|
|
187
|
+
$pid: number | null;
|
|
188
|
+
$parent_agent: string | null;
|
|
189
|
+
$depth: number;
|
|
190
|
+
$run_id: string | null;
|
|
191
|
+
$started_at: string;
|
|
192
|
+
$last_activity: string;
|
|
193
|
+
$escalation_level: number;
|
|
194
|
+
$stalled_since: string | null;
|
|
195
|
+
}
|
|
196
|
+
>(`
|
|
197
|
+
INSERT INTO sessions
|
|
198
|
+
(id, agent_name, capability, worktree_path, branch_name, task_id,
|
|
199
|
+
tmux_session, state, pid, parent_agent, depth, run_id,
|
|
200
|
+
started_at, last_activity, escalation_level, stalled_since)
|
|
201
|
+
VALUES
|
|
202
|
+
($id, $agent_name, $capability, $worktree_path, $branch_name, $task_id,
|
|
203
|
+
$tmux_session, $state, $pid, $parent_agent, $depth, $run_id,
|
|
204
|
+
$started_at, $last_activity, $escalation_level, $stalled_since)
|
|
205
|
+
ON CONFLICT(agent_name) DO UPDATE SET
|
|
206
|
+
id = excluded.id,
|
|
207
|
+
capability = excluded.capability,
|
|
208
|
+
worktree_path = excluded.worktree_path,
|
|
209
|
+
branch_name = excluded.branch_name,
|
|
210
|
+
task_id = excluded.task_id,
|
|
211
|
+
tmux_session = excluded.tmux_session,
|
|
212
|
+
state = excluded.state,
|
|
213
|
+
pid = excluded.pid,
|
|
214
|
+
parent_agent = excluded.parent_agent,
|
|
215
|
+
depth = excluded.depth,
|
|
216
|
+
run_id = excluded.run_id,
|
|
217
|
+
started_at = excluded.started_at,
|
|
218
|
+
last_activity = excluded.last_activity,
|
|
219
|
+
escalation_level = excluded.escalation_level,
|
|
220
|
+
stalled_since = excluded.stalled_since
|
|
221
|
+
`);
|
|
222
|
+
|
|
223
|
+
const getByNameStmt = db.prepare<SessionRow, { $agent_name: string }>(`
|
|
224
|
+
SELECT * FROM sessions WHERE agent_name = $agent_name
|
|
225
|
+
`);
|
|
226
|
+
|
|
227
|
+
const getActiveStmt = db.prepare<SessionRow, Record<string, never>>(`
|
|
228
|
+
SELECT * FROM sessions WHERE state IN ('booting', 'working', 'stalled')
|
|
229
|
+
ORDER BY started_at ASC
|
|
230
|
+
`);
|
|
231
|
+
|
|
232
|
+
const getAllStmt = db.prepare<SessionRow, Record<string, never>>(`
|
|
233
|
+
SELECT * FROM sessions ORDER BY started_at ASC
|
|
234
|
+
`);
|
|
235
|
+
|
|
236
|
+
const getByRunStmt = db.prepare<SessionRow, { $run_id: string }>(`
|
|
237
|
+
SELECT * FROM sessions WHERE run_id = $run_id ORDER BY started_at ASC
|
|
238
|
+
`);
|
|
239
|
+
|
|
240
|
+
const updateStateStmt = db.prepare<void, { $agent_name: string; $state: string }>(`
|
|
241
|
+
UPDATE sessions SET state = $state WHERE agent_name = $agent_name
|
|
242
|
+
`);
|
|
243
|
+
|
|
244
|
+
const updateLastActivityStmt = db.prepare<void, { $agent_name: string; $last_activity: string }>(`
|
|
245
|
+
UPDATE sessions SET last_activity = $last_activity WHERE agent_name = $agent_name
|
|
246
|
+
`);
|
|
247
|
+
|
|
248
|
+
const updateEscalationStmt = db.prepare<
|
|
249
|
+
void,
|
|
250
|
+
{
|
|
251
|
+
$agent_name: string;
|
|
252
|
+
$escalation_level: number;
|
|
253
|
+
$stalled_since: string | null;
|
|
254
|
+
}
|
|
255
|
+
>(`
|
|
256
|
+
UPDATE sessions
|
|
257
|
+
SET escalation_level = $escalation_level, stalled_since = $stalled_since
|
|
258
|
+
WHERE agent_name = $agent_name
|
|
259
|
+
`);
|
|
260
|
+
|
|
261
|
+
const removeStmt = db.prepare<void, { $agent_name: string }>(`
|
|
262
|
+
DELETE FROM sessions WHERE agent_name = $agent_name
|
|
263
|
+
`);
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
upsert(session: AgentSession): void {
|
|
267
|
+
upsertStmt.run({
|
|
268
|
+
$id: session.id,
|
|
269
|
+
$agent_name: session.agentName,
|
|
270
|
+
$capability: session.capability,
|
|
271
|
+
$worktree_path: session.worktreePath,
|
|
272
|
+
$branch_name: session.branchName,
|
|
273
|
+
$task_id: session.beadId,
|
|
274
|
+
$tmux_session: session.tmuxSession,
|
|
275
|
+
$state: session.state,
|
|
276
|
+
$pid: session.pid,
|
|
277
|
+
$parent_agent: session.parentAgent,
|
|
278
|
+
$depth: session.depth,
|
|
279
|
+
$run_id: session.runId,
|
|
280
|
+
$started_at: session.startedAt,
|
|
281
|
+
$last_activity: session.lastActivity,
|
|
282
|
+
$escalation_level: session.escalationLevel,
|
|
283
|
+
$stalled_since: session.stalledSince,
|
|
284
|
+
});
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
getByName(agentName: string): AgentSession | null {
|
|
288
|
+
const row = getByNameStmt.get({ $agent_name: agentName });
|
|
289
|
+
return row ? rowToSession(row) : null;
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
getActive(): AgentSession[] {
|
|
293
|
+
const rows = getActiveStmt.all({});
|
|
294
|
+
return rows.map(rowToSession);
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
getAll(): AgentSession[] {
|
|
298
|
+
const rows = getAllStmt.all({});
|
|
299
|
+
return rows.map(rowToSession);
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
getByRun(runId: string): AgentSession[] {
|
|
303
|
+
const rows = getByRunStmt.all({ $run_id: runId });
|
|
304
|
+
return rows.map(rowToSession);
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
updateState(agentName: string, state: AgentState): void {
|
|
308
|
+
updateStateStmt.run({ $agent_name: agentName, $state: state });
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
updateLastActivity(agentName: string): void {
|
|
312
|
+
updateLastActivityStmt.run({
|
|
313
|
+
$agent_name: agentName,
|
|
314
|
+
$last_activity: new Date().toISOString(),
|
|
315
|
+
});
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
updateEscalation(agentName: string, level: number, stalledSince: string | null): void {
|
|
319
|
+
updateEscalationStmt.run({
|
|
320
|
+
$agent_name: agentName,
|
|
321
|
+
$escalation_level: level,
|
|
322
|
+
$stalled_since: stalledSince,
|
|
323
|
+
});
|
|
324
|
+
},
|
|
325
|
+
|
|
326
|
+
remove(agentName: string): void {
|
|
327
|
+
removeStmt.run({ $agent_name: agentName });
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
purge(opts: { all?: boolean; state?: AgentState; agent?: string }): number {
|
|
331
|
+
if (opts.all) {
|
|
332
|
+
const countRow = db
|
|
333
|
+
.prepare<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM sessions")
|
|
334
|
+
.get();
|
|
335
|
+
const count = countRow?.cnt ?? 0;
|
|
336
|
+
db.prepare("DELETE FROM sessions").run();
|
|
337
|
+
return count;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const conditions: string[] = [];
|
|
341
|
+
const params: Record<string, string> = {};
|
|
342
|
+
|
|
343
|
+
if (opts.state !== undefined) {
|
|
344
|
+
conditions.push("state = $state");
|
|
345
|
+
params.$state = opts.state;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (opts.agent !== undefined) {
|
|
349
|
+
conditions.push("agent_name = $agent");
|
|
350
|
+
params.$agent = opts.agent;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (conditions.length === 0) {
|
|
354
|
+
return 0;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const whereClause = conditions.join(" AND ");
|
|
358
|
+
const countQuery = `SELECT COUNT(*) as cnt FROM sessions WHERE ${whereClause}`;
|
|
359
|
+
const countRow = db.prepare<{ cnt: number }, Record<string, string>>(countQuery).get(params);
|
|
360
|
+
const count = countRow?.cnt ?? 0;
|
|
361
|
+
|
|
362
|
+
const deleteQuery = `DELETE FROM sessions WHERE ${whereClause}`;
|
|
363
|
+
db.prepare<void, Record<string, string>>(deleteQuery).run(params);
|
|
364
|
+
|
|
365
|
+
return count;
|
|
366
|
+
},
|
|
367
|
+
|
|
368
|
+
close(): void {
|
|
369
|
+
try {
|
|
370
|
+
db.exec("PRAGMA wal_checkpoint(PASSIVE)");
|
|
371
|
+
} catch {
|
|
372
|
+
// Best effort -- checkpoint failure is non-fatal
|
|
373
|
+
}
|
|
374
|
+
db.close();
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Create a new RunStore backed by a SQLite database at the given path.
|
|
381
|
+
*
|
|
382
|
+
* Shares the same sessions.db file as SessionStore. Initializes the runs
|
|
383
|
+
* table alongside sessions. Uses WAL mode for concurrent access.
|
|
384
|
+
*/
|
|
385
|
+
export function createRunStore(dbPath: string): RunStore {
|
|
386
|
+
const db = new Database(dbPath);
|
|
387
|
+
|
|
388
|
+
// Configure for concurrent access from multiple agent processes.
|
|
389
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
390
|
+
db.exec("PRAGMA synchronous = NORMAL");
|
|
391
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
392
|
+
|
|
393
|
+
// Create schema (idempotent — safe if SessionStore already created these)
|
|
394
|
+
db.exec(CREATE_RUNS_TABLE);
|
|
395
|
+
db.exec(CREATE_RUNS_INDEXES);
|
|
396
|
+
|
|
397
|
+
// Prepare statements for frequent operations
|
|
398
|
+
const insertRunStmt = db.prepare<
|
|
399
|
+
void,
|
|
400
|
+
{
|
|
401
|
+
$id: string;
|
|
402
|
+
$started_at: string;
|
|
403
|
+
$completed_at: string | null;
|
|
404
|
+
$agent_count: number;
|
|
405
|
+
$coordinator_session_id: string | null;
|
|
406
|
+
$status: string;
|
|
407
|
+
}
|
|
408
|
+
>(`
|
|
409
|
+
INSERT INTO runs (id, started_at, completed_at, agent_count, coordinator_session_id, status)
|
|
410
|
+
VALUES ($id, $started_at, $completed_at, $agent_count, $coordinator_session_id, $status)
|
|
411
|
+
`);
|
|
412
|
+
|
|
413
|
+
const getRunStmt = db.prepare<RunRow, { $id: string }>(`
|
|
414
|
+
SELECT * FROM runs WHERE id = $id
|
|
415
|
+
`);
|
|
416
|
+
|
|
417
|
+
const getActiveRunStmt = db.prepare<RunRow, Record<string, never>>(`
|
|
418
|
+
SELECT * FROM runs WHERE status = 'active'
|
|
419
|
+
ORDER BY started_at DESC
|
|
420
|
+
LIMIT 1
|
|
421
|
+
`);
|
|
422
|
+
|
|
423
|
+
const incrementAgentCountStmt = db.prepare<void, { $id: string }>(`
|
|
424
|
+
UPDATE runs SET agent_count = agent_count + 1 WHERE id = $id
|
|
425
|
+
`);
|
|
426
|
+
|
|
427
|
+
const completeRunStmt = db.prepare<
|
|
428
|
+
void,
|
|
429
|
+
{ $id: string; $status: string; $completed_at: string }
|
|
430
|
+
>(`
|
|
431
|
+
UPDATE runs SET status = $status, completed_at = $completed_at WHERE id = $id
|
|
432
|
+
`);
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
createRun(run: InsertRun): void {
|
|
436
|
+
insertRunStmt.run({
|
|
437
|
+
$id: run.id,
|
|
438
|
+
$started_at: run.startedAt,
|
|
439
|
+
$completed_at: null,
|
|
440
|
+
$agent_count: run.agentCount ?? 0,
|
|
441
|
+
$coordinator_session_id: run.coordinatorSessionId,
|
|
442
|
+
$status: run.status,
|
|
443
|
+
});
|
|
444
|
+
},
|
|
445
|
+
|
|
446
|
+
getRun(id: string): Run | null {
|
|
447
|
+
const row = getRunStmt.get({ $id: id });
|
|
448
|
+
return row ? rowToRun(row) : null;
|
|
449
|
+
},
|
|
450
|
+
|
|
451
|
+
getActiveRun(): Run | null {
|
|
452
|
+
const row = getActiveRunStmt.get({});
|
|
453
|
+
return row ? rowToRun(row) : null;
|
|
454
|
+
},
|
|
455
|
+
|
|
456
|
+
listRuns(opts?: { limit?: number; status?: RunStatus }): Run[] {
|
|
457
|
+
const conditions: string[] = [];
|
|
458
|
+
const params: Record<string, string | number> = {};
|
|
459
|
+
|
|
460
|
+
if (opts?.status !== undefined) {
|
|
461
|
+
conditions.push("status = $status");
|
|
462
|
+
params.$status = opts.status;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
466
|
+
const limitClause = opts?.limit !== undefined ? `LIMIT ${opts.limit}` : "";
|
|
467
|
+
const query = `SELECT * FROM runs ${whereClause} ORDER BY started_at DESC ${limitClause}`;
|
|
468
|
+
|
|
469
|
+
const rows = db.prepare<RunRow, Record<string, string | number>>(query).all(params);
|
|
470
|
+
return rows.map(rowToRun);
|
|
471
|
+
},
|
|
472
|
+
|
|
473
|
+
incrementAgentCount(runId: string): void {
|
|
474
|
+
incrementAgentCountStmt.run({ $id: runId });
|
|
475
|
+
},
|
|
476
|
+
|
|
477
|
+
completeRun(runId: string, status: "completed" | "failed"): void {
|
|
478
|
+
completeRunStmt.run({
|
|
479
|
+
$id: runId,
|
|
480
|
+
$status: status,
|
|
481
|
+
$completed_at: new Date().toISOString(),
|
|
482
|
+
});
|
|
483
|
+
},
|
|
484
|
+
|
|
485
|
+
close(): void {
|
|
486
|
+
try {
|
|
487
|
+
db.exec("PRAGMA wal_checkpoint(PASSIVE)");
|
|
488
|
+
} catch {
|
|
489
|
+
// Best effort -- checkpoint failure is non-fatal
|
|
490
|
+
}
|
|
491
|
+
db.close();
|
|
492
|
+
},
|
|
493
|
+
};
|
|
494
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { cleanupTempDir, commitFile, createTempGitRepo } 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
|
+
const proc = Bun.spawn(["git", "rev-parse", "HEAD"], {
|
|
27
|
+
cwd: repoDir,
|
|
28
|
+
stdout: "pipe",
|
|
29
|
+
stderr: "pipe",
|
|
30
|
+
});
|
|
31
|
+
const exitCode = await proc.exited;
|
|
32
|
+
|
|
33
|
+
expect(exitCode).toBe(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("repo is on a branch (not detached HEAD)", async () => {
|
|
37
|
+
repoDir = await createTempGitRepo();
|
|
38
|
+
|
|
39
|
+
const proc = Bun.spawn(["git", "symbolic-ref", "HEAD"], {
|
|
40
|
+
cwd: repoDir,
|
|
41
|
+
stdout: "pipe",
|
|
42
|
+
stderr: "pipe",
|
|
43
|
+
});
|
|
44
|
+
const stdout = await new Response(proc.stdout).text();
|
|
45
|
+
const exitCode = await proc.exited;
|
|
46
|
+
|
|
47
|
+
expect(exitCode).toBe(0);
|
|
48
|
+
expect(stdout.trim()).toMatch(/^refs\/heads\//);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("commitFile", () => {
|
|
53
|
+
let repoDir: string | undefined;
|
|
54
|
+
|
|
55
|
+
afterEach(async () => {
|
|
56
|
+
if (repoDir) {
|
|
57
|
+
await cleanupTempDir(repoDir);
|
|
58
|
+
repoDir = undefined;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("creates file and commits it", async () => {
|
|
63
|
+
repoDir = await createTempGitRepo();
|
|
64
|
+
|
|
65
|
+
await commitFile(repoDir, "hello.txt", "world");
|
|
66
|
+
|
|
67
|
+
// File exists with correct content
|
|
68
|
+
const content = await readFile(join(repoDir, "hello.txt"), "utf-8");
|
|
69
|
+
expect(content).toBe("world");
|
|
70
|
+
|
|
71
|
+
// Git log shows the commit
|
|
72
|
+
const proc = Bun.spawn(["git", "log", "--oneline"], {
|
|
73
|
+
cwd: repoDir,
|
|
74
|
+
stdout: "pipe",
|
|
75
|
+
stderr: "pipe",
|
|
76
|
+
});
|
|
77
|
+
const stdout = await new Response(proc.stdout).text();
|
|
78
|
+
await proc.exited;
|
|
79
|
+
|
|
80
|
+
expect(stdout).toContain("add hello.txt");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("creates nested directories as needed", async () => {
|
|
84
|
+
repoDir = await createTempGitRepo();
|
|
85
|
+
|
|
86
|
+
await commitFile(repoDir, "src/deep/nested/file.ts", "export const x = 1;");
|
|
87
|
+
|
|
88
|
+
expect(existsSync(join(repoDir, "src/deep/nested/file.ts"))).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("uses custom commit message when provided", async () => {
|
|
92
|
+
repoDir = await createTempGitRepo();
|
|
93
|
+
|
|
94
|
+
await commitFile(repoDir, "readme.md", "# Hi", "docs: add readme");
|
|
95
|
+
|
|
96
|
+
const proc = Bun.spawn(["git", "log", "--oneline", "-1"], {
|
|
97
|
+
cwd: repoDir,
|
|
98
|
+
stdout: "pipe",
|
|
99
|
+
stderr: "pipe",
|
|
100
|
+
});
|
|
101
|
+
const stdout = await new Response(proc.stdout).text();
|
|
102
|
+
await proc.exited;
|
|
103
|
+
|
|
104
|
+
expect(stdout).toContain("docs: add readme");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("cleanupTempDir", () => {
|
|
109
|
+
test("removes directory and all contents", async () => {
|
|
110
|
+
const repoDir = await createTempGitRepo();
|
|
111
|
+
await commitFile(repoDir, "file.txt", "data");
|
|
112
|
+
|
|
113
|
+
expect(existsSync(repoDir)).toBe(true);
|
|
114
|
+
|
|
115
|
+
await cleanupTempDir(repoDir);
|
|
116
|
+
|
|
117
|
+
expect(existsSync(repoDir)).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("does not throw when directory does not exist", async () => {
|
|
121
|
+
await cleanupTempDir("/tmp/overstory-nonexistent-test-dir-12345");
|
|
122
|
+
// No error thrown = pass
|
|
123
|
+
});
|
|
124
|
+
});
|