@katyella/legio 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/CHANGELOG.md +422 -0
  2. package/LICENSE +21 -0
  3. package/README.md +555 -0
  4. package/agents/builder.md +141 -0
  5. package/agents/coordinator.md +351 -0
  6. package/agents/cto.md +196 -0
  7. package/agents/gateway.md +276 -0
  8. package/agents/lead.md +281 -0
  9. package/agents/merger.md +156 -0
  10. package/agents/monitor.md +212 -0
  11. package/agents/reviewer.md +142 -0
  12. package/agents/scout.md +131 -0
  13. package/agents/supervisor.md +416 -0
  14. package/bin/legio.mjs +38 -0
  15. package/package.json +77 -0
  16. package/src/agents/checkpoint.test.ts +88 -0
  17. package/src/agents/checkpoint.ts +102 -0
  18. package/src/agents/hooks-deployer.test.ts +1820 -0
  19. package/src/agents/hooks-deployer.ts +574 -0
  20. package/src/agents/identity.test.ts +614 -0
  21. package/src/agents/identity.ts +385 -0
  22. package/src/agents/lifecycle.test.ts +202 -0
  23. package/src/agents/lifecycle.ts +184 -0
  24. package/src/agents/manifest.test.ts +558 -0
  25. package/src/agents/manifest.ts +297 -0
  26. package/src/agents/overlay.test.ts +592 -0
  27. package/src/agents/overlay.ts +316 -0
  28. package/src/beads/client.test.ts +210 -0
  29. package/src/beads/client.ts +227 -0
  30. package/src/beads/molecules.test.ts +320 -0
  31. package/src/beads/molecules.ts +209 -0
  32. package/src/commands/agents.test.ts +325 -0
  33. package/src/commands/agents.ts +286 -0
  34. package/src/commands/clean.test.ts +730 -0
  35. package/src/commands/clean.ts +653 -0
  36. package/src/commands/completions.test.ts +346 -0
  37. package/src/commands/completions.ts +950 -0
  38. package/src/commands/coordinator.test.ts +1524 -0
  39. package/src/commands/coordinator.ts +880 -0
  40. package/src/commands/costs.test.ts +1015 -0
  41. package/src/commands/costs.ts +473 -0
  42. package/src/commands/dashboard.test.ts +94 -0
  43. package/src/commands/dashboard.ts +607 -0
  44. package/src/commands/doctor.test.ts +295 -0
  45. package/src/commands/doctor.ts +213 -0
  46. package/src/commands/down.test.ts +308 -0
  47. package/src/commands/down.ts +124 -0
  48. package/src/commands/errors.test.ts +648 -0
  49. package/src/commands/errors.ts +255 -0
  50. package/src/commands/feed.test.ts +579 -0
  51. package/src/commands/feed.ts +368 -0
  52. package/src/commands/gateway.test.ts +698 -0
  53. package/src/commands/gateway.ts +419 -0
  54. package/src/commands/group.test.ts +262 -0
  55. package/src/commands/group.ts +539 -0
  56. package/src/commands/hooks.test.ts +292 -0
  57. package/src/commands/hooks.ts +210 -0
  58. package/src/commands/init.test.ts +211 -0
  59. package/src/commands/init.ts +622 -0
  60. package/src/commands/inspect.test.ts +670 -0
  61. package/src/commands/inspect.ts +455 -0
  62. package/src/commands/log.test.ts +1556 -0
  63. package/src/commands/log.ts +752 -0
  64. package/src/commands/logs.test.ts +379 -0
  65. package/src/commands/logs.ts +544 -0
  66. package/src/commands/mail.test.ts +1726 -0
  67. package/src/commands/mail.ts +926 -0
  68. package/src/commands/merge.test.ts +676 -0
  69. package/src/commands/merge.ts +374 -0
  70. package/src/commands/metrics.test.ts +444 -0
  71. package/src/commands/metrics.ts +150 -0
  72. package/src/commands/monitor.test.ts +151 -0
  73. package/src/commands/monitor.ts +394 -0
  74. package/src/commands/nudge.test.ts +230 -0
  75. package/src/commands/nudge.ts +373 -0
  76. package/src/commands/prime.test.ts +467 -0
  77. package/src/commands/prime.ts +386 -0
  78. package/src/commands/replay.test.ts +742 -0
  79. package/src/commands/replay.ts +367 -0
  80. package/src/commands/run.test.ts +443 -0
  81. package/src/commands/run.ts +365 -0
  82. package/src/commands/server.test.ts +626 -0
  83. package/src/commands/server.ts +298 -0
  84. package/src/commands/sling.test.ts +810 -0
  85. package/src/commands/sling.ts +700 -0
  86. package/src/commands/spec.test.ts +206 -0
  87. package/src/commands/spec.ts +171 -0
  88. package/src/commands/status.test.ts +276 -0
  89. package/src/commands/status.ts +339 -0
  90. package/src/commands/stop.test.ts +357 -0
  91. package/src/commands/stop.ts +119 -0
  92. package/src/commands/supervisor.test.ts +186 -0
  93. package/src/commands/supervisor.ts +544 -0
  94. package/src/commands/trace.test.ts +746 -0
  95. package/src/commands/trace.ts +332 -0
  96. package/src/commands/up.test.ts +597 -0
  97. package/src/commands/up.ts +275 -0
  98. package/src/commands/watch.test.ts +152 -0
  99. package/src/commands/watch.ts +238 -0
  100. package/src/commands/worktree.test.ts +648 -0
  101. package/src/commands/worktree.ts +266 -0
  102. package/src/config.test.ts +496 -0
  103. package/src/config.ts +616 -0
  104. package/src/doctor/agents.test.ts +448 -0
  105. package/src/doctor/agents.ts +396 -0
  106. package/src/doctor/config-check.test.ts +184 -0
  107. package/src/doctor/config-check.ts +185 -0
  108. package/src/doctor/consistency.test.ts +645 -0
  109. package/src/doctor/consistency.ts +294 -0
  110. package/src/doctor/databases.test.ts +284 -0
  111. package/src/doctor/databases.ts +211 -0
  112. package/src/doctor/dependencies.test.ts +150 -0
  113. package/src/doctor/dependencies.ts +179 -0
  114. package/src/doctor/logs.test.ts +244 -0
  115. package/src/doctor/logs.ts +295 -0
  116. package/src/doctor/merge-queue.test.ts +210 -0
  117. package/src/doctor/merge-queue.ts +144 -0
  118. package/src/doctor/structure.test.ts +285 -0
  119. package/src/doctor/structure.ts +195 -0
  120. package/src/doctor/types.ts +37 -0
  121. package/src/doctor/version.test.ts +130 -0
  122. package/src/doctor/version.ts +131 -0
  123. package/src/e2e/chat-flow.test.ts +346 -0
  124. package/src/e2e/init-sling-lifecycle.test.ts +288 -0
  125. package/src/errors.test.ts +21 -0
  126. package/src/errors.ts +246 -0
  127. package/src/events/store.test.ts +660 -0
  128. package/src/events/store.ts +344 -0
  129. package/src/events/tool-filter.test.ts +330 -0
  130. package/src/events/tool-filter.ts +126 -0
  131. package/src/global-setup.ts +14 -0
  132. package/src/index.ts +339 -0
  133. package/src/insights/analyzer.test.ts +466 -0
  134. package/src/insights/analyzer.ts +203 -0
  135. package/src/logging/color.test.ts +118 -0
  136. package/src/logging/color.ts +71 -0
  137. package/src/logging/logger.test.ts +812 -0
  138. package/src/logging/logger.ts +266 -0
  139. package/src/logging/reporter.test.ts +258 -0
  140. package/src/logging/reporter.ts +109 -0
  141. package/src/logging/sanitizer.test.ts +190 -0
  142. package/src/logging/sanitizer.ts +57 -0
  143. package/src/mail/broadcast.test.ts +203 -0
  144. package/src/mail/broadcast.ts +92 -0
  145. package/src/mail/client.test.ts +873 -0
  146. package/src/mail/client.ts +236 -0
  147. package/src/mail/store.test.ts +815 -0
  148. package/src/mail/store.ts +402 -0
  149. package/src/merge/queue.test.ts +449 -0
  150. package/src/merge/queue.ts +262 -0
  151. package/src/merge/resolver.test.ts +1453 -0
  152. package/src/merge/resolver.ts +759 -0
  153. package/src/metrics/store.test.ts +1167 -0
  154. package/src/metrics/store.ts +511 -0
  155. package/src/metrics/summary.test.ts +397 -0
  156. package/src/metrics/summary.ts +178 -0
  157. package/src/metrics/transcript.test.ts +643 -0
  158. package/src/metrics/transcript.ts +351 -0
  159. package/src/mulch/client.test.ts +547 -0
  160. package/src/mulch/client.ts +416 -0
  161. package/src/server/audit-store.test.ts +384 -0
  162. package/src/server/audit-store.ts +257 -0
  163. package/src/server/headless.test.ts +180 -0
  164. package/src/server/headless.ts +151 -0
  165. package/src/server/index.test.ts +241 -0
  166. package/src/server/index.ts +317 -0
  167. package/src/server/public/app.js +187 -0
  168. package/src/server/public/apple-touch-icon.png +0 -0
  169. package/src/server/public/components/agent-badge.js +37 -0
  170. package/src/server/public/components/data-table.js +114 -0
  171. package/src/server/public/components/gateway-chat.js +256 -0
  172. package/src/server/public/components/issue-card.js +96 -0
  173. package/src/server/public/components/layout.js +88 -0
  174. package/src/server/public/components/message-bubble.js +120 -0
  175. package/src/server/public/components/stat-card.js +26 -0
  176. package/src/server/public/components/terminal-panel.js +140 -0
  177. package/src/server/public/favicon-16.png +0 -0
  178. package/src/server/public/favicon-32.png +0 -0
  179. package/src/server/public/favicon.ico +0 -0
  180. package/src/server/public/favicon.png +0 -0
  181. package/src/server/public/index.html +64 -0
  182. package/src/server/public/lib/api.js +35 -0
  183. package/src/server/public/lib/markdown.js +8 -0
  184. package/src/server/public/lib/preact-setup.js +8 -0
  185. package/src/server/public/lib/state.js +99 -0
  186. package/src/server/public/lib/utils.js +309 -0
  187. package/src/server/public/lib/ws.js +79 -0
  188. package/src/server/public/views/chat.js +983 -0
  189. package/src/server/public/views/costs.js +692 -0
  190. package/src/server/public/views/dashboard.js +781 -0
  191. package/src/server/public/views/gateway-chat.js +622 -0
  192. package/src/server/public/views/inspect.js +399 -0
  193. package/src/server/public/views/issues.js +470 -0
  194. package/src/server/public/views/setup.js +94 -0
  195. package/src/server/public/views/task-detail.js +422 -0
  196. package/src/server/routes.test.ts +3816 -0
  197. package/src/server/routes.ts +1964 -0
  198. package/src/server/websocket.test.ts +288 -0
  199. package/src/server/websocket.ts +196 -0
  200. package/src/sessions/compat.test.ts +109 -0
  201. package/src/sessions/compat.ts +17 -0
  202. package/src/sessions/store.test.ts +969 -0
  203. package/src/sessions/store.ts +480 -0
  204. package/src/test-helpers.test.ts +97 -0
  205. package/src/test-helpers.ts +143 -0
  206. package/src/types.ts +708 -0
  207. package/src/watchdog/daemon.test.ts +1233 -0
  208. package/src/watchdog/daemon.ts +533 -0
  209. package/src/watchdog/health.test.ts +371 -0
  210. package/src/watchdog/health.ts +248 -0
  211. package/src/watchdog/triage.test.ts +162 -0
  212. package/src/watchdog/triage.ts +193 -0
  213. package/src/worktree/manager.test.ts +444 -0
  214. package/src/worktree/manager.ts +224 -0
  215. package/src/worktree/tmux.test.ts +1238 -0
  216. package/src/worktree/tmux.ts +644 -0
  217. package/templates/CLAUDE.md.tmpl +89 -0
  218. package/templates/hooks.json.tmpl +132 -0
  219. package/templates/overlay.md.tmpl +79 -0
@@ -0,0 +1,244 @@
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 { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
12
+ import type { LegioConfig } from "../types.ts";
13
+ import { checkLogs } from "./logs.ts";
14
+
15
+ describe("checkLogs", () => {
16
+ let tempDir: string;
17
+ let legioDir: string;
18
+ let logsDir: string;
19
+ let mockConfig: LegioConfig;
20
+
21
+ beforeEach(async () => {
22
+ tempDir = await mkdtemp(join(tmpdir(), "logs-test-"));
23
+ legioDir = join(tempDir, ".legio");
24
+ logsDir = join(legioDir, "logs");
25
+
26
+ mockConfig = {
27
+ project: {
28
+ name: "test-project",
29
+ root: tempDir,
30
+ canonicalBranch: "main",
31
+ },
32
+ agents: {
33
+ manifestPath: ".legio/agent-manifest.json",
34
+ baseDir: ".legio/agent-defs",
35
+ maxConcurrent: 5,
36
+ staggerDelayMs: 1000,
37
+ maxDepth: 2,
38
+ },
39
+ worktrees: {
40
+ baseDir: ".legio/worktrees",
41
+ },
42
+ beads: {
43
+ enabled: true,
44
+ },
45
+ mulch: {
46
+ enabled: true,
47
+ domains: [],
48
+ primeFormat: "markdown",
49
+ },
50
+ merge: {
51
+ aiResolveEnabled: false,
52
+ reimagineEnabled: false,
53
+ },
54
+ watchdog: {
55
+ tier0Enabled: true,
56
+ tier0IntervalMs: 30000,
57
+ tier1Enabled: false,
58
+ tier2Enabled: false,
59
+ zombieThresholdMs: 600000,
60
+ nudgeIntervalMs: 60000,
61
+ },
62
+ models: {},
63
+ logging: {
64
+ verbose: false,
65
+ redactSecrets: true,
66
+ },
67
+ };
68
+
69
+ await mkdir(legioDir, { recursive: true });
70
+ });
71
+
72
+ afterEach(async () => {
73
+ await rm(tempDir, { recursive: true, force: true });
74
+ });
75
+
76
+ test("warns when logs/ directory does not exist", async () => {
77
+ const checks = await checkLogs(mockConfig, legioDir);
78
+
79
+ const dirCheck = checks.find((c) => c.name === "logs/ directory");
80
+ expect(dirCheck).toBeDefined();
81
+ expect(dirCheck?.status).toBe("warn");
82
+ expect(dirCheck?.message).toContain("missing");
83
+ });
84
+
85
+ test("passes when logs/ directory exists but is empty", async () => {
86
+ await mkdir(logsDir, { recursive: true });
87
+
88
+ const checks = await checkLogs(mockConfig, legioDir);
89
+
90
+ const dirCheck = checks.find((c) => c.name === "logs/ directory");
91
+ expect(dirCheck?.status).toBe("pass");
92
+
93
+ const usageCheck = checks.find((c) => c.name === "Total disk usage");
94
+ expect(usageCheck?.status).toBe("pass");
95
+ expect(usageCheck?.message).toContain("0B");
96
+ });
97
+
98
+ test("calculates total disk usage correctly", async () => {
99
+ await mkdir(logsDir, { recursive: true });
100
+ const agentDir = join(logsDir, "test-agent", "session-1");
101
+ await mkdir(agentDir, { recursive: true });
102
+
103
+ // Create log files with known sizes
104
+ await writeFile(join(agentDir, "session.log"), "a".repeat(1024)); // 1KB
105
+ await writeFile(join(agentDir, "events.ndjson"), "b".repeat(2048)); // 2KB
106
+ await writeFile(join(agentDir, "tools.ndjson"), "c".repeat(512)); // 512B
107
+
108
+ const checks = await checkLogs(mockConfig, legioDir);
109
+
110
+ const usageCheck = checks.find((c) => c.name === "Total disk usage");
111
+ expect(usageCheck).toBeDefined();
112
+ expect(usageCheck?.status).toBe("pass");
113
+ expect(usageCheck?.message).toContain("3.5KB"); // 1024 + 2048 + 512 = 3584 bytes
114
+ });
115
+
116
+ test("warns when disk usage exceeds threshold", async () => {
117
+ await mkdir(logsDir, { recursive: true });
118
+ const agentDir = join(logsDir, "test-agent", "session-1");
119
+ await mkdir(agentDir, { recursive: true });
120
+
121
+ // Create a sparse file > 500MB threshold using truncate (avoids allocating 600MB in memory)
122
+ const { open } = await import("node:fs/promises");
123
+ const fh = await open(join(agentDir, "session.log"), "w");
124
+ await fh.truncate(600 * 1024 * 1024); // 600MB sparse file
125
+ await fh.close();
126
+
127
+ const checks = await checkLogs(mockConfig, legioDir);
128
+
129
+ const usageCheck = checks.find((c) => c.name === "Total disk usage");
130
+ expect(usageCheck).toBeDefined();
131
+ expect(usageCheck?.status).toBe("warn");
132
+ expect(usageCheck?.details).toBeDefined();
133
+ expect(usageCheck?.details?.some((d) => d.includes("threshold"))).toBe(true);
134
+ });
135
+
136
+ test("reports per-agent log sizes", async () => {
137
+ await mkdir(logsDir, { recursive: true });
138
+
139
+ // Create logs for multiple agents
140
+ const agent1Dir = join(logsDir, "agent-1", "session-1");
141
+ await mkdir(agent1Dir, { recursive: true });
142
+ await writeFile(join(agent1Dir, "session.log"), "a".repeat(10000));
143
+
144
+ const agent2Dir = join(logsDir, "agent-2", "session-1");
145
+ await mkdir(agent2Dir, { recursive: true });
146
+ await writeFile(join(agent2Dir, "session.log"), "b".repeat(5000));
147
+
148
+ const checks = await checkLogs(mockConfig, legioDir);
149
+
150
+ const sizesCheck = checks.find((c) => c.name === "Per-agent log sizes");
151
+ expect(sizesCheck).toBeDefined();
152
+ expect(sizesCheck?.status).toBe("pass");
153
+ expect(sizesCheck?.message).toContain("2 agent(s)");
154
+ expect(sizesCheck?.details).toBeDefined();
155
+ expect(sizesCheck?.details?.some((d) => d.includes("agent-1"))).toBe(true);
156
+ expect(sizesCheck?.details?.some((d) => d.includes("agent-2"))).toBe(true);
157
+ });
158
+
159
+ test("detects malformed NDJSON in events.ndjson", async () => {
160
+ await mkdir(logsDir, { recursive: true });
161
+ const agentDir = join(logsDir, "test-agent", "session-1");
162
+ await mkdir(agentDir, { recursive: true });
163
+
164
+ // Valid and invalid JSON lines
165
+ const content = `{"event":"start","timestamp":"2024-01-01T00:00:00Z"}
166
+ invalid json line here
167
+ {"event":"end","timestamp":"2024-01-01T00:01:00Z"}`;
168
+
169
+ await writeFile(join(agentDir, "events.ndjson"), content);
170
+
171
+ const checks = await checkLogs(mockConfig, legioDir);
172
+
173
+ const integrityCheck = checks.find((c) => c.name === "NDJSON integrity");
174
+ expect(integrityCheck).toBeDefined();
175
+ expect(integrityCheck?.status).toBe("warn");
176
+ expect(integrityCheck?.message).toContain("malformed JSON");
177
+ expect(integrityCheck?.details?.some((d) => d.includes("events.ndjson"))).toBe(true);
178
+ });
179
+
180
+ test("passes when all NDJSON files are valid", async () => {
181
+ await mkdir(logsDir, { recursive: true });
182
+ const agentDir = join(logsDir, "test-agent", "session-1");
183
+ await mkdir(agentDir, { recursive: true });
184
+
185
+ const validContent = `{"event":"start","timestamp":"2024-01-01T00:00:00Z"}
186
+ {"event":"end","timestamp":"2024-01-01T00:01:00Z"}`;
187
+
188
+ await writeFile(join(agentDir, "events.ndjson"), validContent);
189
+ await writeFile(join(agentDir, "tools.ndjson"), validContent);
190
+
191
+ const checks = await checkLogs(mockConfig, legioDir);
192
+
193
+ const integrityCheck = checks.find((c) => c.name === "NDJSON integrity");
194
+ expect(integrityCheck).toBeDefined();
195
+ expect(integrityCheck?.status).toBe("pass");
196
+ });
197
+
198
+ test("detects orphaned toolStart events", async () => {
199
+ await mkdir(logsDir, { recursive: true });
200
+ const agentDir = join(logsDir, "test-agent", "session-1");
201
+ await mkdir(agentDir, { recursive: true });
202
+
203
+ // toolStart without matching toolEnd
204
+ const content = `{"event":"toolStart","tool":"Read","timestamp":"2024-01-01T00:00:00Z"}
205
+ {"event":"toolStart","tool":"Write","timestamp":"2024-01-01T00:01:00Z"}
206
+ {"event":"toolEnd","tool":"Write","timestamp":"2024-01-01T00:02:00Z"}`;
207
+
208
+ await writeFile(join(agentDir, "tools.ndjson"), content);
209
+
210
+ const checks = await checkLogs(mockConfig, legioDir);
211
+
212
+ const orphanCheck = checks.find((c) => c.name === "Orphaned tool events");
213
+ expect(orphanCheck).toBeDefined();
214
+ expect(orphanCheck?.status).toBe("warn");
215
+ expect(orphanCheck?.message).toContain("incomplete");
216
+ expect(orphanCheck?.details?.some((d) => d.includes("Read"))).toBe(true);
217
+ });
218
+
219
+ test("handles missing tools.ndjson gracefully", async () => {
220
+ await mkdir(logsDir, { recursive: true });
221
+ const agentDir = join(logsDir, "test-agent", "session-1");
222
+ await mkdir(agentDir, { recursive: true });
223
+
224
+ // Only create events.ndjson, no tools.ndjson
225
+ await writeFile(join(agentDir, "events.ndjson"), '{"event":"start"}');
226
+
227
+ const checks = await checkLogs(mockConfig, legioDir);
228
+
229
+ // Should not crash, should pass NDJSON integrity
230
+ const integrityCheck = checks.find((c) => c.name === "NDJSON integrity");
231
+ expect(integrityCheck).toBeDefined();
232
+ expect(integrityCheck?.status).toBe("pass");
233
+ });
234
+
235
+ test("handles empty logs directory gracefully", async () => {
236
+ await mkdir(logsDir, { recursive: true });
237
+
238
+ const checks = await checkLogs(mockConfig, legioDir);
239
+
240
+ expect(checks.length).toBeGreaterThan(0);
241
+ const dirCheck = checks.find((c) => c.name === "logs/ directory");
242
+ expect(dirCheck?.status).toBe("pass");
243
+ });
244
+ });
@@ -0,0 +1,295 @@
1
+ import { readdir, readFile, 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 readFile(filePath, "utf-8");
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 readFile(filePath, "utf-8");
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, legioDir): Promise<DoctorCheck[]> => {
148
+ const checks: DoctorCheck[] = [];
149
+ const logsDir = join(legioDir, "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 'legio 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
+ };