@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.
- package/LICENSE +21 -0
- package/README.md +490 -0
- package/next.config.mjs +25 -0
- package/package.json +104 -0
- package/postcss.config.mjs +8 -0
- package/src/app/actions/__tests__/approve-breakpoint.test.ts +246 -0
- package/src/app/actions/approve-breakpoint.ts +145 -0
- package/src/app/api/config/route.ts +137 -0
- package/src/app/api/digest/route.ts +45 -0
- package/src/app/api/runs/[runId]/events/route.ts +56 -0
- package/src/app/api/runs/[runId]/route.ts +84 -0
- package/src/app/api/runs/[runId]/tasks/[effectId]/route.ts +44 -0
- package/src/app/api/runs/route.ts +48 -0
- package/src/app/api/stream/route.ts +136 -0
- package/src/app/api/test/route.ts +1 -0
- package/src/app/api/version/route.ts +57 -0
- package/src/app/globals.css +555 -0
- package/src/app/icon.svg +20 -0
- package/src/app/layout.tsx +39 -0
- package/src/app/not-found.tsx +16 -0
- package/src/app/page.tsx +120 -0
- package/src/app/runs/[runId]/page.tsx +279 -0
- package/src/cli.ts +271 -0
- package/src/components/breakpoint/__tests__/breakpoint-approval.test.tsx +212 -0
- package/src/components/breakpoint/__tests__/breakpoint-panel.test.tsx +130 -0
- package/src/components/breakpoint/__tests__/file-preview.test.tsx +313 -0
- package/src/components/breakpoint/breakpoint-approval.tsx +138 -0
- package/src/components/breakpoint/breakpoint-panel.tsx +95 -0
- package/src/components/breakpoint/file-preview.tsx +215 -0
- package/src/components/dashboard/.gitkeep +0 -0
- package/src/components/dashboard/__tests__/breakpoint-banner.test.tsx +177 -0
- package/src/components/dashboard/__tests__/catch-up-banner.test.tsx +141 -0
- package/src/components/dashboard/__tests__/executive-summary-banner.test.tsx +164 -0
- package/src/components/dashboard/__tests__/kpi-grid.test.tsx +101 -0
- package/src/components/dashboard/__tests__/pagination-controls.test.tsx +125 -0
- package/src/components/dashboard/__tests__/project-accordion.test.tsx +97 -0
- package/src/components/dashboard/__tests__/project-list-view.test.tsx +174 -0
- package/src/components/dashboard/__tests__/project-search-input.test.tsx +110 -0
- package/src/components/dashboard/__tests__/project-section-header.test.tsx +91 -0
- package/src/components/dashboard/__tests__/project-section.test.tsx +151 -0
- package/src/components/dashboard/__tests__/run-card.test.tsx +164 -0
- package/src/components/dashboard/__tests__/run-filter-bar.test.tsx +109 -0
- package/src/components/dashboard/__tests__/run-list.test.tsx +123 -0
- package/src/components/dashboard/__tests__/search-filter.test.tsx +150 -0
- package/src/components/dashboard/__tests__/virtualized-run-list.test.tsx +179 -0
- package/src/components/dashboard/breakpoint-banner.tsx +301 -0
- package/src/components/dashboard/catch-up-banner.tsx +88 -0
- package/src/components/dashboard/executive-summary-banner.tsx +174 -0
- package/src/components/dashboard/global-search.tsx +323 -0
- package/src/components/dashboard/kpi-grid.tsx +140 -0
- package/src/components/dashboard/pagination-controls.tsx +100 -0
- package/src/components/dashboard/project-accordion.tsx +72 -0
- package/src/components/dashboard/project-health-card.tsx +536 -0
- package/src/components/dashboard/project-list-view.tsx +246 -0
- package/src/components/dashboard/project-search-input.tsx +41 -0
- package/src/components/dashboard/project-section-header.tsx +73 -0
- package/src/components/dashboard/project-section.tsx +89 -0
- package/src/components/dashboard/run-card.tsx +218 -0
- package/src/components/dashboard/run-filter-bar.tsx +100 -0
- package/src/components/dashboard/run-list.tsx +77 -0
- package/src/components/dashboard/search-filter.tsx +69 -0
- package/src/components/dashboard/virtualized-run-list.tsx +130 -0
- package/src/components/details/.gitkeep +0 -0
- package/src/components/details/__tests__/agent-panel.test.tsx +236 -0
- package/src/components/details/__tests__/json-tree.test.tsx +347 -0
- package/src/components/details/__tests__/log-viewer.test.tsx +168 -0
- package/src/components/details/__tests__/task-detail.test.tsx +212 -0
- package/src/components/details/__tests__/timing-panel.test.tsx +271 -0
- package/src/components/details/agent-panel.tsx +234 -0
- package/src/components/details/json-tree/categorize.ts +131 -0
- package/src/components/details/json-tree/index.tsx +120 -0
- package/src/components/details/json-tree/json-node.tsx +223 -0
- package/src/components/details/json-tree/smart-summary.tsx +596 -0
- package/src/components/details/json-tree/tree-controls.tsx +47 -0
- package/src/components/details/json-tree.tsx +9 -0
- package/src/components/details/log-viewer.tsx +140 -0
- package/src/components/details/task-detail.tsx +114 -0
- package/src/components/details/timing-panel.tsx +247 -0
- package/src/components/events/.gitkeep +0 -0
- package/src/components/events/__tests__/event-item.test.tsx +211 -0
- package/src/components/events/__tests__/event-stream.test.tsx +225 -0
- package/src/components/events/event-item.tsx +121 -0
- package/src/components/events/event-stream.tsx +260 -0
- package/src/components/notifications/.gitkeep +0 -0
- package/src/components/notifications/__tests__/notification-panel.test.tsx +287 -0
- package/src/components/notifications/__tests__/notification-provider.test.tsx +585 -0
- package/src/components/notifications/__tests__/toast-stack.test.tsx +217 -0
- package/src/components/notifications/notification-panel.tsx +124 -0
- package/src/components/notifications/notification-provider.tsx +175 -0
- package/src/components/notifications/toast-stack.tsx +75 -0
- package/src/components/pipeline/.gitkeep +0 -0
- package/src/components/pipeline/__tests__/parallel-group.test.tsx +88 -0
- package/src/components/pipeline/__tests__/pipeline-view.test.tsx +345 -0
- package/src/components/pipeline/__tests__/step-card.test.tsx +330 -0
- package/src/components/pipeline/parallel-group.tsx +39 -0
- package/src/components/pipeline/pipeline-view.tsx +197 -0
- package/src/components/pipeline/step-card.tsx +166 -0
- package/src/components/providers/event-stream-provider.tsx +29 -0
- package/src/components/providers.tsx +24 -0
- package/src/components/shared/.gitkeep +0 -0
- package/src/components/shared/__tests__/empty-state.test.tsx +49 -0
- package/src/components/shared/__tests__/friendly-id.test.tsx +47 -0
- package/src/components/shared/__tests__/kbd.test.tsx +45 -0
- package/src/components/shared/__tests__/kind-badge.test.tsx +71 -0
- package/src/components/shared/__tests__/metrics-row.test.tsx +74 -0
- package/src/components/shared/__tests__/outcome-banner.test.tsx +71 -0
- package/src/components/shared/__tests__/progress-bar.test.tsx +89 -0
- package/src/components/shared/__tests__/session-pill.test.tsx +62 -0
- package/src/components/shared/__tests__/settings-modal.test.tsx +201 -0
- package/src/components/shared/__tests__/shortcuts-help.test.tsx +103 -0
- package/src/components/shared/__tests__/status-badge.test.tsx +98 -0
- package/src/components/shared/__tests__/theme-provider.test.tsx +100 -0
- package/src/components/shared/__tests__/truncated-id.test.tsx +53 -0
- package/src/components/shared/app-footer.tsx +80 -0
- package/src/components/shared/app-header.tsx +160 -0
- package/src/components/shared/empty-state.tsx +18 -0
- package/src/components/shared/error-boundary.tsx +81 -0
- package/src/components/shared/friendly-id.tsx +48 -0
- package/src/components/shared/kbd.tsx +15 -0
- package/src/components/shared/kind-badge.tsx +51 -0
- package/src/components/shared/metrics-row.tsx +106 -0
- package/src/components/shared/outcome-banner.tsx +56 -0
- package/src/components/shared/progress-bar.tsx +42 -0
- package/src/components/shared/session-pill.tsx +69 -0
- package/src/components/shared/settings-modal.tsx +509 -0
- package/src/components/shared/shortcuts-help.tsx +113 -0
- package/src/components/shared/status-badge.tsx +110 -0
- package/src/components/shared/theme-provider.tsx +46 -0
- package/src/components/shared/truncated-id.tsx +51 -0
- package/src/components/ui/.gitkeep +0 -0
- package/src/components/ui/__tests__/accordion.test.tsx +96 -0
- package/src/components/ui/__tests__/badge.test.tsx +69 -0
- package/src/components/ui/__tests__/button.test.tsx +113 -0
- package/src/components/ui/__tests__/tabs.test.tsx +75 -0
- package/src/components/ui/__tests__/tooltip.test.tsx +90 -0
- package/src/components/ui/accordion.tsx +61 -0
- package/src/components/ui/badge.tsx +25 -0
- package/src/components/ui/button.tsx +40 -0
- package/src/components/ui/card.tsx +21 -0
- package/src/components/ui/scroll-area.tsx +35 -0
- package/src/components/ui/separator.tsx +24 -0
- package/src/components/ui/tabs.tsx +64 -0
- package/src/components/ui/tooltip.tsx +37 -0
- package/src/hooks/.gitkeep +0 -0
- package/src/hooks/__tests__/use-animated-number.test.ts +184 -0
- package/src/hooks/__tests__/use-batched-updates.test.ts +315 -0
- package/src/hooks/__tests__/use-event-stream.test.ts +243 -0
- package/src/hooks/__tests__/use-keyboard.test.ts +217 -0
- package/src/hooks/__tests__/use-notifications.test.ts +230 -0
- package/src/hooks/__tests__/use-polling.test.ts +274 -0
- package/src/hooks/__tests__/use-project-runs.test.ts +163 -0
- package/src/hooks/__tests__/use-projects.test.ts +248 -0
- package/src/hooks/__tests__/use-run-dashboard.test.ts +168 -0
- package/src/hooks/__tests__/use-run-detail.test.ts +273 -0
- package/src/hooks/__tests__/use-smart-polling.test.ts +305 -0
- package/src/hooks/use-animated-number.ts +87 -0
- package/src/hooks/use-batched-updates.ts +150 -0
- package/src/hooks/use-event-stream.ts +150 -0
- package/src/hooks/use-keyboard.ts +45 -0
- package/src/hooks/use-notifications.ts +82 -0
- package/src/hooks/use-persisted-state.ts +60 -0
- package/src/hooks/use-polling.ts +60 -0
- package/src/hooks/use-project-runs.ts +51 -0
- package/src/hooks/use-projects.ts +26 -0
- package/src/hooks/use-run-dashboard.ts +207 -0
- package/src/hooks/use-run-detail.ts +77 -0
- package/src/hooks/use-smart-polling.ts +144 -0
- package/src/lib/.gitkeep +0 -0
- package/src/lib/__tests__/cn.test.ts +69 -0
- package/src/lib/__tests__/config-loader.test.ts +210 -0
- package/src/lib/__tests__/config.test.ts +561 -0
- package/src/lib/__tests__/error-handler.test.ts +143 -0
- package/src/lib/__tests__/fetcher.test.ts +517 -0
- package/src/lib/__tests__/global-registry.test.ts +214 -0
- package/src/lib/__tests__/parser.test.ts +1532 -0
- package/src/lib/__tests__/path-resolver.test.ts +112 -0
- package/src/lib/__tests__/run-cache.test.ts +591 -0
- package/src/lib/__tests__/server-init.test.ts +512 -0
- package/src/lib/__tests__/source-discovery.test.ts +246 -0
- package/src/lib/__tests__/utils.test.ts +160 -0
- package/src/lib/__tests__/watcher.test.ts +227 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/config-loader.ts +195 -0
- package/src/lib/config.ts +20 -0
- package/src/lib/error-handler.ts +76 -0
- package/src/lib/fetcher.ts +394 -0
- package/src/lib/global-registry.ts +117 -0
- package/src/lib/parser.ts +794 -0
- package/src/lib/path-resolver.ts +16 -0
- package/src/lib/run-cache.ts +404 -0
- package/src/lib/server-init.ts +226 -0
- package/src/lib/services/__tests__/run-query-service.test.ts +819 -0
- package/src/lib/services/run-query-service.ts +286 -0
- package/src/lib/source-discovery.ts +216 -0
- package/src/lib/utils.ts +103 -0
- package/src/lib/watcher.ts +265 -0
- package/src/test/fixtures.ts +269 -0
- package/src/test/mocks/handlers.ts +110 -0
- package/src/test/mocks/server.ts +17 -0
- package/src/test/setup.ts +200 -0
- package/src/test/test-utils.tsx +36 -0
- package/src/types/.gitkeep +0 -0
- package/src/types/breakpoint.ts +17 -0
- package/src/types/index.ts +214 -0
- 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);
|