@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,265 @@
1
+ import { watch, type FSWatcher } from "fs";
2
+ import { promises as fs } from "fs";
3
+ import path from "path";
4
+ import { EventEmitter } from "events";
5
+ import { discoverAllRunDirs, invalidateDiscoveryCache, discoverAllRunsParentDirs } from "./source-discovery";
6
+ import { invalidateRun, requestDiscovery } from "./run-cache";
7
+ import { getGlobal } from "./global-registry";
8
+
9
+ // Persist event emitter across HMR reloads via typed global registry
10
+ function getWatcherEvents(): EventEmitter {
11
+ return getGlobal('__observer_watcher_events__', () => new EventEmitter());
12
+ }
13
+
14
+ export const watcherEvents = getWatcherEvents();
15
+
16
+ // Event types
17
+ export type WatcherEventType = "run-changed" | "new-run" | "error";
18
+
19
+ export interface WatcherEvent {
20
+ type: WatcherEventType;
21
+ runDir: string;
22
+ error?: Error;
23
+ }
24
+
25
+ // Persist watcher state across HMR reloads via typed global registry
26
+ interface WatcherState {
27
+ activeWatchers: Map<string, FSWatcher>;
28
+ debounceTimers: Map<string, NodeJS.Timeout>;
29
+ rescanTimer: NodeJS.Timeout | null;
30
+ }
31
+
32
+ function getWatcherState(): WatcherState {
33
+ return getGlobal('__observer_watchers__', () => ({
34
+ activeWatchers: new Map<string, FSWatcher>(),
35
+ debounceTimers: new Map<string, NodeJS.Timeout>(),
36
+ rescanTimer: null,
37
+ }));
38
+ }
39
+
40
+ // WSL-optimized constants
41
+ const DEBOUNCE_MS = 500; // 500ms debounce (WSL cross-FS needs more)
42
+ const RESCAN_INTERVAL_MS = 30000; // 30s — reduced from 120s to detect new project directories faster
43
+
44
+ function debounceChange(runDir: string, callback: () => void) {
45
+ const state = getWatcherState();
46
+ const existing = state.debounceTimers.get(runDir);
47
+ if (existing) {
48
+ clearTimeout(existing);
49
+ }
50
+
51
+ const timer = setTimeout(() => {
52
+ state.debounceTimers.delete(runDir);
53
+ callback();
54
+ }, DEBOUNCE_MS);
55
+
56
+ state.debounceTimers.set(runDir, timer);
57
+ }
58
+
59
+ function handleJournalChange(journalDir: string) {
60
+ const runDir = path.dirname(journalDir);
61
+
62
+ debounceChange(runDir, () => {
63
+ invalidateRun(runDir);
64
+ requestDiscovery(); // Ensure discoverAndCacheAll() re-populates the entry
65
+ watcherEvents.emit("change", {
66
+ type: "run-changed",
67
+ runDir,
68
+ } as WatcherEvent);
69
+ });
70
+ }
71
+
72
+ function handleTasksChange(tasksDir: string) {
73
+ const runDir = path.dirname(tasksDir);
74
+
75
+ debounceChange(runDir, () => {
76
+ invalidateRun(runDir);
77
+ requestDiscovery(); // Ensure discoverAndCacheAll() re-populates the entry
78
+ watcherEvents.emit("change", {
79
+ type: "run-changed",
80
+ runDir,
81
+ } as WatcherEvent);
82
+ });
83
+ }
84
+
85
+ function handleRunsParentChange(runsDir: string) {
86
+ debounceChange(runsDir, () => {
87
+ // Invalidate caches so new runs are picked up on next request
88
+ invalidateDiscoveryCache();
89
+ requestDiscovery();
90
+ watcherEvents.emit("change", {
91
+ type: "new-run",
92
+ runDir: runsDir,
93
+ } as WatcherEvent);
94
+ });
95
+ }
96
+
97
+ function watchDirectory(dirPath: string, onChange: (path: string) => void): FSWatcher | null {
98
+ try {
99
+ // Use non-recursive watch (WSL doesn't support recursive)
100
+ const watcher = watch(dirPath, { recursive: false }, (eventType, filename) => {
101
+ if (filename) {
102
+ onChange(dirPath);
103
+ }
104
+ });
105
+
106
+ watcher.on("error", (err) => {
107
+ console.error(`Watch error for ${dirPath}:`, err);
108
+ watcherEvents.emit("change", {
109
+ type: "error",
110
+ runDir: dirPath,
111
+ error: err,
112
+ } as WatcherEvent);
113
+ });
114
+
115
+ return watcher;
116
+ } catch (err) {
117
+ console.error(`Failed to watch ${dirPath}:`, err);
118
+ return null;
119
+ }
120
+ }
121
+
122
+ async function setupWatchers() {
123
+ const state = getWatcherState();
124
+ const discovered = await discoverAllRunDirs();
125
+ const runsParentDirs = new Set<string>();
126
+
127
+ // Build the set of directories we need to watch
128
+ const neededDirs = new Set<string>();
129
+
130
+ for (const { runDir } of discovered) {
131
+ const journalDir = path.join(runDir, "journal");
132
+ try {
133
+ await fs.access(journalDir);
134
+ neededDirs.add(journalDir);
135
+ } catch {
136
+ // Journal dir doesn't exist yet — skip for now
137
+ }
138
+
139
+ const tasksDir = path.join(runDir, "tasks");
140
+ try {
141
+ await fs.access(tasksDir);
142
+ neededDirs.add(tasksDir);
143
+ } catch {
144
+ // Tasks dir doesn't exist yet — skip for now
145
+ }
146
+
147
+ const runsDir = path.dirname(runDir);
148
+ runsParentDirs.add(runsDir);
149
+ }
150
+
151
+ // Also discover ALL .a5c/runs/ directories (including empty ones)
152
+ // so we detect the very first run in a new project immediately
153
+ try {
154
+ const allRunsParentDirs = await discoverAllRunsParentDirs();
155
+ for (const dir of allRunsParentDirs) {
156
+ runsParentDirs.add(dir);
157
+ }
158
+ } catch {
159
+ // Non-critical — fall back to watching only populated runs dirs
160
+ }
161
+
162
+ for (const runsDir of runsParentDirs) {
163
+ try {
164
+ await fs.access(runsDir);
165
+ neededDirs.add(runsDir);
166
+ } catch {
167
+ // Runs dir doesn't exist
168
+ }
169
+ }
170
+
171
+ // Incremental update: close watchers for directories no longer needed
172
+ for (const [dirPath, watcher] of state.activeWatchers.entries()) {
173
+ if (!neededDirs.has(dirPath)) {
174
+ watcher.close();
175
+ state.activeWatchers.delete(dirPath);
176
+ }
177
+ }
178
+
179
+ // Only create new watchers for newly discovered directories
180
+ for (const dirPath of neededDirs) {
181
+ if (!state.activeWatchers.has(dirPath)) {
182
+ const baseName = path.basename(dirPath);
183
+ const isJournalDir = baseName === "journal";
184
+ const isTasksDir = baseName === "tasks";
185
+ const onChange = isJournalDir
186
+ ? handleJournalChange
187
+ : isTasksDir
188
+ ? handleTasksChange
189
+ : handleRunsParentChange;
190
+ const watcher = watchDirectory(dirPath, onChange);
191
+ if (watcher) {
192
+ state.activeWatchers.set(dirPath, watcher);
193
+ }
194
+ }
195
+ }
196
+
197
+ console.log(`Watching ${state.activeWatchers.size} directories`);
198
+ }
199
+
200
+ async function periodicRescan() {
201
+ try {
202
+ // Re-discover and update watchers incrementally
203
+ await setupWatchers();
204
+ } catch (err) {
205
+ console.error("Periodic rescan failed:", err);
206
+ }
207
+ }
208
+
209
+ export async function initWatcher(): Promise<() => void> {
210
+ const state = getWatcherState();
211
+
212
+ // If watchers already exist from a previous HMR cycle, skip full re-init
213
+ if (state.activeWatchers.size > 0) {
214
+ console.log(`Reusing ${state.activeWatchers.size} existing watchers (HMR-safe)`);
215
+ // Still do an incremental rescan to pick up any new directories
216
+ await setupWatchers();
217
+ } else {
218
+ console.log("Initializing filesystem watcher...");
219
+ await setupWatchers();
220
+ }
221
+
222
+ // Clear any existing rescan timer before setting a new one
223
+ if (state.rescanTimer) {
224
+ clearInterval(state.rescanTimer);
225
+ }
226
+
227
+ // Schedule periodic rescans
228
+ state.rescanTimer = setInterval(periodicRescan, RESCAN_INTERVAL_MS);
229
+
230
+ // Return cleanup function
231
+ return () => {
232
+ console.log("Cleaning up filesystem watcher...");
233
+
234
+ // Clear rescan timer
235
+ if (state.rescanTimer) {
236
+ clearInterval(state.rescanTimer);
237
+ state.rescanTimer = null;
238
+ }
239
+
240
+ // Clear debounce timers
241
+ for (const timer of state.debounceTimers.values()) {
242
+ clearTimeout(timer);
243
+ }
244
+ state.debounceTimers.clear();
245
+
246
+ // Close all watchers
247
+ for (const watcher of state.activeWatchers.values()) {
248
+ watcher.close();
249
+ }
250
+ state.activeWatchers.clear();
251
+
252
+ // Remove all event listeners
253
+ watcherEvents.removeAllListeners();
254
+ };
255
+ }
256
+
257
+ // Get watcher stats for debugging
258
+ export function getWatcherStats() {
259
+ const state = getWatcherState();
260
+ return {
261
+ activeWatchers: state.activeWatchers.size,
262
+ watchedPaths: Array.from(state.activeWatchers.keys()),
263
+ pendingDebounces: state.debounceTimers.size,
264
+ };
265
+ }
@@ -0,0 +1,269 @@
1
+ import type {
2
+ Run,
3
+ RunStatus,
4
+ TaskEffect,
5
+ TaskKind,
6
+ TaskStatus,
7
+ TaskDetail,
8
+ JournalEvent,
9
+ EventType,
10
+ ProjectSummary,
11
+ RunDigest,
12
+ } from '@/types';
13
+ import type { BreakpointPayload } from '@/types/breakpoint';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Helpers
17
+ // ---------------------------------------------------------------------------
18
+
19
+ let idCounter = 0;
20
+
21
+ function nextId(prefix = 'test'): string {
22
+ idCounter += 1;
23
+ return `${prefix}-${String(idCounter).padStart(6, '0')}`;
24
+ }
25
+
26
+ /** Reset the ID counter between test suites if needed. */
27
+ export function resetIdCounter(): void {
28
+ idCounter = 0;
29
+ }
30
+
31
+ function isoNow(offsetMs = 0): string {
32
+ return new Date(Date.now() + offsetMs).toISOString();
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // TaskEffect factory
37
+ // ---------------------------------------------------------------------------
38
+
39
+ export interface CreateMockTaskEffectOptions {
40
+ effectId?: string;
41
+ kind?: TaskKind;
42
+ title?: string;
43
+ label?: string;
44
+ status?: TaskStatus;
45
+ invocationKey?: string;
46
+ stepId?: string;
47
+ taskId?: string;
48
+ requestedAt?: string;
49
+ resolvedAt?: string;
50
+ startedAt?: string;
51
+ finishedAt?: string;
52
+ duration?: number;
53
+ error?: { name: string; message: string; stack?: string };
54
+ breakpointQuestion?: string;
55
+ agent?: { name: string; prompt?: { role: string; task: string; instructions: string[] } };
56
+ }
57
+
58
+ export function createMockTaskEffect(overrides: CreateMockTaskEffectOptions = {}): TaskEffect {
59
+ const id = overrides.effectId ?? nextId('eff');
60
+ const status = overrides.status ?? 'resolved';
61
+ const requestedAt = overrides.requestedAt ?? isoNow(-5000);
62
+ const resolvedAt =
63
+ status === 'resolved' ? (overrides.resolvedAt ?? isoNow(-1000)) : overrides.resolvedAt;
64
+
65
+ return {
66
+ effectId: id,
67
+ kind: overrides.kind ?? 'node',
68
+ title: overrides.title ?? `Task ${id}`,
69
+ label: overrides.label ?? `run-task-${id}`,
70
+ status,
71
+ invocationKey: overrides.invocationKey ?? nextId('inv'),
72
+ stepId: overrides.stepId ?? nextId('step'),
73
+ taskId: overrides.taskId ?? nextId('task'),
74
+ requestedAt,
75
+ resolvedAt,
76
+ startedAt: overrides.startedAt ?? requestedAt,
77
+ finishedAt: overrides.finishedAt ?? resolvedAt,
78
+ duration: overrides.duration ?? 4000,
79
+ error: overrides.error,
80
+ breakpointQuestion: overrides.breakpointQuestion,
81
+ agent: overrides.agent,
82
+ };
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // JournalEvent factory
87
+ // ---------------------------------------------------------------------------
88
+
89
+ export interface CreateMockJournalEventOptions {
90
+ seq?: number;
91
+ id?: string;
92
+ ts?: string;
93
+ type?: EventType;
94
+ payload?: Record<string, unknown>;
95
+ }
96
+
97
+ export function createMockJournalEvent(
98
+ overrides: CreateMockJournalEventOptions = {},
99
+ ): JournalEvent {
100
+ return {
101
+ seq: overrides.seq ?? 1,
102
+ id: overrides.id ?? nextId('evt'),
103
+ ts: overrides.ts ?? isoNow(),
104
+ type: overrides.type ?? 'EFFECT_REQUESTED',
105
+ payload: overrides.payload ?? { effectId: nextId('eff') },
106
+ };
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Run factory
111
+ // ---------------------------------------------------------------------------
112
+
113
+ export interface CreateMockRunOptions {
114
+ runId?: string;
115
+ processId?: string;
116
+ status?: RunStatus;
117
+ createdAt?: string;
118
+ updatedAt?: string;
119
+ completedAt?: string;
120
+ sessionId?: string;
121
+ tasks?: TaskEffect[];
122
+ events?: JournalEvent[];
123
+ totalTasks?: number;
124
+ completedTasks?: number;
125
+ failedTasks?: number;
126
+ duration?: number;
127
+ failedStep?: string;
128
+ breakpointQuestion?: string;
129
+ sourceLabel?: string;
130
+ projectName?: string;
131
+ isStale?: boolean;
132
+ waitingKind?: 'breakpoint' | 'task';
133
+ }
134
+
135
+ export function createMockRun(overrides: CreateMockRunOptions = {}): Run {
136
+ const tasks = overrides.tasks ?? [
137
+ createMockTaskEffect({ status: 'resolved', kind: 'node' }),
138
+ createMockTaskEffect({ status: 'resolved', kind: 'agent' }),
139
+ createMockTaskEffect({ status: 'requested', kind: 'shell' }),
140
+ ];
141
+
142
+ const completedTasks =
143
+ overrides.completedTasks ?? tasks.filter((t) => t.status === 'resolved').length;
144
+ const failedTasks =
145
+ overrides.failedTasks ?? tasks.filter((t) => t.status === 'error').length;
146
+
147
+ return {
148
+ runId: overrides.runId ?? nextId('run'),
149
+ processId: overrides.processId ?? 'data-pipeline/ingest',
150
+ status: overrides.status ?? 'completed',
151
+ createdAt: overrides.createdAt ?? isoNow(-60000),
152
+ updatedAt: overrides.updatedAt ?? isoNow(-1000),
153
+ completedAt: overrides.completedAt,
154
+ sessionId: overrides.sessionId ?? nextId('session'),
155
+ tasks,
156
+ events: overrides.events ?? [
157
+ createMockJournalEvent({ type: 'RUN_CREATED', seq: 0 }),
158
+ createMockJournalEvent({ type: 'EFFECT_REQUESTED', seq: 1 }),
159
+ createMockJournalEvent({ type: 'EFFECT_RESOLVED', seq: 2 }),
160
+ ],
161
+ totalTasks: overrides.totalTasks ?? tasks.length,
162
+ completedTasks,
163
+ failedTasks,
164
+ duration: overrides.duration ?? 59000,
165
+ failedStep: overrides.failedStep,
166
+ breakpointQuestion: overrides.breakpointQuestion,
167
+ sourceLabel: overrides.sourceLabel ?? 'cli',
168
+ projectName: overrides.projectName ?? 'my-project',
169
+ isStale: overrides.isStale,
170
+ waitingKind: overrides.waitingKind,
171
+ };
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // TaskDetail factory (extends TaskEffect with extra fields)
176
+ // ---------------------------------------------------------------------------
177
+
178
+ export interface CreateMockTaskDetailOptions extends CreateMockTaskEffectOptions {
179
+ input?: Record<string, unknown>;
180
+ result?: Record<string, unknown>;
181
+ stdout?: string;
182
+ stderr?: string;
183
+ taskDef?: Record<string, unknown>;
184
+ breakpoint?: BreakpointPayload;
185
+ }
186
+
187
+ export function createMockTaskDetail(overrides: CreateMockTaskDetailOptions = {}): TaskDetail {
188
+ const base = createMockTaskEffect(overrides);
189
+
190
+ return {
191
+ ...base,
192
+ input: overrides.input ?? { query: 'test input' },
193
+ result: overrides.result ?? { output: 'test output', exitCode: 0 },
194
+ stdout: overrides.stdout ?? 'Hello from stdout\n',
195
+ stderr: overrides.stderr ?? '',
196
+ taskDef: overrides.taskDef,
197
+ breakpoint: overrides.breakpoint,
198
+ };
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // ProjectSummary factory
203
+ // ---------------------------------------------------------------------------
204
+
205
+ export interface CreateMockProjectSummaryOptions {
206
+ projectName?: string;
207
+ totalRuns?: number;
208
+ activeRuns?: number;
209
+ completedRuns?: number;
210
+ failedRuns?: number;
211
+ staleRuns?: number;
212
+ totalTasks?: number;
213
+ completedTasksAggregate?: number;
214
+ latestUpdate?: string;
215
+ }
216
+
217
+ export function createMockProjectSummary(
218
+ overrides: CreateMockProjectSummaryOptions = {},
219
+ ): ProjectSummary {
220
+ return {
221
+ projectName: overrides.projectName ?? 'my-project',
222
+ totalRuns: overrides.totalRuns ?? 10,
223
+ activeRuns: overrides.activeRuns ?? 2,
224
+ completedRuns: overrides.completedRuns ?? 7,
225
+ failedRuns: overrides.failedRuns ?? 1,
226
+ staleRuns: overrides.staleRuns ?? 0,
227
+ totalTasks: overrides.totalTasks ?? 50,
228
+ completedTasksAggregate: overrides.completedTasksAggregate ?? 45,
229
+ latestUpdate: overrides.latestUpdate ?? isoNow(),
230
+ pendingBreakpoints: 0,
231
+ breakpointRuns: [],
232
+ };
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // RunDigest factory
237
+ // ---------------------------------------------------------------------------
238
+
239
+ export interface CreateMockRunDigestOptions {
240
+ runId?: string;
241
+ latestSeq?: number;
242
+ status?: RunStatus;
243
+ taskCount?: number;
244
+ completedTasks?: number;
245
+ updatedAt?: string;
246
+ pendingBreakpoints?: number;
247
+ breakpointQuestion?: string;
248
+ sourceLabel?: string;
249
+ projectName?: string;
250
+ isStale?: boolean;
251
+ waitingKind?: 'breakpoint' | 'task';
252
+ }
253
+
254
+ export function createMockRunDigest(overrides: CreateMockRunDigestOptions = {}): RunDigest {
255
+ return {
256
+ runId: overrides.runId ?? nextId('run'),
257
+ latestSeq: overrides.latestSeq ?? 5,
258
+ status: overrides.status ?? 'completed',
259
+ taskCount: overrides.taskCount ?? 3,
260
+ completedTasks: overrides.completedTasks ?? 3,
261
+ updatedAt: overrides.updatedAt ?? isoNow(),
262
+ pendingBreakpoints: overrides.pendingBreakpoints,
263
+ breakpointQuestion: overrides.breakpointQuestion,
264
+ sourceLabel: overrides.sourceLabel ?? 'cli',
265
+ projectName: overrides.projectName ?? 'my-project',
266
+ isStale: overrides.isStale,
267
+ waitingKind: overrides.waitingKind,
268
+ };
269
+ }
@@ -0,0 +1,110 @@
1
+ import { http, HttpResponse } from 'msw';
2
+ import {
3
+ createMockRun,
4
+ createMockTaskDetail,
5
+ createMockJournalEvent,
6
+ createMockProjectSummary,
7
+ } from '../fixtures';
8
+
9
+ // Pre-built mock data so handlers return consistent references
10
+ const mockRun = createMockRun({ runId: 'run-abc-123', projectName: 'my-project' });
11
+ const mockTaskDetail = createMockTaskDetail({ effectId: 'eff-001', kind: 'node' });
12
+ const mockEvents = [
13
+ createMockJournalEvent({ seq: 0, type: 'RUN_CREATED' }),
14
+ createMockJournalEvent({ seq: 1, type: 'EFFECT_REQUESTED' }),
15
+ createMockJournalEvent({ seq: 2, type: 'EFFECT_RESOLVED' }),
16
+ createMockJournalEvent({ seq: 3, type: 'RUN_COMPLETED' }),
17
+ ];
18
+ const mockProjects = [
19
+ createMockProjectSummary({ projectName: 'my-project', totalRuns: 5, activeRuns: 1 }),
20
+ createMockProjectSummary({ projectName: 'other-project', totalRuns: 3, activeRuns: 0 }),
21
+ ];
22
+
23
+ export const handlers = [
24
+ // GET /api/runs — returns run list (supports ?mode=projects)
25
+ http.get('/api/runs', ({ request }) => {
26
+ const url = new URL(request.url);
27
+ const mode = url.searchParams.get('mode');
28
+
29
+ if (mode === 'projects') {
30
+ return HttpResponse.json({ projects: mockProjects });
31
+ }
32
+
33
+ const project = url.searchParams.get('project');
34
+ if (project) {
35
+ const filtered = [mockRun].filter((r) => r.projectName === project);
36
+ return HttpResponse.json({ runs: filtered, totalCount: filtered.length, project });
37
+ }
38
+
39
+ return HttpResponse.json({ runs: [mockRun], totalCount: 1 });
40
+ }),
41
+
42
+ // GET /api/runs/:runId — returns single run
43
+ http.get('/api/runs/:runId', ({ params }) => {
44
+ const { runId } = params;
45
+ if (runId === mockRun.runId) {
46
+ return HttpResponse.json({ run: mockRun });
47
+ }
48
+ return HttpResponse.json({ error: 'Run not found' }, { status: 404 });
49
+ }),
50
+
51
+ // GET /api/runs/:runId/events — returns journal events
52
+ http.get('/api/runs/:runId/events', () => {
53
+ return HttpResponse.json({ events: mockEvents, total: mockEvents.length });
54
+ }),
55
+
56
+ // GET /api/runs/:runId/tasks/:effectId — returns task detail
57
+ http.get('/api/runs/:runId/tasks/:effectId', ({ params }) => {
58
+ const { effectId } = params;
59
+ if (effectId === mockTaskDetail.effectId) {
60
+ return HttpResponse.json({ task: mockTaskDetail });
61
+ }
62
+ return HttpResponse.json({ error: 'Task not found' }, { status: 404 });
63
+ }),
64
+
65
+ // POST /api/runs/:runId/tasks/:effectId/resolve — resolve a breakpoint
66
+ http.post('/api/runs/:runId/tasks/:effectId/resolve', async ({ request }) => {
67
+ const body = (await request.json()) as { approved: boolean; value?: string };
68
+ return HttpResponse.json({ success: true, approved: body.approved });
69
+ }),
70
+
71
+ // GET /api/config — returns observer config
72
+ http.get('/api/config', () => {
73
+ return HttpResponse.json({
74
+ sources: [{ path: '/tmp/test-project', depth: 2, label: 'test' }],
75
+ port: 4800,
76
+ pollInterval: 2000,
77
+ theme: 'dark',
78
+ staleThresholdMs: 3600000,
79
+ });
80
+ }),
81
+
82
+ // POST /api/config — update observer config
83
+ http.post('/api/config', async ({ request }) => {
84
+ const body = (await request.json()) as Record<string, unknown>;
85
+ return HttpResponse.json({
86
+ sources: body.sources ?? [],
87
+ port: 4800,
88
+ pollInterval: (body.pollInterval as number) ?? 2000,
89
+ theme: (body.theme as string) ?? 'dark',
90
+ staleThresholdMs: (body.staleThresholdMs as number) ?? 3600000,
91
+ });
92
+ }),
93
+
94
+ // GET /api/digest — lightweight polling digest
95
+ http.get('/api/digest', () => {
96
+ return HttpResponse.json({
97
+ runs: [
98
+ {
99
+ runId: mockRun.runId,
100
+ latestSeq: 3,
101
+ status: mockRun.status,
102
+ taskCount: mockRun.totalTasks,
103
+ completedTasks: mockRun.completedTasks,
104
+ updatedAt: mockRun.updatedAt,
105
+ projectName: mockRun.projectName,
106
+ },
107
+ ],
108
+ });
109
+ }),
110
+ ];
@@ -0,0 +1,17 @@
1
+ import { setupServer } from 'msw/node';
2
+ import { handlers } from './handlers';
3
+
4
+ /**
5
+ * MSW server instance for use in Vitest (Node environment).
6
+ *
7
+ * Usage in tests:
8
+ *
9
+ * import { server } from '@/test/mocks/server';
10
+ *
11
+ * beforeAll(() => server.listen());
12
+ * afterEach(() => server.resetHandlers());
13
+ * afterAll(() => server.close());
14
+ *
15
+ * Or import the setup in vitest.config.ts setupFiles for global availability.
16
+ */
17
+ export const server = setupServer(...handlers);