@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,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for logs doctor checks.
|
|
3
|
+
*
|
|
4
|
+
* Uses temp directories with real filesystem operations.
|
|
5
|
+
* No mocks needed -- all operations are cheap and local.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import type { OverstoryConfig } from "../types.ts";
|
|
13
|
+
import { checkLogs } from "./logs.ts";
|
|
14
|
+
|
|
15
|
+
describe("checkLogs", () => {
|
|
16
|
+
let tempDir: string;
|
|
17
|
+
let overstoryDir: string;
|
|
18
|
+
let logsDir: string;
|
|
19
|
+
let mockConfig: OverstoryConfig;
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
tempDir = await mkdtemp(join(tmpdir(), "logs-test-"));
|
|
23
|
+
overstoryDir = join(tempDir, ".overstory");
|
|
24
|
+
logsDir = join(overstoryDir, "logs");
|
|
25
|
+
|
|
26
|
+
mockConfig = {
|
|
27
|
+
project: {
|
|
28
|
+
name: "test-project",
|
|
29
|
+
root: tempDir,
|
|
30
|
+
canonicalBranch: "main",
|
|
31
|
+
},
|
|
32
|
+
agents: {
|
|
33
|
+
manifestPath: ".overstory/agent-manifest.json",
|
|
34
|
+
baseDir: ".overstory/agent-defs",
|
|
35
|
+
maxConcurrent: 5,
|
|
36
|
+
staggerDelayMs: 1000,
|
|
37
|
+
maxDepth: 2,
|
|
38
|
+
maxSessionsPerRun: 0,
|
|
39
|
+
},
|
|
40
|
+
worktrees: {
|
|
41
|
+
baseDir: ".overstory/worktrees",
|
|
42
|
+
},
|
|
43
|
+
taskTracker: {
|
|
44
|
+
backend: "auto",
|
|
45
|
+
enabled: true,
|
|
46
|
+
},
|
|
47
|
+
mulch: {
|
|
48
|
+
enabled: true,
|
|
49
|
+
domains: [],
|
|
50
|
+
primeFormat: "markdown",
|
|
51
|
+
},
|
|
52
|
+
merge: {
|
|
53
|
+
aiResolveEnabled: false,
|
|
54
|
+
reimagineEnabled: false,
|
|
55
|
+
},
|
|
56
|
+
providers: {
|
|
57
|
+
anthropic: { type: "native" },
|
|
58
|
+
},
|
|
59
|
+
watchdog: {
|
|
60
|
+
tier0Enabled: true,
|
|
61
|
+
tier0IntervalMs: 30000,
|
|
62
|
+
tier1Enabled: false,
|
|
63
|
+
tier2Enabled: false,
|
|
64
|
+
staleThresholdMs: 300000,
|
|
65
|
+
zombieThresholdMs: 600000,
|
|
66
|
+
nudgeIntervalMs: 60000,
|
|
67
|
+
},
|
|
68
|
+
models: {},
|
|
69
|
+
logging: {
|
|
70
|
+
verbose: false,
|
|
71
|
+
redactSecrets: true,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
await mkdir(overstoryDir, { recursive: true });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterEach(async () => {
|
|
79
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("warns when logs/ directory does not exist", async () => {
|
|
83
|
+
const checks = await checkLogs(mockConfig, overstoryDir);
|
|
84
|
+
|
|
85
|
+
const dirCheck = checks.find((c) => c.name === "logs/ directory");
|
|
86
|
+
expect(dirCheck).toBeDefined();
|
|
87
|
+
expect(dirCheck?.status).toBe("warn");
|
|
88
|
+
expect(dirCheck?.message).toContain("missing");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("passes when logs/ directory exists but is empty", async () => {
|
|
92
|
+
await mkdir(logsDir, { recursive: true });
|
|
93
|
+
|
|
94
|
+
const checks = await checkLogs(mockConfig, overstoryDir);
|
|
95
|
+
|
|
96
|
+
const dirCheck = checks.find((c) => c.name === "logs/ directory");
|
|
97
|
+
expect(dirCheck?.status).toBe("pass");
|
|
98
|
+
|
|
99
|
+
const usageCheck = checks.find((c) => c.name === "Total disk usage");
|
|
100
|
+
expect(usageCheck?.status).toBe("pass");
|
|
101
|
+
expect(usageCheck?.message).toContain("0B");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("calculates total disk usage correctly", async () => {
|
|
105
|
+
await mkdir(logsDir, { recursive: true });
|
|
106
|
+
const agentDir = join(logsDir, "test-agent", "session-1");
|
|
107
|
+
await mkdir(agentDir, { recursive: true });
|
|
108
|
+
|
|
109
|
+
// Create log files with known sizes
|
|
110
|
+
await writeFile(join(agentDir, "session.log"), "a".repeat(1024)); // 1KB
|
|
111
|
+
await writeFile(join(agentDir, "events.ndjson"), "b".repeat(2048)); // 2KB
|
|
112
|
+
await writeFile(join(agentDir, "tools.ndjson"), "c".repeat(512)); // 512B
|
|
113
|
+
|
|
114
|
+
const checks = await checkLogs(mockConfig, overstoryDir);
|
|
115
|
+
|
|
116
|
+
const usageCheck = checks.find((c) => c.name === "Total disk usage");
|
|
117
|
+
expect(usageCheck).toBeDefined();
|
|
118
|
+
expect(usageCheck?.status).toBe("pass");
|
|
119
|
+
expect(usageCheck?.message).toContain("3.5KB"); // 1024 + 2048 + 512 = 3584 bytes
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("warns when disk usage exceeds threshold", async () => {
|
|
123
|
+
await mkdir(logsDir, { recursive: true });
|
|
124
|
+
const agentDir = join(logsDir, "test-agent", "session-1");
|
|
125
|
+
await mkdir(agentDir, { recursive: true });
|
|
126
|
+
|
|
127
|
+
// Create a sparse file that reports as 600MB without allocating real disk space
|
|
128
|
+
const filePath = join(agentDir, "session.log");
|
|
129
|
+
const { promises: fsp } = await import("node:fs");
|
|
130
|
+
const fd = await fsp.open(filePath, "w");
|
|
131
|
+
await fd.truncate(600 * 1024 * 1024); // 600MB sparse file
|
|
132
|
+
await fd.close();
|
|
133
|
+
|
|
134
|
+
const checks = await checkLogs(mockConfig, overstoryDir);
|
|
135
|
+
|
|
136
|
+
const usageCheck = checks.find((c) => c.name === "Total disk usage");
|
|
137
|
+
expect(usageCheck).toBeDefined();
|
|
138
|
+
expect(usageCheck?.status).toBe("warn");
|
|
139
|
+
expect(usageCheck?.details).toBeDefined();
|
|
140
|
+
expect(usageCheck?.details?.some((d) => d.includes("threshold"))).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("reports per-agent log sizes", async () => {
|
|
144
|
+
await mkdir(logsDir, { recursive: true });
|
|
145
|
+
|
|
146
|
+
// Create logs for multiple agents
|
|
147
|
+
const agent1Dir = join(logsDir, "agent-1", "session-1");
|
|
148
|
+
await mkdir(agent1Dir, { recursive: true });
|
|
149
|
+
await writeFile(join(agent1Dir, "session.log"), "a".repeat(10000));
|
|
150
|
+
|
|
151
|
+
const agent2Dir = join(logsDir, "agent-2", "session-1");
|
|
152
|
+
await mkdir(agent2Dir, { recursive: true });
|
|
153
|
+
await writeFile(join(agent2Dir, "session.log"), "b".repeat(5000));
|
|
154
|
+
|
|
155
|
+
const checks = await checkLogs(mockConfig, overstoryDir);
|
|
156
|
+
|
|
157
|
+
const sizesCheck = checks.find((c) => c.name === "Per-agent log sizes");
|
|
158
|
+
expect(sizesCheck).toBeDefined();
|
|
159
|
+
expect(sizesCheck?.status).toBe("pass");
|
|
160
|
+
expect(sizesCheck?.message).toContain("2 agent(s)");
|
|
161
|
+
expect(sizesCheck?.details).toBeDefined();
|
|
162
|
+
expect(sizesCheck?.details?.some((d) => d.includes("agent-1"))).toBe(true);
|
|
163
|
+
expect(sizesCheck?.details?.some((d) => d.includes("agent-2"))).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("detects malformed NDJSON in events.ndjson", async () => {
|
|
167
|
+
await mkdir(logsDir, { recursive: true });
|
|
168
|
+
const agentDir = join(logsDir, "test-agent", "session-1");
|
|
169
|
+
await mkdir(agentDir, { recursive: true });
|
|
170
|
+
|
|
171
|
+
// Valid and invalid JSON lines
|
|
172
|
+
const content = `{"event":"start","timestamp":"2024-01-01T00:00:00Z"}
|
|
173
|
+
invalid json line here
|
|
174
|
+
{"event":"end","timestamp":"2024-01-01T00:01:00Z"}`;
|
|
175
|
+
|
|
176
|
+
await writeFile(join(agentDir, "events.ndjson"), content);
|
|
177
|
+
|
|
178
|
+
const checks = await checkLogs(mockConfig, overstoryDir);
|
|
179
|
+
|
|
180
|
+
const integrityCheck = checks.find((c) => c.name === "NDJSON integrity");
|
|
181
|
+
expect(integrityCheck).toBeDefined();
|
|
182
|
+
expect(integrityCheck?.status).toBe("warn");
|
|
183
|
+
expect(integrityCheck?.message).toContain("malformed JSON");
|
|
184
|
+
expect(integrityCheck?.details?.some((d) => d.includes("events.ndjson"))).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("passes when all NDJSON files are valid", async () => {
|
|
188
|
+
await mkdir(logsDir, { recursive: true });
|
|
189
|
+
const agentDir = join(logsDir, "test-agent", "session-1");
|
|
190
|
+
await mkdir(agentDir, { recursive: true });
|
|
191
|
+
|
|
192
|
+
const validContent = `{"event":"start","timestamp":"2024-01-01T00:00:00Z"}
|
|
193
|
+
{"event":"end","timestamp":"2024-01-01T00:01:00Z"}`;
|
|
194
|
+
|
|
195
|
+
await writeFile(join(agentDir, "events.ndjson"), validContent);
|
|
196
|
+
await writeFile(join(agentDir, "tools.ndjson"), validContent);
|
|
197
|
+
|
|
198
|
+
const checks = await checkLogs(mockConfig, overstoryDir);
|
|
199
|
+
|
|
200
|
+
const integrityCheck = checks.find((c) => c.name === "NDJSON integrity");
|
|
201
|
+
expect(integrityCheck).toBeDefined();
|
|
202
|
+
expect(integrityCheck?.status).toBe("pass");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("detects orphaned toolStart events", async () => {
|
|
206
|
+
await mkdir(logsDir, { recursive: true });
|
|
207
|
+
const agentDir = join(logsDir, "test-agent", "session-1");
|
|
208
|
+
await mkdir(agentDir, { recursive: true });
|
|
209
|
+
|
|
210
|
+
// toolStart without matching toolEnd
|
|
211
|
+
const content = `{"event":"toolStart","tool":"Read","timestamp":"2024-01-01T00:00:00Z"}
|
|
212
|
+
{"event":"toolStart","tool":"Write","timestamp":"2024-01-01T00:01:00Z"}
|
|
213
|
+
{"event":"toolEnd","tool":"Write","timestamp":"2024-01-01T00:02:00Z"}`;
|
|
214
|
+
|
|
215
|
+
await writeFile(join(agentDir, "tools.ndjson"), content);
|
|
216
|
+
|
|
217
|
+
const checks = await checkLogs(mockConfig, overstoryDir);
|
|
218
|
+
|
|
219
|
+
const orphanCheck = checks.find((c) => c.name === "Orphaned tool events");
|
|
220
|
+
expect(orphanCheck).toBeDefined();
|
|
221
|
+
expect(orphanCheck?.status).toBe("warn");
|
|
222
|
+
expect(orphanCheck?.message).toContain("incomplete");
|
|
223
|
+
expect(orphanCheck?.details?.some((d) => d.includes("Read"))).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("handles missing tools.ndjson gracefully", async () => {
|
|
227
|
+
await mkdir(logsDir, { recursive: true });
|
|
228
|
+
const agentDir = join(logsDir, "test-agent", "session-1");
|
|
229
|
+
await mkdir(agentDir, { recursive: true });
|
|
230
|
+
|
|
231
|
+
// Only create events.ndjson, no tools.ndjson
|
|
232
|
+
await writeFile(join(agentDir, "events.ndjson"), '{"event":"start"}');
|
|
233
|
+
|
|
234
|
+
const checks = await checkLogs(mockConfig, overstoryDir);
|
|
235
|
+
|
|
236
|
+
// Should not crash, should pass NDJSON integrity
|
|
237
|
+
const integrityCheck = checks.find((c) => c.name === "NDJSON integrity");
|
|
238
|
+
expect(integrityCheck).toBeDefined();
|
|
239
|
+
expect(integrityCheck?.status).toBe("pass");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("handles empty logs directory gracefully", async () => {
|
|
243
|
+
await mkdir(logsDir, { recursive: true });
|
|
244
|
+
|
|
245
|
+
const checks = await checkLogs(mockConfig, overstoryDir);
|
|
246
|
+
|
|
247
|
+
expect(checks.length).toBeGreaterThan(0);
|
|
248
|
+
const dirCheck = checks.find((c) => c.name === "logs/ directory");
|
|
249
|
+
expect(dirCheck?.status).toBe("pass");
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { DoctorCheck, DoctorCheckFn } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
const DISK_USAGE_WARN_THRESHOLD = 500 * 1024 * 1024; // 500MB in bytes
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Check if a path exists.
|
|
9
|
+
*/
|
|
10
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
11
|
+
try {
|
|
12
|
+
await stat(path);
|
|
13
|
+
return true;
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Calculate total disk usage for a directory recursively.
|
|
21
|
+
*/
|
|
22
|
+
async function calculateDiskUsage(dirPath: string): Promise<number> {
|
|
23
|
+
let totalBytes = 0;
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
27
|
+
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
const fullPath = join(dirPath, entry.name);
|
|
30
|
+
|
|
31
|
+
if (entry.isDirectory()) {
|
|
32
|
+
totalBytes += await calculateDiskUsage(fullPath);
|
|
33
|
+
} else if (entry.isFile()) {
|
|
34
|
+
const stats = await stat(fullPath);
|
|
35
|
+
totalBytes += stats.size;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// Ignore errors (permission denied, etc.)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return totalBytes;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Format bytes as human-readable size.
|
|
47
|
+
*/
|
|
48
|
+
function formatBytes(bytes: number): string {
|
|
49
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
50
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
51
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
52
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check NDJSON file for malformed lines.
|
|
57
|
+
*/
|
|
58
|
+
async function checkNDJSON(filePath: string): Promise<string[]> {
|
|
59
|
+
const errors: string[] = [];
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const content = await Bun.file(filePath).text();
|
|
63
|
+
const lines = content.split("\n").filter((line) => line.trim() !== "");
|
|
64
|
+
|
|
65
|
+
for (let i = 0; i < lines.length; i++) {
|
|
66
|
+
const line = lines[i];
|
|
67
|
+
if (!line) continue;
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
JSON.parse(line);
|
|
71
|
+
} catch {
|
|
72
|
+
errors.push(`Line ${i + 1}: malformed JSON`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// File doesn't exist or can't be read
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return errors;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check tools.ndjson for orphaned toolStart events.
|
|
84
|
+
*/
|
|
85
|
+
async function checkOrphanedToolEvents(filePath: string): Promise<string[]> {
|
|
86
|
+
const orphans: string[] = [];
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const content = await Bun.file(filePath).text();
|
|
90
|
+
const lines = content.split("\n").filter((line) => line.trim() !== "");
|
|
91
|
+
|
|
92
|
+
const startEvents = new Set<string>();
|
|
93
|
+
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
if (!line) continue;
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const event = JSON.parse(line) as { event?: string; tool?: string; data?: unknown };
|
|
99
|
+
|
|
100
|
+
if (event.event === "toolStart" && typeof event.tool === "string") {
|
|
101
|
+
startEvents.add(event.tool);
|
|
102
|
+
} else if (event.event === "toolEnd" && typeof event.tool === "string") {
|
|
103
|
+
startEvents.delete(event.tool);
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// Ignore malformed lines
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const tool of startEvents) {
|
|
111
|
+
orphans.push(tool);
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// File doesn't exist or can't be read
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return orphans;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get per-agent log sizes.
|
|
122
|
+
*/
|
|
123
|
+
async function getPerAgentSizes(logsDir: string): Promise<Map<string, number>> {
|
|
124
|
+
const sizes = new Map<string, number>();
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const agentDirs = await readdir(logsDir, { withFileTypes: true });
|
|
128
|
+
|
|
129
|
+
for (const agentDir of agentDirs) {
|
|
130
|
+
if (!agentDir.isDirectory()) continue;
|
|
131
|
+
|
|
132
|
+
const agentPath = join(logsDir, agentDir.name);
|
|
133
|
+
const size = await calculateDiskUsage(agentPath);
|
|
134
|
+
sizes.set(agentDir.name, size);
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
// Logs directory doesn't exist or can't be read
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return sizes;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Log directory health checks.
|
|
145
|
+
* Validates log directory structure and detects excessive log accumulation.
|
|
146
|
+
*/
|
|
147
|
+
export const checkLogs: DoctorCheckFn = async (_config, overstoryDir): Promise<DoctorCheck[]> => {
|
|
148
|
+
const checks: DoctorCheck[] = [];
|
|
149
|
+
const logsDir = join(overstoryDir, "logs");
|
|
150
|
+
|
|
151
|
+
// Check 1: logs/ directory exists
|
|
152
|
+
const logsDirExists = await pathExists(logsDir);
|
|
153
|
+
checks.push({
|
|
154
|
+
name: "logs/ directory",
|
|
155
|
+
category: "logs",
|
|
156
|
+
status: logsDirExists ? "pass" : "warn",
|
|
157
|
+
message: logsDirExists
|
|
158
|
+
? "Directory exists"
|
|
159
|
+
: "Directory missing (will be created on first log)",
|
|
160
|
+
details: logsDirExists ? undefined : ["Not an error - created automatically on agent spawn"],
|
|
161
|
+
fixable: false,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// If logs/ doesn't exist, no further checks
|
|
165
|
+
if (!logsDirExists) {
|
|
166
|
+
return checks;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Check 2: Total disk usage
|
|
170
|
+
const totalBytes = await calculateDiskUsage(logsDir);
|
|
171
|
+
checks.push({
|
|
172
|
+
name: "Total disk usage",
|
|
173
|
+
category: "logs",
|
|
174
|
+
status: totalBytes > DISK_USAGE_WARN_THRESHOLD ? "warn" : "pass",
|
|
175
|
+
message: `Using ${formatBytes(totalBytes)}`,
|
|
176
|
+
details:
|
|
177
|
+
totalBytes > DISK_USAGE_WARN_THRESHOLD
|
|
178
|
+
? [
|
|
179
|
+
`Exceeds ${formatBytes(DISK_USAGE_WARN_THRESHOLD)} threshold`,
|
|
180
|
+
"Consider running 'overstory worktree clean --completed' to remove old logs",
|
|
181
|
+
]
|
|
182
|
+
: undefined,
|
|
183
|
+
fixable: totalBytes > DISK_USAGE_WARN_THRESHOLD,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Check 3: Per-agent log sizes
|
|
187
|
+
const perAgentSizes = await getPerAgentSizes(logsDir);
|
|
188
|
+
if (perAgentSizes.size > 0) {
|
|
189
|
+
const details = Array.from(perAgentSizes.entries())
|
|
190
|
+
.sort((a, b) => b[1] - a[1]) // Sort by size descending
|
|
191
|
+
.map(([agent, size]) => `${agent}: ${formatBytes(size)}`);
|
|
192
|
+
|
|
193
|
+
checks.push({
|
|
194
|
+
name: "Per-agent log sizes",
|
|
195
|
+
category: "logs",
|
|
196
|
+
status: "pass",
|
|
197
|
+
message: `${perAgentSizes.size} agent(s) with logs`,
|
|
198
|
+
details,
|
|
199
|
+
fixable: false,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Check 4: Sample NDJSON files for malformed JSON
|
|
204
|
+
// We'll check a few random session directories
|
|
205
|
+
try {
|
|
206
|
+
const agentDirs = await readdir(logsDir, { withFileTypes: true });
|
|
207
|
+
const ndjsonErrors: string[] = [];
|
|
208
|
+
|
|
209
|
+
for (const agentDir of agentDirs) {
|
|
210
|
+
if (!agentDir.isDirectory()) continue;
|
|
211
|
+
|
|
212
|
+
const agentPath = join(logsDir, agentDir.name);
|
|
213
|
+
const sessionDirs = await readdir(agentPath, { withFileTypes: true });
|
|
214
|
+
|
|
215
|
+
for (const sessionDir of sessionDirs) {
|
|
216
|
+
if (!sessionDir.isDirectory()) continue;
|
|
217
|
+
|
|
218
|
+
const sessionPath = join(agentPath, sessionDir.name);
|
|
219
|
+
|
|
220
|
+
// Check events.ndjson
|
|
221
|
+
const eventsErrors = await checkNDJSON(join(sessionPath, "events.ndjson"));
|
|
222
|
+
if (eventsErrors.length > 0) {
|
|
223
|
+
ndjsonErrors.push(
|
|
224
|
+
`${agentDir.name}/${sessionDir.name}/events.ndjson: ${eventsErrors.length} error(s)`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Check tools.ndjson
|
|
229
|
+
const toolsErrors = await checkNDJSON(join(sessionPath, "tools.ndjson"));
|
|
230
|
+
if (toolsErrors.length > 0) {
|
|
231
|
+
ndjsonErrors.push(
|
|
232
|
+
`${agentDir.name}/${sessionDir.name}/tools.ndjson: ${toolsErrors.length} error(s)`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (ndjsonErrors.length > 0) {
|
|
239
|
+
checks.push({
|
|
240
|
+
name: "NDJSON integrity",
|
|
241
|
+
category: "logs",
|
|
242
|
+
status: "warn",
|
|
243
|
+
message: `Found ${ndjsonErrors.length} file(s) with malformed JSON`,
|
|
244
|
+
details: ndjsonErrors.slice(0, 10), // Limit to first 10
|
|
245
|
+
fixable: false,
|
|
246
|
+
});
|
|
247
|
+
} else {
|
|
248
|
+
checks.push({
|
|
249
|
+
name: "NDJSON integrity",
|
|
250
|
+
category: "logs",
|
|
251
|
+
status: "pass",
|
|
252
|
+
message: "All sampled NDJSON files are valid",
|
|
253
|
+
fixable: false,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Check 5: Orphaned toolStart events
|
|
258
|
+
const orphanedTools: string[] = [];
|
|
259
|
+
|
|
260
|
+
for (const agentDir of agentDirs) {
|
|
261
|
+
if (!agentDir.isDirectory()) continue;
|
|
262
|
+
|
|
263
|
+
const agentPath = join(logsDir, agentDir.name);
|
|
264
|
+
const sessionDirs = await readdir(agentPath, { withFileTypes: true });
|
|
265
|
+
|
|
266
|
+
for (const sessionDir of sessionDirs) {
|
|
267
|
+
if (!sessionDir.isDirectory()) continue;
|
|
268
|
+
|
|
269
|
+
const sessionPath = join(agentPath, sessionDir.name);
|
|
270
|
+
const orphans = await checkOrphanedToolEvents(join(sessionPath, "tools.ndjson"));
|
|
271
|
+
|
|
272
|
+
if (orphans.length > 0) {
|
|
273
|
+
orphanedTools.push(
|
|
274
|
+
`${agentDir.name}/${sessionDir.name}: ${orphans.join(", ")} (incomplete session)`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (orphanedTools.length > 0) {
|
|
281
|
+
checks.push({
|
|
282
|
+
name: "Orphaned tool events",
|
|
283
|
+
category: "logs",
|
|
284
|
+
status: "warn",
|
|
285
|
+
message: `Found ${orphanedTools.length} session(s) with incomplete tool logs`,
|
|
286
|
+
details: orphanedTools.slice(0, 10), // Limit to first 10
|
|
287
|
+
fixable: false,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
} catch {
|
|
291
|
+
// Ignore errors reading log directories
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return checks;
|
|
295
|
+
};
|