@a5c-ai/babysitter-observer-dashboard 1.0.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 (205) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +490 -0
  3. package/next.config.mjs +25 -0
  4. package/package.json +104 -0
  5. package/postcss.config.mjs +8 -0
  6. package/src/app/actions/__tests__/approve-breakpoint.test.ts +246 -0
  7. package/src/app/actions/approve-breakpoint.ts +145 -0
  8. package/src/app/api/config/route.ts +137 -0
  9. package/src/app/api/digest/route.ts +45 -0
  10. package/src/app/api/runs/[runId]/events/route.ts +56 -0
  11. package/src/app/api/runs/[runId]/route.ts +84 -0
  12. package/src/app/api/runs/[runId]/tasks/[effectId]/route.ts +44 -0
  13. package/src/app/api/runs/route.ts +48 -0
  14. package/src/app/api/stream/route.ts +136 -0
  15. package/src/app/api/test/route.ts +1 -0
  16. package/src/app/api/version/route.ts +57 -0
  17. package/src/app/globals.css +555 -0
  18. package/src/app/icon.svg +20 -0
  19. package/src/app/layout.tsx +39 -0
  20. package/src/app/not-found.tsx +16 -0
  21. package/src/app/page.tsx +120 -0
  22. package/src/app/runs/[runId]/page.tsx +279 -0
  23. package/src/cli.ts +271 -0
  24. package/src/components/breakpoint/__tests__/breakpoint-approval.test.tsx +212 -0
  25. package/src/components/breakpoint/__tests__/breakpoint-panel.test.tsx +130 -0
  26. package/src/components/breakpoint/__tests__/file-preview.test.tsx +313 -0
  27. package/src/components/breakpoint/breakpoint-approval.tsx +138 -0
  28. package/src/components/breakpoint/breakpoint-panel.tsx +95 -0
  29. package/src/components/breakpoint/file-preview.tsx +215 -0
  30. package/src/components/dashboard/.gitkeep +0 -0
  31. package/src/components/dashboard/__tests__/breakpoint-banner.test.tsx +177 -0
  32. package/src/components/dashboard/__tests__/catch-up-banner.test.tsx +141 -0
  33. package/src/components/dashboard/__tests__/executive-summary-banner.test.tsx +164 -0
  34. package/src/components/dashboard/__tests__/kpi-grid.test.tsx +101 -0
  35. package/src/components/dashboard/__tests__/pagination-controls.test.tsx +125 -0
  36. package/src/components/dashboard/__tests__/project-accordion.test.tsx +97 -0
  37. package/src/components/dashboard/__tests__/project-list-view.test.tsx +174 -0
  38. package/src/components/dashboard/__tests__/project-search-input.test.tsx +110 -0
  39. package/src/components/dashboard/__tests__/project-section-header.test.tsx +91 -0
  40. package/src/components/dashboard/__tests__/project-section.test.tsx +151 -0
  41. package/src/components/dashboard/__tests__/run-card.test.tsx +164 -0
  42. package/src/components/dashboard/__tests__/run-filter-bar.test.tsx +109 -0
  43. package/src/components/dashboard/__tests__/run-list.test.tsx +123 -0
  44. package/src/components/dashboard/__tests__/search-filter.test.tsx +150 -0
  45. package/src/components/dashboard/__tests__/virtualized-run-list.test.tsx +179 -0
  46. package/src/components/dashboard/breakpoint-banner.tsx +301 -0
  47. package/src/components/dashboard/catch-up-banner.tsx +88 -0
  48. package/src/components/dashboard/executive-summary-banner.tsx +174 -0
  49. package/src/components/dashboard/global-search.tsx +323 -0
  50. package/src/components/dashboard/kpi-grid.tsx +140 -0
  51. package/src/components/dashboard/pagination-controls.tsx +100 -0
  52. package/src/components/dashboard/project-accordion.tsx +72 -0
  53. package/src/components/dashboard/project-health-card.tsx +536 -0
  54. package/src/components/dashboard/project-list-view.tsx +246 -0
  55. package/src/components/dashboard/project-search-input.tsx +41 -0
  56. package/src/components/dashboard/project-section-header.tsx +73 -0
  57. package/src/components/dashboard/project-section.tsx +89 -0
  58. package/src/components/dashboard/run-card.tsx +218 -0
  59. package/src/components/dashboard/run-filter-bar.tsx +100 -0
  60. package/src/components/dashboard/run-list.tsx +77 -0
  61. package/src/components/dashboard/search-filter.tsx +69 -0
  62. package/src/components/dashboard/virtualized-run-list.tsx +130 -0
  63. package/src/components/details/.gitkeep +0 -0
  64. package/src/components/details/__tests__/agent-panel.test.tsx +236 -0
  65. package/src/components/details/__tests__/json-tree.test.tsx +347 -0
  66. package/src/components/details/__tests__/log-viewer.test.tsx +168 -0
  67. package/src/components/details/__tests__/task-detail.test.tsx +212 -0
  68. package/src/components/details/__tests__/timing-panel.test.tsx +271 -0
  69. package/src/components/details/agent-panel.tsx +234 -0
  70. package/src/components/details/json-tree/categorize.ts +131 -0
  71. package/src/components/details/json-tree/index.tsx +120 -0
  72. package/src/components/details/json-tree/json-node.tsx +223 -0
  73. package/src/components/details/json-tree/smart-summary.tsx +596 -0
  74. package/src/components/details/json-tree/tree-controls.tsx +47 -0
  75. package/src/components/details/json-tree.tsx +9 -0
  76. package/src/components/details/log-viewer.tsx +140 -0
  77. package/src/components/details/task-detail.tsx +114 -0
  78. package/src/components/details/timing-panel.tsx +247 -0
  79. package/src/components/events/.gitkeep +0 -0
  80. package/src/components/events/__tests__/event-item.test.tsx +211 -0
  81. package/src/components/events/__tests__/event-stream.test.tsx +225 -0
  82. package/src/components/events/event-item.tsx +121 -0
  83. package/src/components/events/event-stream.tsx +260 -0
  84. package/src/components/notifications/.gitkeep +0 -0
  85. package/src/components/notifications/__tests__/notification-panel.test.tsx +287 -0
  86. package/src/components/notifications/__tests__/notification-provider.test.tsx +585 -0
  87. package/src/components/notifications/__tests__/toast-stack.test.tsx +217 -0
  88. package/src/components/notifications/notification-panel.tsx +124 -0
  89. package/src/components/notifications/notification-provider.tsx +175 -0
  90. package/src/components/notifications/toast-stack.tsx +75 -0
  91. package/src/components/pipeline/.gitkeep +0 -0
  92. package/src/components/pipeline/__tests__/parallel-group.test.tsx +88 -0
  93. package/src/components/pipeline/__tests__/pipeline-view.test.tsx +345 -0
  94. package/src/components/pipeline/__tests__/step-card.test.tsx +330 -0
  95. package/src/components/pipeline/parallel-group.tsx +39 -0
  96. package/src/components/pipeline/pipeline-view.tsx +197 -0
  97. package/src/components/pipeline/step-card.tsx +166 -0
  98. package/src/components/providers/event-stream-provider.tsx +29 -0
  99. package/src/components/providers.tsx +24 -0
  100. package/src/components/shared/.gitkeep +0 -0
  101. package/src/components/shared/__tests__/empty-state.test.tsx +49 -0
  102. package/src/components/shared/__tests__/friendly-id.test.tsx +47 -0
  103. package/src/components/shared/__tests__/kbd.test.tsx +45 -0
  104. package/src/components/shared/__tests__/kind-badge.test.tsx +71 -0
  105. package/src/components/shared/__tests__/metrics-row.test.tsx +74 -0
  106. package/src/components/shared/__tests__/outcome-banner.test.tsx +71 -0
  107. package/src/components/shared/__tests__/progress-bar.test.tsx +89 -0
  108. package/src/components/shared/__tests__/session-pill.test.tsx +62 -0
  109. package/src/components/shared/__tests__/settings-modal.test.tsx +201 -0
  110. package/src/components/shared/__tests__/shortcuts-help.test.tsx +103 -0
  111. package/src/components/shared/__tests__/status-badge.test.tsx +98 -0
  112. package/src/components/shared/__tests__/theme-provider.test.tsx +100 -0
  113. package/src/components/shared/__tests__/truncated-id.test.tsx +53 -0
  114. package/src/components/shared/app-footer.tsx +80 -0
  115. package/src/components/shared/app-header.tsx +160 -0
  116. package/src/components/shared/empty-state.tsx +18 -0
  117. package/src/components/shared/error-boundary.tsx +81 -0
  118. package/src/components/shared/friendly-id.tsx +48 -0
  119. package/src/components/shared/kbd.tsx +15 -0
  120. package/src/components/shared/kind-badge.tsx +51 -0
  121. package/src/components/shared/metrics-row.tsx +106 -0
  122. package/src/components/shared/outcome-banner.tsx +56 -0
  123. package/src/components/shared/progress-bar.tsx +42 -0
  124. package/src/components/shared/session-pill.tsx +69 -0
  125. package/src/components/shared/settings-modal.tsx +509 -0
  126. package/src/components/shared/shortcuts-help.tsx +113 -0
  127. package/src/components/shared/status-badge.tsx +110 -0
  128. package/src/components/shared/theme-provider.tsx +46 -0
  129. package/src/components/shared/truncated-id.tsx +51 -0
  130. package/src/components/ui/.gitkeep +0 -0
  131. package/src/components/ui/__tests__/accordion.test.tsx +96 -0
  132. package/src/components/ui/__tests__/badge.test.tsx +69 -0
  133. package/src/components/ui/__tests__/button.test.tsx +113 -0
  134. package/src/components/ui/__tests__/tabs.test.tsx +75 -0
  135. package/src/components/ui/__tests__/tooltip.test.tsx +90 -0
  136. package/src/components/ui/accordion.tsx +61 -0
  137. package/src/components/ui/badge.tsx +25 -0
  138. package/src/components/ui/button.tsx +40 -0
  139. package/src/components/ui/card.tsx +21 -0
  140. package/src/components/ui/scroll-area.tsx +35 -0
  141. package/src/components/ui/separator.tsx +24 -0
  142. package/src/components/ui/tabs.tsx +64 -0
  143. package/src/components/ui/tooltip.tsx +37 -0
  144. package/src/hooks/.gitkeep +0 -0
  145. package/src/hooks/__tests__/use-animated-number.test.ts +184 -0
  146. package/src/hooks/__tests__/use-batched-updates.test.ts +315 -0
  147. package/src/hooks/__tests__/use-event-stream.test.ts +243 -0
  148. package/src/hooks/__tests__/use-keyboard.test.ts +217 -0
  149. package/src/hooks/__tests__/use-notifications.test.ts +230 -0
  150. package/src/hooks/__tests__/use-polling.test.ts +274 -0
  151. package/src/hooks/__tests__/use-project-runs.test.ts +163 -0
  152. package/src/hooks/__tests__/use-projects.test.ts +248 -0
  153. package/src/hooks/__tests__/use-run-dashboard.test.ts +168 -0
  154. package/src/hooks/__tests__/use-run-detail.test.ts +273 -0
  155. package/src/hooks/__tests__/use-smart-polling.test.ts +305 -0
  156. package/src/hooks/use-animated-number.ts +87 -0
  157. package/src/hooks/use-batched-updates.ts +150 -0
  158. package/src/hooks/use-event-stream.ts +150 -0
  159. package/src/hooks/use-keyboard.ts +45 -0
  160. package/src/hooks/use-notifications.ts +82 -0
  161. package/src/hooks/use-persisted-state.ts +60 -0
  162. package/src/hooks/use-polling.ts +60 -0
  163. package/src/hooks/use-project-runs.ts +51 -0
  164. package/src/hooks/use-projects.ts +26 -0
  165. package/src/hooks/use-run-dashboard.ts +207 -0
  166. package/src/hooks/use-run-detail.ts +77 -0
  167. package/src/hooks/use-smart-polling.ts +144 -0
  168. package/src/lib/.gitkeep +0 -0
  169. package/src/lib/__tests__/cn.test.ts +69 -0
  170. package/src/lib/__tests__/config-loader.test.ts +210 -0
  171. package/src/lib/__tests__/config.test.ts +561 -0
  172. package/src/lib/__tests__/error-handler.test.ts +143 -0
  173. package/src/lib/__tests__/fetcher.test.ts +517 -0
  174. package/src/lib/__tests__/global-registry.test.ts +214 -0
  175. package/src/lib/__tests__/parser.test.ts +1532 -0
  176. package/src/lib/__tests__/path-resolver.test.ts +112 -0
  177. package/src/lib/__tests__/run-cache.test.ts +591 -0
  178. package/src/lib/__tests__/server-init.test.ts +512 -0
  179. package/src/lib/__tests__/source-discovery.test.ts +246 -0
  180. package/src/lib/__tests__/utils.test.ts +160 -0
  181. package/src/lib/__tests__/watcher.test.ts +227 -0
  182. package/src/lib/cn.ts +6 -0
  183. package/src/lib/config-loader.ts +195 -0
  184. package/src/lib/config.ts +20 -0
  185. package/src/lib/error-handler.ts +76 -0
  186. package/src/lib/fetcher.ts +394 -0
  187. package/src/lib/global-registry.ts +117 -0
  188. package/src/lib/parser.ts +794 -0
  189. package/src/lib/path-resolver.ts +16 -0
  190. package/src/lib/run-cache.ts +404 -0
  191. package/src/lib/server-init.ts +226 -0
  192. package/src/lib/services/__tests__/run-query-service.test.ts +819 -0
  193. package/src/lib/services/run-query-service.ts +286 -0
  194. package/src/lib/source-discovery.ts +216 -0
  195. package/src/lib/utils.ts +103 -0
  196. package/src/lib/watcher.ts +265 -0
  197. package/src/test/fixtures.ts +269 -0
  198. package/src/test/mocks/handlers.ts +110 -0
  199. package/src/test/mocks/server.ts +17 -0
  200. package/src/test/setup.ts +200 -0
  201. package/src/test/test-utils.tsx +36 -0
  202. package/src/types/.gitkeep +0 -0
  203. package/src/types/breakpoint.ts +17 -0
  204. package/src/types/index.ts +214 -0
  205. package/tsconfig.json +50 -0
@@ -0,0 +1,794 @@
1
+ import { promises as fs } from "fs";
2
+ import path from "path";
3
+
4
+ /** Return true when err represents a "file/directory not found" filesystem error. */
5
+ function isNotFoundError(err: unknown): boolean {
6
+ if (!(err instanceof Error)) return false;
7
+ const code = (err as NodeJS.ErrnoException).code;
8
+ return code === "ENOENT" || code === "ENOTDIR" || err.message.includes("ENOENT");
9
+ }
10
+
11
+ import type {
12
+ Run,
13
+ RunStatus,
14
+ JournalEvent,
15
+ TaskEffect,
16
+ TaskDetail,
17
+ TaskKind,
18
+ RunDigest,
19
+ EffectRequestedPayload,
20
+ EffectResolvedPayload,
21
+ RunCreatedPayload,
22
+ } from "@/types";
23
+ import { getConfig } from "@/lib/config-loader";
24
+
25
+ function parseTimestampMs(value: string | undefined): number | null {
26
+ if (!value) return null;
27
+ const ms = new Date(value).getTime();
28
+ return Number.isFinite(ms) ? ms : null;
29
+ }
30
+
31
+ function normalizeInterval(startMs: number | null, endMs: number | null): { startMs: number; endMs: number } | null {
32
+ if (startMs == null || endMs == null) return null;
33
+ return {
34
+ startMs,
35
+ endMs: Math.max(endMs, startMs),
36
+ };
37
+ }
38
+
39
+ function getTaskActiveWindow(task: {
40
+ startedAt?: string;
41
+ finishedAt?: string;
42
+ requestedAt?: string;
43
+ resolvedAt?: string;
44
+ }): { startMs: number; endMs: number } | null {
45
+ const explicitWindow = normalizeInterval(
46
+ parseTimestampMs(task.startedAt),
47
+ parseTimestampMs(task.finishedAt)
48
+ );
49
+ if (explicitWindow) return explicitWindow;
50
+
51
+ return normalizeInterval(
52
+ parseTimestampMs(task.requestedAt),
53
+ parseTimestampMs(task.resolvedAt)
54
+ );
55
+ }
56
+
57
+ function getTaskActiveDuration(task: {
58
+ startedAt?: string;
59
+ finishedAt?: string;
60
+ requestedAt?: string;
61
+ resolvedAt?: string;
62
+ }): number | undefined {
63
+ const window = getTaskActiveWindow(task);
64
+ if (!window) return undefined;
65
+ return window.endMs - window.startMs;
66
+ }
67
+
68
+ function getCoveredDurationMs(windows: Array<{ startMs: number; endMs: number }>): number {
69
+ if (windows.length === 0) return 0;
70
+
71
+ const sorted = [...windows].sort((a, b) => a.startMs - b.startMs);
72
+ let covered = 0;
73
+ let current = sorted[0];
74
+
75
+ for (let i = 1; i < sorted.length; i++) {
76
+ const next = sorted[i];
77
+ if (next.startMs <= current.endMs) {
78
+ current.endMs = Math.max(current.endMs, next.endMs);
79
+ continue;
80
+ }
81
+ covered += current.endMs - current.startMs;
82
+ current = next;
83
+ }
84
+
85
+ covered += current.endMs - current.startMs;
86
+ return covered;
87
+ }
88
+
89
+ async function fileExists(filePath: string): Promise<boolean> {
90
+ try {
91
+ await fs.access(filePath);
92
+ return true;
93
+ } catch (err) {
94
+ // ENOENT is expected for non-existent paths; warn on permission or other errors
95
+ if (!isNotFoundError(err)) {
96
+ console.warn(`[parser] Unexpected error checking existence of ${filePath}:`, err);
97
+ }
98
+ return false;
99
+ }
100
+ }
101
+
102
+ async function readJsonSafe<T>(filePath: string, fallback: T | null | undefined): Promise<T | null | undefined> {
103
+ try {
104
+ const content = await fs.readFile(filePath, "utf-8");
105
+ return JSON.parse(content) as T;
106
+ } catch (err) {
107
+ // ENOENT is expected for optional files; warn on parse errors or permission issues
108
+ if (!isNotFoundError(err)) {
109
+ console.warn(`[parser] Failed to read/parse JSON from ${filePath}:`, err);
110
+ }
111
+ return fallback;
112
+ }
113
+ }
114
+
115
+ async function readTextSafe(filePath: string): Promise<string | undefined> {
116
+ try {
117
+ return await fs.readFile(filePath, "utf-8");
118
+ } catch (err) {
119
+ // ENOENT is expected for optional log files; warn on permission or other errors
120
+ if (!isNotFoundError(err)) {
121
+ console.warn(`[parser] Failed to read text file ${filePath}:`, err);
122
+ }
123
+ return undefined;
124
+ }
125
+ }
126
+
127
+ /** Maximum concurrent filesystem operations to prevent file descriptor exhaustion. */
128
+ const BATCH_CONCURRENCY_LIMIT = 50;
129
+
130
+ /**
131
+ * Execute an array of async factory functions with a concurrency limit.
132
+ * Returns results in the same order as the input, using Promise.allSettled
133
+ * semantics so that individual failures don't crash the batch.
134
+ */
135
+ async function batchAllSettled<T>(
136
+ factories: Array<() => Promise<T>>,
137
+ limit: number = BATCH_CONCURRENCY_LIMIT
138
+ ): Promise<PromiseSettledResult<T>[]> {
139
+ const results: PromiseSettledResult<T>[] = new Array(factories.length);
140
+ let nextIndex = 0;
141
+
142
+ async function worker() {
143
+ while (nextIndex < factories.length) {
144
+ const idx = nextIndex++;
145
+ try {
146
+ const value = await factories[idx]();
147
+ results[idx] = { status: "fulfilled", value };
148
+ } catch (reason) {
149
+ results[idx] = { status: "rejected", reason };
150
+ }
151
+ }
152
+ }
153
+
154
+ const workerCount = Math.min(limit, factories.length);
155
+ const workers: Promise<void>[] = [];
156
+ for (let i = 0; i < workerCount; i++) {
157
+ workers.push(worker());
158
+ }
159
+ await Promise.all(workers);
160
+ return results;
161
+ }
162
+
163
+ // Normalize raw journal entry (which uses `data` and `recordedAt`) into our JournalEvent type
164
+ function normalizeJournalEvent(raw: Record<string, unknown>, filename: string): JournalEvent | null {
165
+ if (!raw || !raw.type) return null;
166
+
167
+ // Parse seq and id from filename: "000001.ULID.json"
168
+ const parts = filename.replace(/\.json$/, "").split(".");
169
+ const seq = parseInt(parts[0], 10) || 0;
170
+ const id = parts[1] || "";
171
+
172
+ return {
173
+ seq,
174
+ id,
175
+ ts: (raw.recordedAt as string) || (raw.ts as string) || "",
176
+ type: raw.type as JournalEvent["type"],
177
+ payload: (raw.data as Record<string, unknown>) || (raw.payload as Record<string, unknown>) || {},
178
+ };
179
+ }
180
+
181
+ /** Result of an incremental journal parse. */
182
+ export interface IncrementalJournalResult {
183
+ events: JournalEvent[];
184
+ /** Number of JSON files in the journal directory after this parse. */
185
+ fileCount: number;
186
+ }
187
+
188
+ export async function parseJournalDir(
189
+ journalPath: string
190
+ ): Promise<JournalEvent[]> {
191
+ const result = await parseJournalDirIncremental(journalPath);
192
+ return result.events;
193
+ }
194
+
195
+ /**
196
+ * Incrementally parse a journal directory.
197
+ *
198
+ * When `previousEvents` and `previousFileCount` are supplied the function
199
+ * skips files that were already parsed in a previous call. If the
200
+ * directory now has *fewer* files than `previousFileCount` (truncation /
201
+ * rotation) the journal is re-read from scratch.
202
+ *
203
+ * @param journalPath Path to the journal directory.
204
+ * @param previousEvents Events returned by a prior call (used as base for merge).
205
+ * @param previousFileCount Number of JSON files that existed during the prior call.
206
+ * @returns Merged events array (sorted by seq) and the current file count.
207
+ */
208
+ export async function parseJournalDirIncremental(
209
+ journalPath: string,
210
+ previousEvents?: JournalEvent[],
211
+ previousFileCount?: number
212
+ ): Promise<IncrementalJournalResult> {
213
+ if (!(await fileExists(journalPath))) return { events: [], fileCount: 0 };
214
+
215
+ const files = await fs.readdir(journalPath);
216
+ const jsonFiles = files.filter((f) => f.endsWith(".json")).sort();
217
+ const currentFileCount = jsonFiles.length;
218
+
219
+ // Determine whether we can do an incremental read.
220
+ // Incremental is possible when we have cached state AND the file count
221
+ // has not shrunk (truncation / rotation guard).
222
+ const canIncremental =
223
+ previousEvents !== undefined &&
224
+ previousFileCount !== undefined &&
225
+ previousFileCount >= 0 &&
226
+ currentFileCount >= previousFileCount;
227
+
228
+ if (canIncremental) {
229
+ const newFilesStartIdx = previousFileCount!;
230
+
231
+ // No new files — return the previous result as-is.
232
+ if (newFilesStartIdx >= currentFileCount) {
233
+ return { events: previousEvents!, fileCount: currentFileCount };
234
+ }
235
+
236
+ const newFiles = jsonFiles.slice(newFilesStartIdx);
237
+
238
+ // Batch-read only the new files
239
+ const readFactories = newFiles.map(
240
+ (file) => () =>
241
+ readJsonSafe<Record<string, unknown>>(path.join(journalPath, file), null)
242
+ );
243
+ const settled = await batchAllSettled(readFactories);
244
+
245
+ const newEvents: JournalEvent[] = [];
246
+ for (let i = 0; i < newFiles.length; i++) {
247
+ const result = settled[i];
248
+ const raw = result.status === "fulfilled" ? result.value : null;
249
+ if (raw) {
250
+ const event = normalizeJournalEvent(raw, newFiles[i]);
251
+ if (event) newEvents.push(event);
252
+ }
253
+ }
254
+
255
+ // Merge: previousEvents is already sorted; new events are appended and
256
+ // the full array is re-sorted to guarantee correctness.
257
+ const merged = [...previousEvents!, ...newEvents].sort((a, b) => a.seq - b.seq);
258
+ return { events: merged, fileCount: currentFileCount };
259
+ }
260
+
261
+ // Full re-read (first call, or truncation detected).
262
+ const readFactories = jsonFiles.map(
263
+ (file) => () =>
264
+ readJsonSafe<Record<string, unknown>>(path.join(journalPath, file), null)
265
+ );
266
+ const settled = await batchAllSettled(readFactories);
267
+
268
+ const events: JournalEvent[] = [];
269
+ for (let i = 0; i < jsonFiles.length; i++) {
270
+ const result = settled[i];
271
+ const raw = result.status === "fulfilled" ? result.value : null;
272
+ if (raw) {
273
+ const event = normalizeJournalEvent(raw, jsonFiles[i]);
274
+ if (event) events.push(event);
275
+ }
276
+ }
277
+
278
+ return { events: events.sort((a, b) => a.seq - b.seq), fileCount: currentFileCount };
279
+ }
280
+
281
+ /** Options for incremental run parsing. */
282
+ export interface IncrementalRunOptions {
283
+ previousEvents?: JournalEvent[];
284
+ previousFileCount?: number;
285
+ }
286
+
287
+ /** Extended Run result that includes the journal file count for caching. */
288
+ export interface ParseRunResult extends Run {
289
+ /** Number of journal files parsed — used by the cache layer for incremental reads. */
290
+ _journalFileCount: number;
291
+ }
292
+
293
+ export async function parseRunDir(
294
+ runPath: string,
295
+ incremental?: IncrementalRunOptions
296
+ ): Promise<ParseRunResult> {
297
+ const runJson = await readJsonSafe<Record<string, unknown>>(
298
+ path.join(runPath, "run.json"),
299
+ {}
300
+ );
301
+
302
+ const journalResult = await parseJournalDirIncremental(
303
+ path.join(runPath, "journal"),
304
+ incremental?.previousEvents,
305
+ incremental?.previousFileCount
306
+ );
307
+ const events = journalResult.events;
308
+
309
+ // Extract run info from events
310
+ const runCreated = events.find((e) => e.type === "RUN_CREATED");
311
+ const runCompleted = events.find((e) => e.type === "RUN_COMPLETED");
312
+ const runFailed = events.find((e) => e.type === "RUN_FAILED");
313
+
314
+ const createdPayload = (runCreated?.payload ||
315
+ {}) as unknown as RunCreatedPayload;
316
+
317
+ // Build task map from events — first pass: collect all requested/resolved info
318
+ const taskMap = new Map<string, TaskEffect>();
319
+ const requestedPayloads: EffectRequestedPayload[] = [];
320
+
321
+ for (const event of events) {
322
+ if (event.type === "EFFECT_REQUESTED") {
323
+ const p = event.payload as unknown as EffectRequestedPayload;
324
+ requestedPayloads.push(p);
325
+ taskMap.set(p.effectId, {
326
+ effectId: p.effectId,
327
+ kind: p.kind,
328
+ title: p.label || p.taskId,
329
+ label: p.label || p.taskId,
330
+ status: "requested",
331
+ invocationKey: p.invocationKey,
332
+ stepId: p.stepId,
333
+ taskId: p.taskId,
334
+ requestedAt: event.ts,
335
+ });
336
+ }
337
+
338
+ if (event.type === "EFFECT_RESOLVED") {
339
+ const p = event.payload as unknown as EffectResolvedPayload;
340
+ const task = taskMap.get(p.effectId);
341
+ if (task) {
342
+ task.status = p.status === "ok" ? "resolved" : "error";
343
+ task.resolvedAt = event.ts;
344
+ task.startedAt = p.startedAt;
345
+ task.finishedAt = p.finishedAt;
346
+ task.duration = getTaskActiveDuration({
347
+ startedAt: p.startedAt,
348
+ finishedAt: p.finishedAt,
349
+ requestedAt: task.requestedAt,
350
+ resolvedAt: event.ts,
351
+ });
352
+ if (p.error) {
353
+ task.error = {
354
+ name: p.error.name,
355
+ message: p.error.message,
356
+ stack: p.error.stack,
357
+ };
358
+ }
359
+ }
360
+ }
361
+ }
362
+
363
+ // Batch-read all task.json files in parallel for EFFECT_REQUESTED tasks
364
+ if (requestedPayloads.length > 0) {
365
+ const taskDefFactories = requestedPayloads.map(
366
+ (p) => () =>
367
+ readJsonSafe<Record<string, unknown>>(
368
+ path.join(runPath, "tasks", p.effectId, "task.json"),
369
+ null
370
+ )
371
+ );
372
+ const taskDefResults = await batchAllSettled(taskDefFactories);
373
+
374
+ for (let i = 0; i < requestedPayloads.length; i++) {
375
+ const p = requestedPayloads[i];
376
+ const result = taskDefResults[i];
377
+ const taskDef = result.status === "fulfilled" ? result.value : null;
378
+ if (taskDef) {
379
+ const task = taskMap.get(p.effectId)!;
380
+ task.title = (taskDef.title as string) || task.title;
381
+ if (taskDef.agent && typeof taskDef.agent === "object") {
382
+ const agentDef = taskDef.agent as Record<string, unknown>;
383
+ task.agent = {
384
+ name: (agentDef.name as string) || "unknown",
385
+ prompt: agentDef.prompt as NonNullable<TaskEffect["agent"]>["prompt"],
386
+ };
387
+ }
388
+ // Extract breakpoint question from inputs for breakpoint tasks
389
+ if (p.kind === "breakpoint") {
390
+ const inputs = taskDef.inputs as Record<string, unknown> | undefined;
391
+ if (inputs && typeof inputs.question === "string") {
392
+ task.breakpointQuestion = inputs.question;
393
+ }
394
+ }
395
+ }
396
+ }
397
+ }
398
+
399
+ const tasks = Array.from(taskMap.values());
400
+ const completedTasks = tasks.filter((t) => t.status === "resolved").length;
401
+ const failedTasks = tasks.filter((t) => t.status === "error").length;
402
+
403
+ // Task 1.2: Extract failed step name from the first task that resolved with error
404
+ const firstFailedTask = tasks.find((t) => t.status === "error");
405
+ const failedStep = firstFailedTask
406
+ ? firstFailedTask.title || firstFailedTask.label || firstFailedTask.stepId
407
+ : undefined;
408
+
409
+ // Extract failure details from RUN_FAILED event or last failed EFFECT_RESOLVED
410
+ let failureError: string | undefined;
411
+ let failureMessage: string | undefined;
412
+
413
+ if (runFailed) {
414
+ const failPayload = runFailed.payload as Record<string, unknown>;
415
+ const runError = failPayload.error as { name?: string; message?: string; stack?: string } | undefined;
416
+ if (runError) {
417
+ failureError = runError.name || "Error";
418
+ failureMessage = runError.message || runError.stack || undefined;
419
+ }
420
+ }
421
+
422
+ // If we still don't have a message, look at the last EFFECT_RESOLVED with error status
423
+ if (!failureMessage) {
424
+ const lastFailedEffect = [...events]
425
+ .reverse()
426
+ .find((e) => e.type === "EFFECT_RESOLVED" && (e.payload as Record<string, unknown>).status === "error");
427
+ if (lastFailedEffect) {
428
+ const effectPayload = lastFailedEffect.payload as Record<string, unknown>;
429
+ const effectError = effectPayload.error as { name?: string; message?: string; stack?: string } | undefined;
430
+ if (effectError) {
431
+ failureError = failureError || effectError.name || "Error";
432
+ failureMessage = effectError.message || effectError.stack || undefined;
433
+ }
434
+ }
435
+ }
436
+
437
+ let status: RunStatus = "pending";
438
+ if (runCompleted) status = "completed";
439
+ else if (runFailed) status = "failed";
440
+ else if (tasks.some((t) => t.status === "requested")) status = "waiting";
441
+
442
+ // Task 1.3: Extract breakpoint question from pending breakpoint tasks
443
+ let breakpointQuestion: string | undefined;
444
+ if (status === "waiting") {
445
+ const pendingBreakpoint = tasks.find(
446
+ (t) => t.kind === "breakpoint" && t.status === "requested"
447
+ );
448
+ if (pendingBreakpoint?.breakpointQuestion) {
449
+ breakpointQuestion = pendingBreakpoint.breakpointQuestion;
450
+ }
451
+ }
452
+
453
+ // Determine waitingKind: check the last requested (pending) task
454
+ let waitingKind: 'breakpoint' | 'task' | undefined;
455
+ if (status === "waiting") {
456
+ const requestedTasks = tasks.filter((t) => t.status === "requested");
457
+ const lastRequested = requestedTasks[requestedTasks.length - 1];
458
+ if (lastRequested) {
459
+ waitingKind = lastRequested.kind === "breakpoint" ? "breakpoint" : "task";
460
+ }
461
+ }
462
+
463
+ const createdAt = runCreated?.ts || "";
464
+ const lastEvent = events[events.length - 1];
465
+
466
+ let duration: number | undefined;
467
+ const taskWindows = tasks
468
+ .map((task) => getTaskActiveWindow(task))
469
+ .filter((window): window is { startMs: number; endMs: number } => window !== null);
470
+ if (taskWindows.length > 0) {
471
+ duration = getCoveredDurationMs(taskWindows);
472
+ } else if (createdAt && (runCompleted || runFailed)) {
473
+ const endTs = (runCompleted || runFailed)!.ts;
474
+ duration = new Date(endTs).getTime() - new Date(createdAt).getTime();
475
+ } else if (createdAt && lastEvent) {
476
+ duration =
477
+ new Date(lastEvent.ts).getTime() - new Date(createdAt).getTime();
478
+ }
479
+
480
+ // Detect staleness for waiting or pending runs
481
+ let isStale: boolean | undefined;
482
+ if (status === "waiting" || status === "pending") {
483
+ const updatedAtTs = lastEvent?.ts || createdAt;
484
+ if (updatedAtTs) {
485
+ const config = await getConfig();
486
+ const timeSinceUpdate = Date.now() - new Date(updatedAtTs).getTime();
487
+ if (timeSinceUpdate > config.staleThresholdMs) {
488
+ isStale = true;
489
+ }
490
+ }
491
+ }
492
+
493
+ // Detect orphaned runs: all tasks resolved but no terminal event
494
+ // (process likely crashed before writing RUN_COMPLETED)
495
+ if (status === "pending" && tasks.length > 0 && !tasks.some((t) => t.status === "requested")) {
496
+ isStale = true;
497
+ }
498
+
499
+ return {
500
+ runId: createdPayload.runId || path.basename(runPath),
501
+ processId:
502
+ createdPayload.processId ||
503
+ (runJson?.processId as string) ||
504
+ "unknown",
505
+ status,
506
+ createdAt,
507
+ updatedAt: lastEvent?.ts || createdAt,
508
+ completedAt: (runCompleted || runFailed)?.ts,
509
+ tasks,
510
+ events,
511
+ totalTasks: tasks.length,
512
+ completedTasks,
513
+ failedTasks,
514
+ duration,
515
+ failedStep,
516
+ failureError,
517
+ failureMessage,
518
+ breakpointQuestion,
519
+ isStale,
520
+ waitingKind,
521
+ _journalFileCount: journalResult.fileCount,
522
+ };
523
+ }
524
+
525
+ export async function parseTaskDetail(
526
+ runPath: string,
527
+ effectId: string
528
+ ): Promise<TaskDetail | null> {
529
+ const taskDir = path.join(runPath, "tasks", effectId);
530
+ if (!(await fileExists(taskDir))) return null;
531
+
532
+ // Read all 5 task files + journal in parallel with Promise.allSettled
533
+ const [
534
+ taskDefResult,
535
+ inputResult,
536
+ resultResult,
537
+ stdoutResult,
538
+ stderrResult,
539
+ journalEventsResult,
540
+ ] = await Promise.allSettled([
541
+ readJsonSafe<Record<string, unknown>>(path.join(taskDir, "task.json"), null),
542
+ readJsonSafe<Record<string, unknown>>(path.join(taskDir, "input.json"), undefined),
543
+ readJsonSafe<Record<string, unknown>>(path.join(taskDir, "result.json"), undefined),
544
+ readTextSafe(path.join(taskDir, "stdout.log")),
545
+ readTextSafe(path.join(taskDir, "stderr.log")),
546
+ parseJournalDir(path.join(runPath, "journal")),
547
+ ]);
548
+
549
+ const taskDef = taskDefResult.status === "fulfilled" ? taskDefResult.value : null;
550
+ const input = inputResult.status === "fulfilled" ? inputResult.value : undefined;
551
+ const result = resultResult.status === "fulfilled" ? resultResult.value : undefined;
552
+ const stdout = stdoutResult.status === "fulfilled" ? stdoutResult.value : undefined;
553
+ const stderr = stderrResult.status === "fulfilled" ? stderrResult.value : undefined;
554
+ const journalEvents = journalEventsResult.status === "fulfilled" ? journalEventsResult.value : [];
555
+
556
+ // Extract timing from result.json
557
+ const resultStartedAt = result?.startedAt as string | undefined;
558
+ const resultFinishedAt = result?.finishedAt as string | undefined;
559
+ const requestedEvent = journalEvents.find(
560
+ (e) => e.type === "EFFECT_REQUESTED" && (e.payload as Record<string, unknown>).effectId === effectId
561
+ );
562
+ const resolvedEvent = journalEvents.find(
563
+ (e) => e.type === "EFFECT_RESOLVED" && (e.payload as Record<string, unknown>).effectId === effectId
564
+ );
565
+
566
+ const requestedAt = requestedEvent?.ts || "";
567
+ const resolvedAt = resolvedEvent?.ts;
568
+
569
+ const duration = getTaskActiveDuration({
570
+ startedAt: resultStartedAt,
571
+ finishedAt: resultFinishedAt,
572
+ requestedAt,
573
+ resolvedAt,
574
+ });
575
+
576
+ // Use inputs from task.json if separate input.json doesn't exist
577
+ const resolvedInput = input ?? (taskDef?.inputs as Record<string, unknown> | undefined);
578
+
579
+ // Extract breakpoint payload for breakpoint tasks
580
+ const kind = (taskDef?.kind as TaskKind) || "agent";
581
+ let breakpointPayload: import("@/types").BreakpointPayload | undefined;
582
+ if (kind === "breakpoint" && resolvedInput) {
583
+ breakpointPayload = {
584
+ question: (resolvedInput.question as string) || "Approval required",
585
+ title: (resolvedInput.title as string) || (taskDef?.title as string) || "Breakpoint",
586
+ options: Array.isArray(resolvedInput.options) ? (resolvedInput.options as string[]) : undefined,
587
+ context: resolvedInput.context as import("@/types").BreakpointPayload["context"],
588
+ };
589
+ }
590
+
591
+ // Determine error status from result or journal
592
+ const resolvedPayload = resolvedEvent?.payload as Record<string, unknown> | undefined;
593
+ const isError = result
594
+ ? (result.status === "error")
595
+ : (resolvedPayload?.status === "error");
596
+
597
+ return {
598
+ effectId,
599
+ kind,
600
+ title: (taskDef?.title as string) || effectId,
601
+ label: (taskDef?.title as string) || effectId,
602
+ status: resolvedEvent ? (isError ? "error" : "resolved") : "requested",
603
+ invocationKey: (taskDef?.invocationKey as string) || "",
604
+ stepId: (taskDef?.stepId as string) || "",
605
+ taskId: (taskDef?.taskId as string) || "",
606
+ requestedAt,
607
+ resolvedAt,
608
+ startedAt: resultStartedAt,
609
+ finishedAt: resultFinishedAt,
610
+ duration,
611
+ input: resolvedInput,
612
+ result: result ?? undefined,
613
+ stdout,
614
+ stderr,
615
+ taskDef: taskDef ?? undefined,
616
+ breakpoint: breakpointPayload,
617
+ breakpointQuestion: breakpointPayload?.question,
618
+ };
619
+ }
620
+
621
+ export async function getRunDigest(runPath: string): Promise<RunDigest> {
622
+ const journalPath = path.join(runPath, "journal");
623
+ let latestSeq = 0;
624
+ let status: RunStatus = "pending";
625
+ let taskCount = 0;
626
+ let completedTasks = 0;
627
+ let updatedAt = "";
628
+
629
+ const requestedBreakpoints = new Set<string>();
630
+ const resolvedEffects = new Set<string>();
631
+ const breakpointEffectIds = new Set<string>();
632
+ // Track requested effects and their kinds for waitingKind determination
633
+ const requestedEffects: Array<{ effectId: string; kind: string }> = [];
634
+
635
+ if (await fileExists(journalPath)) {
636
+ const files = await fs.readdir(journalPath);
637
+ const jsonFiles = files.filter((f) => f.endsWith(".json")).sort();
638
+ latestSeq = jsonFiles.length;
639
+
640
+ // Batch-read all journal files in parallel with concurrency limit
641
+ const readFactories = jsonFiles.map(
642
+ (file) => () =>
643
+ readJsonSafe<Record<string, unknown>>(path.join(journalPath, file), null)
644
+ );
645
+ const settled = await batchAllSettled(readFactories);
646
+
647
+ // Process results sequentially to maintain event ordering for updatedAt
648
+ for (let i = 0; i < jsonFiles.length; i++) {
649
+ const result = settled[i];
650
+ const raw = result.status === "fulfilled" ? result.value : null;
651
+ if (!raw) continue;
652
+ const event = normalizeJournalEvent(raw, jsonFiles[i]);
653
+ if (!event) continue;
654
+ updatedAt = event.ts;
655
+ if (event.type === "EFFECT_REQUESTED") {
656
+ taskCount++;
657
+ const data = event.payload as Record<string, unknown>;
658
+ const effectId = data.effectId as string;
659
+ const kind = (data.kind as string) || "agent";
660
+ requestedEffects.push({ effectId, kind });
661
+ if (data.kind === "breakpoint") {
662
+ requestedBreakpoints.add(effectId);
663
+ breakpointEffectIds.add(effectId);
664
+ }
665
+ }
666
+ if (event.type === "EFFECT_RESOLVED") {
667
+ completedTasks++;
668
+ const data = event.payload as Record<string, unknown>;
669
+ resolvedEffects.add(data.effectId as string);
670
+ }
671
+ if (event.type === "RUN_COMPLETED") status = "completed";
672
+ if (event.type === "RUN_FAILED") status = "failed";
673
+ }
674
+
675
+ if (status === "pending" && taskCount > 0) status = "waiting";
676
+ }
677
+
678
+ // Count pending breakpoints (requested but not yet resolved).
679
+ // Also check result.json — the dashboard writes it on approve but can't
680
+ // write journal events, so the journal alone may lag behind.
681
+ let pendingBreakpoints = 0;
682
+ if (requestedBreakpoints.size > 0) {
683
+ const unresolvedBps = [...requestedBreakpoints].filter(
684
+ (id) => !resolvedEffects.has(id)
685
+ );
686
+ if (unresolvedBps.length > 0) {
687
+ const resultChecks = await Promise.all(
688
+ unresolvedBps.map((id) =>
689
+ readJsonSafe<Record<string, unknown>>(
690
+ path.join(runPath, "tasks", id, "result.json"),
691
+ null
692
+ )
693
+ )
694
+ );
695
+ for (let i = 0; i < unresolvedBps.length; i++) {
696
+ if (resultChecks[i] && resultChecks[i]!.status === "ok") {
697
+ resolvedEffects.add(unresolvedBps[i]);
698
+ } else {
699
+ pendingBreakpoints++;
700
+ }
701
+ }
702
+ }
703
+ }
704
+
705
+ // Extract breakpoint question and effectId from pending breakpoint tasks — batch-read all at once
706
+ let breakpointQuestion: string | undefined;
707
+ let breakpointEffectId: string | undefined;
708
+ if (status === "waiting" && breakpointEffectIds.size > 0) {
709
+ const pendingBpIds = [...breakpointEffectIds].filter(
710
+ (id) => !resolvedEffects.has(id)
711
+ );
712
+ if (pendingBpIds.length > 0) {
713
+ // Store the first pending breakpoint effectId regardless of question
714
+ breakpointEffectId = pendingBpIds[0];
715
+
716
+ const bpFactories = pendingBpIds.map(
717
+ (effectId) => () =>
718
+ readJsonSafe<Record<string, unknown>>(
719
+ path.join(runPath, "tasks", effectId, "task.json"),
720
+ null
721
+ )
722
+ );
723
+ const bpResults = await batchAllSettled(bpFactories);
724
+
725
+ // Use the first pending breakpoint question found
726
+ for (let i = 0; i < pendingBpIds.length; i++) {
727
+ const result = bpResults[i];
728
+ const taskDef = result.status === "fulfilled" ? result.value : null;
729
+ if (taskDef) {
730
+ const inputs = taskDef.inputs as Record<string, unknown> | undefined;
731
+ if (inputs && typeof inputs.question === "string") {
732
+ breakpointQuestion = inputs.question;
733
+ breakpointEffectId = pendingBpIds[i];
734
+ break;
735
+ }
736
+ }
737
+ }
738
+ }
739
+ }
740
+
741
+ // Determine waitingKind from the last requested (pending) effect
742
+ let waitingKind: 'breakpoint' | 'task' | undefined;
743
+ if (status === "waiting") {
744
+ // Find the last requested effect that hasn't been resolved
745
+ const pendingEffects = requestedEffects.filter(
746
+ (e) => !resolvedEffects.has(e.effectId)
747
+ );
748
+ const lastPending = pendingEffects[pendingEffects.length - 1];
749
+ if (lastPending) {
750
+ waitingKind = lastPending.kind === "breakpoint" ? "breakpoint" : "task";
751
+ }
752
+ }
753
+
754
+ // Detect staleness for waiting or pending runs
755
+ let isStale: boolean | undefined;
756
+ if (status === "waiting" || status === "pending") {
757
+ if (updatedAt) {
758
+ const config = await getConfig();
759
+ const timeSinceUpdate = Date.now() - new Date(updatedAt).getTime();
760
+ if (timeSinceUpdate > config.staleThresholdMs) {
761
+ isStale = true;
762
+ }
763
+ }
764
+ }
765
+
766
+ // Detect orphaned runs: all effects resolved but no terminal event
767
+ if (status === "waiting" && taskCount > 0 && completedTasks >= taskCount) {
768
+ isStale = true;
769
+ }
770
+
771
+ return {
772
+ runId: path.basename(runPath),
773
+ latestSeq,
774
+ status,
775
+ taskCount,
776
+ completedTasks,
777
+ updatedAt,
778
+ pendingBreakpoints,
779
+ breakpointQuestion,
780
+ breakpointEffectId,
781
+ isStale,
782
+ waitingKind,
783
+ };
784
+ }
785
+
786
+ export async function getRunIds(runsPath: string): Promise<string[]> {
787
+ if (!(await fileExists(runsPath))) return [];
788
+ const entries = await fs.readdir(runsPath, { withFileTypes: true });
789
+ return entries
790
+ .filter((e) => e.isDirectory())
791
+ .map((e) => e.name)
792
+ .sort()
793
+ .reverse();
794
+ }