@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,294 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { openSessionStore } from "../sessions/compat.ts";
|
|
4
|
+
import type { AgentSession, OverstoryConfig } from "../types.ts";
|
|
5
|
+
import { listWorktrees } from "../worktree/manager.ts";
|
|
6
|
+
import { isProcessAlive, listSessions } from "../worktree/tmux.ts";
|
|
7
|
+
import type { DoctorCheck } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Dependencies for consistency checks.
|
|
11
|
+
* Allows injection for testing without module-level mocks.
|
|
12
|
+
*/
|
|
13
|
+
export interface ConsistencyCheckDeps {
|
|
14
|
+
listSessions: () => Promise<Array<{ name: string; pid: number }>>;
|
|
15
|
+
isProcessAlive: (pid: number) => boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Cross-subsystem consistency checks.
|
|
20
|
+
* Validates SessionStore vs worktrees, tmux sessions vs sessions, etc.
|
|
21
|
+
*
|
|
22
|
+
* @param config - Overstory configuration
|
|
23
|
+
* @param overstoryDir - Absolute path to .overstory/
|
|
24
|
+
* @param deps - Optional dependencies for testing (defaults to real implementations)
|
|
25
|
+
*/
|
|
26
|
+
export async function checkConsistency(
|
|
27
|
+
config: OverstoryConfig,
|
|
28
|
+
overstoryDir: string,
|
|
29
|
+
deps?: ConsistencyCheckDeps,
|
|
30
|
+
): Promise<DoctorCheck[]> {
|
|
31
|
+
// Use injected dependencies or defaults
|
|
32
|
+
const { listSessions: listSessionsFn, isProcessAlive: isProcessAliveFn } = deps || {
|
|
33
|
+
listSessions,
|
|
34
|
+
isProcessAlive,
|
|
35
|
+
};
|
|
36
|
+
const checks: DoctorCheck[] = [];
|
|
37
|
+
|
|
38
|
+
// Gather data from all three sources
|
|
39
|
+
let worktrees: Array<{ path: string; branch: string; head: string }> = [];
|
|
40
|
+
let tmuxSessions: Array<{ name: string; pid: number }> = [];
|
|
41
|
+
let storeSessions: AgentSession[] = [];
|
|
42
|
+
|
|
43
|
+
// 1. List git worktrees
|
|
44
|
+
try {
|
|
45
|
+
worktrees = await listWorktrees(config.project.root);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
checks.push({
|
|
48
|
+
name: "worktree-listing",
|
|
49
|
+
category: "consistency",
|
|
50
|
+
status: "fail",
|
|
51
|
+
message: "Failed to list git worktrees",
|
|
52
|
+
details: [error instanceof Error ? error.message : String(error)],
|
|
53
|
+
});
|
|
54
|
+
// Can't continue consistency checks without worktree data
|
|
55
|
+
return checks;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 2. List tmux sessions
|
|
59
|
+
try {
|
|
60
|
+
tmuxSessions = await listSessionsFn();
|
|
61
|
+
} catch (error) {
|
|
62
|
+
// Tmux not installed or not running is not necessarily a fatal error
|
|
63
|
+
checks.push({
|
|
64
|
+
name: "tmux-listing",
|
|
65
|
+
category: "consistency",
|
|
66
|
+
status: "warn",
|
|
67
|
+
message: "Failed to list tmux sessions (tmux may not be installed)",
|
|
68
|
+
details: [error instanceof Error ? error.message : String(error)],
|
|
69
|
+
});
|
|
70
|
+
// Continue with empty tmux session list
|
|
71
|
+
tmuxSessions = [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 3. Open SessionStore and get all sessions
|
|
75
|
+
let storeHandle: ReturnType<typeof openSessionStore>["store"] | null = null;
|
|
76
|
+
try {
|
|
77
|
+
const { store } = openSessionStore(overstoryDir);
|
|
78
|
+
storeHandle = store;
|
|
79
|
+
storeSessions = store.getAll();
|
|
80
|
+
} catch (error) {
|
|
81
|
+
checks.push({
|
|
82
|
+
name: "sessionstore-open",
|
|
83
|
+
category: "consistency",
|
|
84
|
+
status: "fail",
|
|
85
|
+
message: "Failed to open SessionStore",
|
|
86
|
+
details: [error instanceof Error ? error.message : String(error)],
|
|
87
|
+
});
|
|
88
|
+
// Can't do consistency checks without SessionStore
|
|
89
|
+
return checks;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Now perform cross-validation checks
|
|
93
|
+
|
|
94
|
+
// 4. Check for orphaned worktrees (worktree exists but no SessionStore entry)
|
|
95
|
+
// Normalize all paths to handle symlinks like /tmp -> /private/tmp on macOS
|
|
96
|
+
const worktreeBasePath = realpathSync(join(overstoryDir, "worktrees"));
|
|
97
|
+
const overstoryWorktrees = worktrees.filter((wt) => wt.path.startsWith(worktreeBasePath));
|
|
98
|
+
|
|
99
|
+
// Normalize SessionStore paths for comparison
|
|
100
|
+
const storeWorktreePaths = new Set(
|
|
101
|
+
storeSessions.map((s) => {
|
|
102
|
+
try {
|
|
103
|
+
return realpathSync(s.worktreePath);
|
|
104
|
+
} catch {
|
|
105
|
+
// Path doesn't exist, use as-is
|
|
106
|
+
return s.worktreePath;
|
|
107
|
+
}
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const orphanedWorktrees = overstoryWorktrees.filter((wt) => !storeWorktreePaths.has(wt.path));
|
|
112
|
+
|
|
113
|
+
if (orphanedWorktrees.length > 0) {
|
|
114
|
+
checks.push({
|
|
115
|
+
name: "orphaned-worktrees",
|
|
116
|
+
category: "consistency",
|
|
117
|
+
status: "warn",
|
|
118
|
+
message: `Found ${orphanedWorktrees.length} orphaned worktree(s) with no SessionStore entry`,
|
|
119
|
+
details: orphanedWorktrees.map((wt) => `${wt.path} (branch: ${wt.branch})`),
|
|
120
|
+
fixable: true,
|
|
121
|
+
});
|
|
122
|
+
} else {
|
|
123
|
+
checks.push({
|
|
124
|
+
name: "orphaned-worktrees",
|
|
125
|
+
category: "consistency",
|
|
126
|
+
status: "pass",
|
|
127
|
+
message: "No orphaned worktrees found",
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 5. Check for orphaned tmux sessions (tmux session exists but no SessionStore entry)
|
|
132
|
+
const projectName = config.project.name;
|
|
133
|
+
const overstoryTmuxPrefix = `overstory-${projectName}-`;
|
|
134
|
+
const overstoryTmuxSessions = tmuxSessions.filter((s) => s.name.startsWith(overstoryTmuxPrefix));
|
|
135
|
+
const storeTmuxNames = new Set(storeSessions.map((s) => s.tmuxSession));
|
|
136
|
+
|
|
137
|
+
const orphanedTmux = overstoryTmuxSessions.filter((s) => !storeTmuxNames.has(s.name));
|
|
138
|
+
|
|
139
|
+
if (orphanedTmux.length > 0) {
|
|
140
|
+
checks.push({
|
|
141
|
+
name: "orphaned-tmux",
|
|
142
|
+
category: "consistency",
|
|
143
|
+
status: "warn",
|
|
144
|
+
message: `Found ${orphanedTmux.length} orphaned tmux session(s) with no SessionStore entry`,
|
|
145
|
+
details: orphanedTmux.map((s) => `${s.name} (pid: ${s.pid})`),
|
|
146
|
+
fixable: true,
|
|
147
|
+
});
|
|
148
|
+
} else {
|
|
149
|
+
checks.push({
|
|
150
|
+
name: "orphaned-tmux",
|
|
151
|
+
category: "consistency",
|
|
152
|
+
status: "pass",
|
|
153
|
+
message: "No orphaned tmux sessions found",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 6. Check for dead processes in SessionStore
|
|
158
|
+
const deadSessions = storeSessions.filter((s) => s.pid !== null && !isProcessAliveFn(s.pid));
|
|
159
|
+
|
|
160
|
+
if (deadSessions.length > 0) {
|
|
161
|
+
checks.push({
|
|
162
|
+
name: "dead-pids",
|
|
163
|
+
category: "consistency",
|
|
164
|
+
status: "warn",
|
|
165
|
+
message: `Found ${deadSessions.length} session(s) with dead PIDs`,
|
|
166
|
+
details: deadSessions.map((s) => `${s.agentName} (pid: ${s.pid}, state: ${s.state})`),
|
|
167
|
+
fixable: true,
|
|
168
|
+
});
|
|
169
|
+
} else {
|
|
170
|
+
checks.push({
|
|
171
|
+
name: "dead-pids",
|
|
172
|
+
category: "consistency",
|
|
173
|
+
status: "pass",
|
|
174
|
+
message: "All SessionStore PIDs are alive or null",
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 7. Check for SessionStore entries with missing worktrees
|
|
179
|
+
const existingWorktreePaths = new Set(worktrees.map((wt) => wt.path));
|
|
180
|
+
const missingWorktrees = storeSessions.filter((s) => {
|
|
181
|
+
// Try to normalize the SessionStore path for comparison
|
|
182
|
+
try {
|
|
183
|
+
const normalizedPath = realpathSync(s.worktreePath);
|
|
184
|
+
return !existingWorktreePaths.has(normalizedPath);
|
|
185
|
+
} catch {
|
|
186
|
+
// Path doesn't exist or can't be resolved, check as-is
|
|
187
|
+
return !existingWorktreePaths.has(s.worktreePath);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
if (missingWorktrees.length > 0) {
|
|
192
|
+
checks.push({
|
|
193
|
+
name: "missing-worktrees",
|
|
194
|
+
category: "consistency",
|
|
195
|
+
status: "warn",
|
|
196
|
+
message: `Found ${missingWorktrees.length} session(s) with missing worktrees`,
|
|
197
|
+
details: missingWorktrees.map((s) => `${s.agentName}: ${s.worktreePath}`),
|
|
198
|
+
fixable: true,
|
|
199
|
+
});
|
|
200
|
+
} else {
|
|
201
|
+
checks.push({
|
|
202
|
+
name: "missing-worktrees",
|
|
203
|
+
category: "consistency",
|
|
204
|
+
status: "pass",
|
|
205
|
+
message: "All SessionStore worktrees exist",
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 8. Check for SessionStore entries with missing tmux sessions
|
|
210
|
+
const existingTmuxNames = new Set(tmuxSessions.map((s) => s.name));
|
|
211
|
+
const missingTmux = storeSessions.filter((s) => !existingTmuxNames.has(s.tmuxSession));
|
|
212
|
+
|
|
213
|
+
if (missingTmux.length > 0) {
|
|
214
|
+
checks.push({
|
|
215
|
+
name: "missing-tmux",
|
|
216
|
+
category: "consistency",
|
|
217
|
+
status: "warn",
|
|
218
|
+
message: `Found ${missingTmux.length} session(s) with missing tmux sessions`,
|
|
219
|
+
details: missingTmux.map((s) => `${s.agentName}: ${s.tmuxSession}`),
|
|
220
|
+
fixable: true,
|
|
221
|
+
});
|
|
222
|
+
} else {
|
|
223
|
+
checks.push({
|
|
224
|
+
name: "missing-tmux",
|
|
225
|
+
category: "consistency",
|
|
226
|
+
status: "pass",
|
|
227
|
+
message: "All SessionStore tmux sessions exist",
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 9. Check reviewer-to-builder ratio per lead
|
|
232
|
+
const parentGroups = new Map<string, { builders: number; reviewers: number }>();
|
|
233
|
+
for (const session of storeSessions) {
|
|
234
|
+
if (
|
|
235
|
+
session.parentAgent &&
|
|
236
|
+
(session.capability === "builder" || session.capability === "reviewer")
|
|
237
|
+
) {
|
|
238
|
+
const group = parentGroups.get(session.parentAgent) ?? { builders: 0, reviewers: 0 };
|
|
239
|
+
if (session.capability === "builder") {
|
|
240
|
+
group.builders++;
|
|
241
|
+
} else {
|
|
242
|
+
group.reviewers++;
|
|
243
|
+
}
|
|
244
|
+
parentGroups.set(session.parentAgent, group);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const leadsWithoutReview: string[] = [];
|
|
249
|
+
const leadsWithPartialReview: string[] = [];
|
|
250
|
+
for (const [parent, counts] of parentGroups) {
|
|
251
|
+
if (counts.builders > 0 && counts.reviewers === 0) {
|
|
252
|
+
leadsWithoutReview.push(`${parent}: ${counts.builders} builder(s), 0 reviewers`);
|
|
253
|
+
} else if (counts.builders > 0 && counts.reviewers < counts.builders) {
|
|
254
|
+
leadsWithPartialReview.push(
|
|
255
|
+
`${parent}: ${counts.builders} builder(s), ${counts.reviewers} reviewer(s)`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (leadsWithoutReview.length > 0) {
|
|
261
|
+
checks.push({
|
|
262
|
+
name: "reviewer-coverage",
|
|
263
|
+
category: "consistency",
|
|
264
|
+
status: "warn",
|
|
265
|
+
message: `${leadsWithoutReview.length} lead(s) spawned builders without any reviewers`,
|
|
266
|
+
details: [...leadsWithoutReview, ...leadsWithPartialReview],
|
|
267
|
+
});
|
|
268
|
+
} else if (leadsWithPartialReview.length > 0) {
|
|
269
|
+
checks.push({
|
|
270
|
+
name: "reviewer-coverage",
|
|
271
|
+
category: "consistency",
|
|
272
|
+
status: "warn",
|
|
273
|
+
message: `${leadsWithPartialReview.length} lead(s) have partial reviewer coverage`,
|
|
274
|
+
details: leadsWithPartialReview,
|
|
275
|
+
});
|
|
276
|
+
} else {
|
|
277
|
+
checks.push({
|
|
278
|
+
name: "reviewer-coverage",
|
|
279
|
+
category: "consistency",
|
|
280
|
+
status: "pass",
|
|
281
|
+
message:
|
|
282
|
+
parentGroups.size > 0
|
|
283
|
+
? "All leads have reviewer coverage for builders"
|
|
284
|
+
: "No builder sessions found (nothing to check)",
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Close the SessionStore
|
|
289
|
+
if (storeHandle) {
|
|
290
|
+
storeHandle.close();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return checks;
|
|
294
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import type { OverstoryConfig } from "../types.ts";
|
|
7
|
+
import { checkDatabases } from "./databases.ts";
|
|
8
|
+
import type { DoctorCheck } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
describe("checkDatabases", () => {
|
|
11
|
+
let tempDir: string;
|
|
12
|
+
let mockConfig: OverstoryConfig;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
tempDir = mkdtempSync(join(tmpdir(), "overstory-test-"));
|
|
16
|
+
mockConfig = {
|
|
17
|
+
project: { name: "test", root: tempDir, canonicalBranch: "main" },
|
|
18
|
+
agents: {
|
|
19
|
+
manifestPath: "",
|
|
20
|
+
baseDir: "",
|
|
21
|
+
maxConcurrent: 5,
|
|
22
|
+
staggerDelayMs: 100,
|
|
23
|
+
maxDepth: 2,
|
|
24
|
+
maxSessionsPerRun: 0,
|
|
25
|
+
},
|
|
26
|
+
worktrees: { baseDir: "" },
|
|
27
|
+
taskTracker: { backend: "auto", enabled: true },
|
|
28
|
+
mulch: { enabled: true, domains: [], primeFormat: "markdown" },
|
|
29
|
+
merge: { aiResolveEnabled: false, reimagineEnabled: false },
|
|
30
|
+
providers: {
|
|
31
|
+
anthropic: { type: "native" },
|
|
32
|
+
},
|
|
33
|
+
watchdog: {
|
|
34
|
+
tier0Enabled: true,
|
|
35
|
+
tier0IntervalMs: 30000,
|
|
36
|
+
tier1Enabled: false,
|
|
37
|
+
tier2Enabled: false,
|
|
38
|
+
staleThresholdMs: 300000,
|
|
39
|
+
zombieThresholdMs: 600000,
|
|
40
|
+
nudgeIntervalMs: 60000,
|
|
41
|
+
},
|
|
42
|
+
models: {},
|
|
43
|
+
logging: { verbose: false, redactSecrets: true },
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("fails when database files do not exist", () => {
|
|
52
|
+
const checks = checkDatabases(mockConfig, tempDir) as DoctorCheck[];
|
|
53
|
+
|
|
54
|
+
expect(checks).toHaveLength(3);
|
|
55
|
+
expect(checks[0]?.status).toBe("fail");
|
|
56
|
+
expect(checks[0]?.name).toBe("mail.db exists");
|
|
57
|
+
expect(checks[1]?.status).toBe("fail");
|
|
58
|
+
expect(checks[1]?.name).toBe("metrics.db exists");
|
|
59
|
+
expect(checks[2]?.status).toBe("fail");
|
|
60
|
+
expect(checks[2]?.name).toBe("sessions.db exists");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("passes when databases exist with correct schema", () => {
|
|
64
|
+
// Create mail.db
|
|
65
|
+
const mailDb = new Database(join(tempDir, "mail.db"));
|
|
66
|
+
mailDb.exec("PRAGMA journal_mode=WAL");
|
|
67
|
+
mailDb.exec(`
|
|
68
|
+
CREATE TABLE messages (
|
|
69
|
+
id TEXT PRIMARY KEY,
|
|
70
|
+
from_agent TEXT NOT NULL,
|
|
71
|
+
to_agent TEXT NOT NULL,
|
|
72
|
+
subject TEXT NOT NULL,
|
|
73
|
+
body TEXT NOT NULL,
|
|
74
|
+
type TEXT NOT NULL DEFAULT 'status',
|
|
75
|
+
priority TEXT NOT NULL DEFAULT 'normal',
|
|
76
|
+
thread_id TEXT,
|
|
77
|
+
payload TEXT,
|
|
78
|
+
read INTEGER NOT NULL DEFAULT 0,
|
|
79
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
80
|
+
)
|
|
81
|
+
`);
|
|
82
|
+
mailDb.close();
|
|
83
|
+
|
|
84
|
+
// Create metrics.db
|
|
85
|
+
const metricsDb = new Database(join(tempDir, "metrics.db"));
|
|
86
|
+
metricsDb.exec("PRAGMA journal_mode=WAL");
|
|
87
|
+
metricsDb.exec(`
|
|
88
|
+
CREATE TABLE sessions (
|
|
89
|
+
agent_name TEXT NOT NULL,
|
|
90
|
+
task_id TEXT NOT NULL,
|
|
91
|
+
capability TEXT NOT NULL,
|
|
92
|
+
started_at TEXT NOT NULL,
|
|
93
|
+
completed_at TEXT,
|
|
94
|
+
duration_ms INTEGER NOT NULL DEFAULT 0,
|
|
95
|
+
exit_code INTEGER,
|
|
96
|
+
merge_result TEXT,
|
|
97
|
+
parent_agent TEXT,
|
|
98
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
99
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
100
|
+
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
101
|
+
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
|
102
|
+
estimated_cost_usd REAL,
|
|
103
|
+
model_used TEXT,
|
|
104
|
+
PRIMARY KEY (agent_name, task_id)
|
|
105
|
+
)
|
|
106
|
+
`);
|
|
107
|
+
metricsDb.close();
|
|
108
|
+
|
|
109
|
+
// Create sessions.db
|
|
110
|
+
const sessionsDb = new Database(join(tempDir, "sessions.db"));
|
|
111
|
+
sessionsDb.exec("PRAGMA journal_mode=WAL");
|
|
112
|
+
sessionsDb.exec(`
|
|
113
|
+
CREATE TABLE sessions (
|
|
114
|
+
id TEXT PRIMARY KEY,
|
|
115
|
+
agent_name TEXT NOT NULL UNIQUE,
|
|
116
|
+
capability TEXT NOT NULL,
|
|
117
|
+
worktree_path TEXT NOT NULL,
|
|
118
|
+
branch_name TEXT NOT NULL,
|
|
119
|
+
task_id TEXT NOT NULL,
|
|
120
|
+
tmux_session TEXT NOT NULL,
|
|
121
|
+
state TEXT NOT NULL DEFAULT 'booting',
|
|
122
|
+
pid INTEGER,
|
|
123
|
+
parent_agent TEXT,
|
|
124
|
+
depth INTEGER NOT NULL DEFAULT 0,
|
|
125
|
+
run_id TEXT,
|
|
126
|
+
started_at TEXT NOT NULL,
|
|
127
|
+
last_activity TEXT NOT NULL,
|
|
128
|
+
escalation_level INTEGER NOT NULL DEFAULT 0,
|
|
129
|
+
stalled_since TEXT
|
|
130
|
+
)
|
|
131
|
+
`);
|
|
132
|
+
sessionsDb.exec(`
|
|
133
|
+
CREATE TABLE runs (
|
|
134
|
+
id TEXT PRIMARY KEY,
|
|
135
|
+
started_at TEXT NOT NULL,
|
|
136
|
+
completed_at TEXT,
|
|
137
|
+
agent_count INTEGER NOT NULL DEFAULT 0,
|
|
138
|
+
coordinator_session_id TEXT,
|
|
139
|
+
status TEXT NOT NULL DEFAULT 'active'
|
|
140
|
+
)
|
|
141
|
+
`);
|
|
142
|
+
sessionsDb.close();
|
|
143
|
+
|
|
144
|
+
const checks = checkDatabases(mockConfig, tempDir) as DoctorCheck[];
|
|
145
|
+
|
|
146
|
+
expect(checks).toHaveLength(3);
|
|
147
|
+
expect(checks.every((c) => c?.status === "pass")).toBe(true);
|
|
148
|
+
expect(checks[0]?.name).toBe("mail.db health");
|
|
149
|
+
expect(checks[1]?.name).toBe("metrics.db health");
|
|
150
|
+
expect(checks[2]?.name).toBe("sessions.db health");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("fails when table is missing", () => {
|
|
154
|
+
// Create mail.db without messages table
|
|
155
|
+
const mailDb = new Database(join(tempDir, "mail.db"));
|
|
156
|
+
mailDb.exec("PRAGMA journal_mode=WAL");
|
|
157
|
+
mailDb.close();
|
|
158
|
+
|
|
159
|
+
// Create other databases properly to isolate the test
|
|
160
|
+
const metricsDb = new Database(join(tempDir, "metrics.db"));
|
|
161
|
+
metricsDb.exec("PRAGMA journal_mode=WAL");
|
|
162
|
+
metricsDb.exec(`
|
|
163
|
+
CREATE TABLE sessions (
|
|
164
|
+
agent_name TEXT NOT NULL,
|
|
165
|
+
task_id TEXT NOT NULL,
|
|
166
|
+
capability TEXT NOT NULL,
|
|
167
|
+
started_at TEXT NOT NULL,
|
|
168
|
+
completed_at TEXT,
|
|
169
|
+
duration_ms INTEGER NOT NULL DEFAULT 0,
|
|
170
|
+
exit_code INTEGER,
|
|
171
|
+
merge_result TEXT,
|
|
172
|
+
parent_agent TEXT,
|
|
173
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
174
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
175
|
+
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
176
|
+
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
|
177
|
+
estimated_cost_usd REAL,
|
|
178
|
+
model_used TEXT,
|
|
179
|
+
PRIMARY KEY (agent_name, task_id)
|
|
180
|
+
)
|
|
181
|
+
`);
|
|
182
|
+
metricsDb.close();
|
|
183
|
+
|
|
184
|
+
const sessionsDb = new Database(join(tempDir, "sessions.db"));
|
|
185
|
+
sessionsDb.exec("PRAGMA journal_mode=WAL");
|
|
186
|
+
sessionsDb.exec(`
|
|
187
|
+
CREATE TABLE sessions (
|
|
188
|
+
id TEXT PRIMARY KEY,
|
|
189
|
+
agent_name TEXT NOT NULL UNIQUE,
|
|
190
|
+
capability TEXT NOT NULL,
|
|
191
|
+
worktree_path TEXT NOT NULL,
|
|
192
|
+
branch_name TEXT NOT NULL,
|
|
193
|
+
task_id TEXT NOT NULL,
|
|
194
|
+
tmux_session TEXT NOT NULL,
|
|
195
|
+
state TEXT NOT NULL DEFAULT 'booting',
|
|
196
|
+
pid INTEGER,
|
|
197
|
+
parent_agent TEXT,
|
|
198
|
+
depth INTEGER NOT NULL DEFAULT 0,
|
|
199
|
+
run_id TEXT,
|
|
200
|
+
started_at TEXT NOT NULL,
|
|
201
|
+
last_activity TEXT NOT NULL,
|
|
202
|
+
escalation_level INTEGER NOT NULL DEFAULT 0,
|
|
203
|
+
stalled_since TEXT
|
|
204
|
+
)
|
|
205
|
+
`);
|
|
206
|
+
sessionsDb.exec(`
|
|
207
|
+
CREATE TABLE runs (
|
|
208
|
+
id TEXT PRIMARY KEY,
|
|
209
|
+
started_at TEXT NOT NULL,
|
|
210
|
+
completed_at TEXT,
|
|
211
|
+
agent_count INTEGER NOT NULL DEFAULT 0,
|
|
212
|
+
coordinator_session_id TEXT,
|
|
213
|
+
status TEXT NOT NULL DEFAULT 'active'
|
|
214
|
+
)
|
|
215
|
+
`);
|
|
216
|
+
sessionsDb.close();
|
|
217
|
+
|
|
218
|
+
const checks = checkDatabases(mockConfig, tempDir) as DoctorCheck[];
|
|
219
|
+
|
|
220
|
+
const mailCheck = checks.find((c) => c?.name === "mail.db schema");
|
|
221
|
+
expect(mailCheck?.status).toBe("fail");
|
|
222
|
+
expect(mailCheck?.details).toContain("Missing tables: messages");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("fails when column is missing", () => {
|
|
226
|
+
// Create messages table without payload column
|
|
227
|
+
const mailDb = new Database(join(tempDir, "mail.db"));
|
|
228
|
+
mailDb.exec("PRAGMA journal_mode=WAL");
|
|
229
|
+
mailDb.exec(`
|
|
230
|
+
CREATE TABLE messages (
|
|
231
|
+
id TEXT PRIMARY KEY,
|
|
232
|
+
from_agent TEXT NOT NULL,
|
|
233
|
+
to_agent TEXT NOT NULL,
|
|
234
|
+
subject TEXT NOT NULL,
|
|
235
|
+
body TEXT NOT NULL,
|
|
236
|
+
type TEXT NOT NULL DEFAULT 'status',
|
|
237
|
+
priority TEXT NOT NULL DEFAULT 'normal',
|
|
238
|
+
thread_id TEXT,
|
|
239
|
+
read INTEGER NOT NULL DEFAULT 0,
|
|
240
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
241
|
+
)
|
|
242
|
+
`);
|
|
243
|
+
mailDb.close();
|
|
244
|
+
|
|
245
|
+
const checks = checkDatabases(mockConfig, tempDir) as DoctorCheck[];
|
|
246
|
+
|
|
247
|
+
const mailCheck = checks.find((c) => c?.name === "mail.db schema");
|
|
248
|
+
expect(mailCheck?.status).toBe("fail");
|
|
249
|
+
expect(mailCheck?.details?.some((d) => d.includes("missing column: payload"))).toBe(true);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("warns when WAL mode is not enabled", () => {
|
|
253
|
+
// Create database without WAL mode
|
|
254
|
+
const mailDb = new Database(join(tempDir, "mail.db"));
|
|
255
|
+
mailDb.exec(`
|
|
256
|
+
CREATE TABLE messages (
|
|
257
|
+
id TEXT PRIMARY KEY,
|
|
258
|
+
from_agent TEXT NOT NULL,
|
|
259
|
+
to_agent TEXT NOT NULL,
|
|
260
|
+
subject TEXT NOT NULL,
|
|
261
|
+
body TEXT NOT NULL,
|
|
262
|
+
type TEXT NOT NULL DEFAULT 'status',
|
|
263
|
+
priority TEXT NOT NULL DEFAULT 'normal',
|
|
264
|
+
thread_id TEXT,
|
|
265
|
+
payload TEXT,
|
|
266
|
+
read INTEGER NOT NULL DEFAULT 0,
|
|
267
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
268
|
+
)
|
|
269
|
+
`);
|
|
270
|
+
mailDb.close();
|
|
271
|
+
|
|
272
|
+
const checks = checkDatabases(mockConfig, tempDir) as DoctorCheck[];
|
|
273
|
+
|
|
274
|
+
const walCheck = checks.find((c) => c?.name === "mail.db WAL mode");
|
|
275
|
+
expect(walCheck?.status).toBe("warn");
|
|
276
|
+
expect(walCheck?.message).toContain("not using WAL mode");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("fails when database is corrupted", () => {
|
|
280
|
+
// Create a corrupt database file (just write garbage)
|
|
281
|
+
const { writeFileSync } = require("node:fs");
|
|
282
|
+
writeFileSync(join(tempDir, "mail.db"), "not a valid sqlite database");
|
|
283
|
+
|
|
284
|
+
const checks = checkDatabases(mockConfig, tempDir) as DoctorCheck[];
|
|
285
|
+
|
|
286
|
+
const integrityCheck = checks.find((c) => c?.name === "mail.db integrity");
|
|
287
|
+
expect(integrityCheck?.status).toBe("fail");
|
|
288
|
+
expect(integrityCheck?.message).toContain("Failed to open or validate");
|
|
289
|
+
});
|
|
290
|
+
});
|