@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,16 @@
1
+ import path from "path";
2
+ import { discoverAllRunDirs, invalidateDiscoveryCache, type DiscoveredRun } from "./source-discovery";
3
+
4
+ // Find a specific run directory by runId across all sources.
5
+ // On cache miss, invalidates the discovery cache and retries once
6
+ // to handle runs created after the last cache refresh.
7
+ export async function findRunDir(runId: string): Promise<DiscoveredRun | null> {
8
+ const allRuns = await discoverAllRunDirs();
9
+ const found = allRuns.find((r) => path.basename(r.runDir) === runId);
10
+ if (found) return found;
11
+
12
+ // Cache may be stale — force re-discovery and retry
13
+ invalidateDiscoveryCache();
14
+ const fresh = await discoverAllRunDirs();
15
+ return fresh.find((r) => path.basename(r.runDir) === runId) || null;
16
+ }
@@ -0,0 +1,404 @@
1
+ import { getRunDigest, parseRunDir } from "./parser";
2
+ import type { ParseRunResult } from "./parser";
3
+ import { discoverAllRunDirs, type DiscoveredRun } from "./source-discovery";
4
+ import type { WatchSource } from "./config-loader";
5
+ import type { RunDigest, Run, ProjectSummary } from "@/types";
6
+ import { promises as fs } from "fs";
7
+ import path from "path";
8
+ import { getGlobal } from "./global-registry";
9
+
10
+ /** Return true when err represents a "file/directory not found" filesystem error. */
11
+ function isNotFoundError(err: unknown): boolean {
12
+ if (!(err instanceof Error)) return false;
13
+ const code = (err as NodeJS.ErrnoException).code;
14
+ return code === "ENOENT" || code === "ENOTDIR" || err.message.includes("ENOENT");
15
+ }
16
+
17
+ // Extended RunDigest with cache metadata
18
+ export interface CachedRunDigest extends RunDigest {
19
+ processId: string;
20
+ sourceLabel?: string;
21
+ projectName?: string;
22
+ }
23
+
24
+ interface CacheEntry {
25
+ digest: CachedRunDigest;
26
+ cachedAt: number;
27
+ runDir: string;
28
+ fullRun?: Run;
29
+ /** Number of journal files parsed in the last full-run read — used for incremental parsing. */
30
+ journalFileCount?: number;
31
+ }
32
+
33
+ // Persist cache across HMR reloads via typed global registry
34
+ function getCache(): Map<string, CacheEntry> {
35
+ return getGlobal('__observer_run_cache__', () => new Map<string, CacheEntry>()) as Map<string, CacheEntry>;
36
+ }
37
+
38
+ // Cache size limit to prevent unbounded memory growth
39
+ const MAX_CACHE_SIZE = 1000;
40
+
41
+ // TTL constants
42
+ const TTL_COMPLETED = 30000; // 30s for completed runs
43
+ const TTL_ACTIVE = 5000; // 5s for active runs (waiting/pending)
44
+ // Note: TTL_BREAKPOINT was removed in v0.12.3 — the aggressive 3s eviction
45
+ // in getProjectSummaries() caused breakpoint banner flickering during active
46
+ // orchestration (race condition with forceRefreshBreakpointRuns + discovery
47
+ // debounce). The normal TTL_ACTIVE (5s) is sufficient for timely updates.
48
+
49
+ function getTTL(status: RunDigest["status"]): number {
50
+ return status === "waiting" || status === "pending" ? TTL_ACTIVE : TTL_COMPLETED;
51
+ }
52
+
53
+ function isCacheValid(entry: CacheEntry): boolean {
54
+ const now = Date.now();
55
+ const ttl = getTTL(entry.digest.status);
56
+ return now - entry.cachedAt < ttl;
57
+ }
58
+
59
+ // Evict oldest entries when cache exceeds MAX_CACHE_SIZE.
60
+ // Evicts expired entries first, then oldest by cachedAt if still over limit.
61
+ function evictIfNeeded(): void {
62
+ const cache = getCache();
63
+ if (cache.size <= MAX_CACHE_SIZE) return;
64
+
65
+ // First pass: remove expired entries
66
+ const now = Date.now();
67
+ for (const [key, entry] of cache) {
68
+ if (now - entry.cachedAt >= getTTL(entry.digest.status)) {
69
+ cache.delete(key);
70
+ }
71
+ }
72
+ if (cache.size <= MAX_CACHE_SIZE) return;
73
+
74
+ // Second pass: evict oldest entries until under limit
75
+ const entries = Array.from(cache.entries()).sort(
76
+ ([, a], [, b]) => a.cachedAt - b.cachedAt
77
+ );
78
+ const toRemove = cache.size - MAX_CACHE_SIZE;
79
+ for (let i = 0; i < toRemove; i++) {
80
+ cache.delete(entries[i][0]);
81
+ }
82
+ }
83
+
84
+ // Read processId and optional projectName from run.json
85
+ async function getRunJsonMeta(runDir: string): Promise<{ processId: string; projectName?: string }> {
86
+ try {
87
+ const runJsonPath = path.join(runDir, "run.json");
88
+ const content = await fs.readFile(runJsonPath, "utf-8");
89
+ const json = JSON.parse(content);
90
+ return {
91
+ processId: json.processId || "unknown",
92
+ projectName: json.projectName || undefined,
93
+ };
94
+ } catch (err) {
95
+ // ENOENT is expected for runs that haven't written run.json yet; warn on corruption or permission errors
96
+ if (!isNotFoundError(err)) {
97
+ console.warn(`[run-cache] Failed to read run.json metadata from ${runDir}:`, err);
98
+ }
99
+ return { processId: "unknown" };
100
+ }
101
+ }
102
+
103
+ export async function getDigestCached(
104
+ runDir: string,
105
+ source: WatchSource,
106
+ projectName: string
107
+ ): Promise<CachedRunDigest> {
108
+ const cache = getCache();
109
+ const entry = cache.get(runDir);
110
+
111
+ // Return cached if valid
112
+ if (entry && isCacheValid(entry)) {
113
+ return entry.digest;
114
+ }
115
+
116
+ // Cache miss — fetch fresh digest
117
+ const digest = await getRunDigest(runDir);
118
+ const meta = await getRunJsonMeta(runDir);
119
+
120
+ // Prefer projectName from run.json over discovery-provided name
121
+ const effectiveProjectName = meta.projectName || projectName;
122
+
123
+ const cachedDigest: CachedRunDigest = {
124
+ ...digest,
125
+ processId: meta.processId,
126
+ sourceLabel: source.label,
127
+ projectName: effectiveProjectName,
128
+ };
129
+
130
+ // Update cache
131
+ cache.set(runDir, {
132
+ digest: cachedDigest,
133
+ cachedAt: Date.now(),
134
+ runDir,
135
+ fullRun: entry?.fullRun, // Preserve full run if present
136
+ journalFileCount: entry?.journalFileCount, // Preserve for incremental parsing
137
+ });
138
+
139
+ evictIfNeeded();
140
+
141
+ return cachedDigest;
142
+ }
143
+
144
+ export async function getRunCached(
145
+ runDir: string,
146
+ source: WatchSource,
147
+ projectName: string
148
+ ): Promise<Run> {
149
+ const cache = getCache();
150
+ const entry = cache.get(runDir);
151
+
152
+ // Return cached full run if valid and present
153
+ if (entry && isCacheValid(entry) && entry.fullRun) {
154
+ return entry.fullRun;
155
+ }
156
+
157
+ // Build incremental options from previous cache entry (if available).
158
+ // This avoids re-parsing all journal files when only new events have
159
+ // been appended since the last read.
160
+ const incremental =
161
+ entry?.fullRun && entry.journalFileCount !== undefined
162
+ ? {
163
+ previousEvents: entry.fullRun.events,
164
+ previousFileCount: entry.journalFileCount,
165
+ }
166
+ : undefined;
167
+
168
+ // Fetch full run (incrementally when possible)
169
+ const run = await parseRunDir(runDir, incremental) as ParseRunResult;
170
+
171
+ // Extract the journal file count before stripping it from the Run object
172
+ const journalFileCount = run._journalFileCount;
173
+
174
+ // Read run.json meta for accurate projectName
175
+ const meta = await getRunJsonMeta(runDir);
176
+ const effectiveProjectName = meta.projectName || projectName;
177
+
178
+ // Enrich with metadata
179
+ const enrichedRun: Run = {
180
+ ...run,
181
+ sourceLabel: source.label,
182
+ projectName: effectiveProjectName,
183
+ };
184
+ // Remove internal field from the exposed Run object
185
+ delete (enrichedRun as unknown as Record<string, unknown>)._journalFileCount;
186
+
187
+ // Update cache with full run
188
+ const digest = await getDigestCached(runDir, source, projectName);
189
+ cache.set(runDir, {
190
+ digest,
191
+ cachedAt: Date.now(),
192
+ runDir,
193
+ fullRun: enrichedRun,
194
+ journalFileCount,
195
+ });
196
+
197
+ evictIfNeeded();
198
+
199
+ return enrichedRun;
200
+ }
201
+
202
+ export function invalidateRun(runDir: string): void {
203
+ getCache().delete(runDir);
204
+ }
205
+
206
+ /**
207
+ * Force-invalidate all cached entries that have pending breakpoints.
208
+ *
209
+ * Design note: this intentionally checks only `pendingBreakpoints > 0` without
210
+ * also requiring `waitingKind === "breakpoint"`. The broader condition is safer
211
+ * because it ensures *any* entry that might represent a breakpoint — even one
212
+ * whose waitingKind was not yet set by the parser — is evicted and refetched.
213
+ * `getProjectSummaries()` applies the stricter `waitingKind === "breakpoint"`
214
+ * filter when *counting* breakpoints for display, so the worst case of a
215
+ * broader eviction here is an extra cache miss, not a false positive in the UI.
216
+ */
217
+ export function forceRefreshBreakpointRuns(): void {
218
+ const cache = getCache();
219
+ for (const [runDir, entry] of cache) {
220
+ if (entry.digest.pendingBreakpoints && entry.digest.pendingBreakpoints > 0) {
221
+ cache.delete(runDir);
222
+ }
223
+ }
224
+ }
225
+
226
+ export function invalidateAll(): void {
227
+ getCache().clear();
228
+ lastDiscoveryTime = 0;
229
+ discoveryNeeded = true; // Force re-discovery on next request
230
+ }
231
+
232
+ export function getProjectSummaries(): ProjectSummary[] {
233
+ const cache = getCache();
234
+
235
+ // Build project summaries from cache entries.
236
+ // Breakpoint entries are only included when isCacheValid() passes (TTL_ACTIVE = 5s),
237
+ // which ensures stale breakpoint data is not displayed without destructively
238
+ // deleting cache entries (which caused flickering — see v0.12.3 fix).
239
+ const projectMap = new Map<string, {
240
+ totalRuns: number;
241
+ activeRuns: number;
242
+ completedRuns: number;
243
+ failedRuns: number;
244
+ staleRuns: number;
245
+ totalTasks: number;
246
+ completedTasksAggregate: number;
247
+ latestUpdate: string;
248
+ pendingBreakpoints: number;
249
+ breakpointRuns: ProjectSummary["breakpointRuns"];
250
+ }>();
251
+
252
+ for (const entry of cache.values()) {
253
+ const projectName = entry.digest.projectName || "Unknown";
254
+ const existing = projectMap.get(projectName) || {
255
+ totalRuns: 0,
256
+ activeRuns: 0,
257
+ completedRuns: 0,
258
+ failedRuns: 0,
259
+ staleRuns: 0,
260
+ totalTasks: 0,
261
+ completedTasksAggregate: 0,
262
+ latestUpdate: "",
263
+ pendingBreakpoints: 0,
264
+ breakpointRuns: [],
265
+ };
266
+
267
+ existing.totalRuns++;
268
+ existing.totalTasks += entry.digest.taskCount || 0;
269
+ existing.completedTasksAggregate += entry.digest.completedTasks || 0;
270
+
271
+ if ((entry.digest.status === "waiting" || entry.digest.status === "pending") && !entry.digest.isStale) {
272
+ existing.activeRuns++;
273
+ } else if (entry.digest.status === "completed") {
274
+ existing.completedRuns++;
275
+ } else if (entry.digest.status === "failed") {
276
+ existing.failedRuns++;
277
+ }
278
+
279
+ if (entry.digest.isStale) {
280
+ existing.staleRuns++;
281
+ }
282
+
283
+ // Track pending breakpoints. Breakpoint state only changes when explicitly
284
+ // approved (which calls invalidateRun), so we always count cached breakpoints
285
+ // regardless of cache TTL — the TTL controls when to re-fetch data from disk,
286
+ // not whether the data is valid for display. Removing the isCacheValid check
287
+ // prevents breakpoint banner flickering when discovery is debounced. (v0.12.3)
288
+ if (entry.digest.pendingBreakpoints && entry.digest.pendingBreakpoints > 0 &&
289
+ entry.digest.waitingKind === "breakpoint") {
290
+ existing.pendingBreakpoints += entry.digest.pendingBreakpoints;
291
+ existing.breakpointRuns.push({
292
+ runId: entry.digest.runId,
293
+ effectId: entry.digest.breakpointEffectId || "",
294
+ projectName,
295
+ processId: entry.digest.processId || "unknown",
296
+ breakpointQuestion: entry.digest.breakpointQuestion || "Approval required",
297
+ });
298
+ }
299
+
300
+ // Track latest update
301
+ if (!existing.latestUpdate || entry.digest.updatedAt > existing.latestUpdate) {
302
+ existing.latestUpdate = entry.digest.updatedAt;
303
+ }
304
+
305
+ projectMap.set(projectName, existing);
306
+ }
307
+
308
+ return Array.from(projectMap.entries()).map(([projectName, stats]) => ({
309
+ projectName,
310
+ ...stats,
311
+ }));
312
+ }
313
+
314
+ // Debounce discovery to avoid scanning filesystem on every poll
315
+ let lastDiscoveryTime = 0;
316
+ const DISCOVERY_DEBOUNCE_MS = 10000; // 10s — reduced from 60s to ensure new runs appear quickly
317
+ let discoveryNeeded = true; // Flag set by watcher when new runs detected
318
+
319
+ // Signal that new runs may exist (called by watcher)
320
+ export function requestDiscovery(): void {
321
+ discoveryNeeded = true;
322
+ }
323
+
324
+ export async function discoverAndCacheAll(): Promise<void> {
325
+ const cache = getCache();
326
+ const now = Date.now();
327
+
328
+ // If cache is populated and no new runs detected, skip expensive filesystem scan
329
+ if (cache.size > 0 && !discoveryNeeded && now - lastDiscoveryTime < DISCOVERY_DEBOUNCE_MS) {
330
+ return;
331
+ }
332
+
333
+ lastDiscoveryTime = now;
334
+ discoveryNeeded = false;
335
+
336
+ const discovered = await discoverAllRunDirs();
337
+
338
+ // Deduplicate by run ID (basename) — discoverAllRunDirs already deduplicates
339
+ // by run ID preferring directories with run.json, but guard here too in case
340
+ // the same run ID somehow appears from different sources.
341
+ const seen = new Set<string>();
342
+ const unique = discovered.filter((d: DiscoveredRun) => {
343
+ const runId = path.basename(d.runDir);
344
+ if (seen.has(runId)) return false;
345
+ seen.add(runId);
346
+ return true;
347
+ });
348
+
349
+ // Build the set of valid runDir paths from discovery
350
+ const validRunDirs = new Set(unique.map((d: DiscoveredRun) => d.runDir));
351
+
352
+ // Prune cache entries whose runDir is no longer in the discovered set.
353
+ // This removes ghost entries that were cached before dedup was applied
354
+ // (e.g. a duplicate run discovered at a different path).
355
+ for (const [runDir] of cache) {
356
+ if (!validRunDirs.has(runDir)) {
357
+ cache.delete(runDir);
358
+ }
359
+ }
360
+
361
+ // Pre-populate cache with digests in batches to avoid overwhelming filesystem
362
+ const BATCH_SIZE = 10;
363
+ for (let i = 0; i < unique.length; i += BATCH_SIZE) {
364
+ const batch = unique.slice(i, i + BATCH_SIZE);
365
+ await Promise.all(
366
+ batch.map(async (discoveredRun: DiscoveredRun) => {
367
+ try {
368
+ await getDigestCached(
369
+ discoveredRun.runDir,
370
+ discoveredRun.source,
371
+ discoveredRun.projectName
372
+ );
373
+ } catch (err) {
374
+ console.error(`Failed to cache run ${discoveredRun.runDir}:`, err);
375
+ }
376
+ })
377
+ );
378
+ }
379
+ }
380
+
381
+ // Return all cached digests without filesystem scanning.
382
+ // Used by the digest API for fast, non-blocking responses.
383
+ export function getAllCachedDigests(): CachedRunDigest[] {
384
+ const cache = getCache();
385
+ const digests: CachedRunDigest[] = [];
386
+ for (const entry of cache.values()) {
387
+ digests.push(entry.digest);
388
+ }
389
+ return digests;
390
+ }
391
+
392
+ // Export cache for debugging
393
+ export function getCacheStats() {
394
+ const cache = getCache();
395
+ return {
396
+ size: cache.size,
397
+ entries: Array.from(cache.entries()).map(([runDir, entry]) => ({
398
+ runDir,
399
+ status: entry.digest.status,
400
+ cachedAt: entry.cachedAt,
401
+ hasFullRun: !!entry.fullRun,
402
+ })),
403
+ };
404
+ }
@@ -0,0 +1,226 @@
1
+ import path from "path";
2
+ import { EventEmitter } from "events";
3
+ import { initWatcher, watcherEvents, type WatcherEvent } from "./watcher";
4
+ import { discoverAndCacheAll } from "./run-cache";
5
+ import { getGlobal } from "./global-registry";
6
+
7
+ // Shared event bus for SSE endpoints — persist across HMR via typed global registry
8
+ function getServerEvents(): EventEmitter {
9
+ return getGlobal('__observer_server_events__', () => new EventEmitter());
10
+ }
11
+
12
+ export const serverEvents = getServerEvents();
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Leading-edge debounce for SSE broadcasts
16
+ // ---------------------------------------------------------------------------
17
+ // N clients x M rapid file changes = N*M redundant API requests. This
18
+ // debounce fires immediately on the *first* run-changed event (leading edge),
19
+ // then collects any subsequent events within a 500ms window and emits a
20
+ // single batched notification containing all affected runIds.
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export const SSE_DEBOUNCE_MS = 500;
24
+
25
+ export interface BatchedRunChangedEvent {
26
+ type: "run-changed";
27
+ runIds: string[];
28
+ runDirs: string[];
29
+ }
30
+
31
+ // Persist debounce state across HMR reloads via typed global registry
32
+ interface DebounceState {
33
+ pendingRunDirs: Set<string>;
34
+ timer: ReturnType<typeof setTimeout> | null;
35
+ /** True while we are inside a debounce window (leading edge already fired). */
36
+ windowOpen: boolean;
37
+ }
38
+
39
+ function getDebounceState(): DebounceState {
40
+ return getGlobal('__observer_sse_debounce__', () => ({
41
+ pendingRunDirs: new Set<string>(),
42
+ timer: null,
43
+ windowOpen: false,
44
+ }));
45
+ }
46
+
47
+ export function resetDebounceState(): void {
48
+ const ds = getDebounceState();
49
+ if (ds.timer) clearTimeout(ds.timer);
50
+ ds.timer = null;
51
+ ds.pendingRunDirs.clear();
52
+ ds.windowOpen = false;
53
+ }
54
+
55
+ /** Flush the batch — emit a single event with all collected runIds. */
56
+ function flushBatch(): void {
57
+ const ds = getDebounceState();
58
+ const runDirs = Array.from(ds.pendingRunDirs);
59
+ ds.pendingRunDirs.clear();
60
+ ds.timer = null;
61
+ ds.windowOpen = false;
62
+
63
+ if (runDirs.length === 0) return;
64
+
65
+ const runIds = runDirs.map((d) => path.basename(d));
66
+ serverEvents.emit("run-changed", {
67
+ type: "run-changed",
68
+ runIds,
69
+ runDirs,
70
+ } as BatchedRunChangedEvent);
71
+ }
72
+
73
+ /**
74
+ * Enqueue a run-changed event into the leading-edge debounce.
75
+ *
76
+ * Behaviour:
77
+ * 1. If no window is open, emit immediately (leading edge) and open window.
78
+ * 2. If window is already open, collect the runDir and reset the 500ms timer
79
+ * (trailing flush).
80
+ */
81
+ export function enqueueRunChanged(event: WatcherEvent): void {
82
+ const ds = getDebounceState();
83
+
84
+ // Note: forceRefreshBreakpointRuns() was previously called here on every
85
+ // watcher event, but it deleted ALL breakpoint cache entries globally —
86
+ // causing banner flickering during active orchestration. The specific run
87
+ // is already invalidated by invalidateRun(runDir) in the watcher handlers,
88
+ // so broad cache clearing is unnecessary. (v0.12.3 fix)
89
+
90
+ if (!ds.windowOpen) {
91
+ // --- Leading edge: fire immediately for this single runDir ---
92
+ ds.windowOpen = true;
93
+
94
+ const runId = path.basename(event.runDir);
95
+ serverEvents.emit("run-changed", {
96
+ type: "run-changed",
97
+ runIds: [runId],
98
+ runDirs: [event.runDir],
99
+ } as BatchedRunChangedEvent);
100
+
101
+ // Open a 500ms collection window for subsequent events
102
+ ds.timer = setTimeout(flushBatch, SSE_DEBOUNCE_MS);
103
+ } else {
104
+ // --- Inside window: collect and reset timer ---
105
+ ds.pendingRunDirs.add(event.runDir);
106
+
107
+ // Reset the trailing-edge timer so the window extends on each new event
108
+ if (ds.timer) clearTimeout(ds.timer);
109
+ ds.timer = setTimeout(flushBatch, SSE_DEBOUNCE_MS);
110
+ }
111
+ }
112
+
113
+ // Persist initialization state across HMR reloads via typed global registry
114
+ interface InitState {
115
+ initialized: boolean;
116
+ initPromise: Promise<void> | null;
117
+ cleanup: (() => void) | null;
118
+ }
119
+
120
+ function getInitState(): InitState {
121
+ return getGlobal('__observer_init__', () => ({
122
+ initialized: false,
123
+ initPromise: null,
124
+ cleanup: null,
125
+ }));
126
+ }
127
+
128
+ export async function ensureInitialized(): Promise<void> {
129
+ const state = getInitState();
130
+
131
+ // Already initialized
132
+ if (state.initialized) {
133
+ return;
134
+ }
135
+
136
+ // Initialization in progress
137
+ if (state.initPromise) {
138
+ return state.initPromise;
139
+ }
140
+
141
+ // Start initialization
142
+ state.initPromise = (async () => {
143
+ try {
144
+ console.log("Starting server initialization...");
145
+
146
+ // Step 1: Initialize watcher
147
+ console.log("Setting up filesystem watcher...");
148
+ state.cleanup = await initWatcher();
149
+
150
+ // Step 2: Initial cache population
151
+ console.log("Populating cache with discovered runs...");
152
+ await discoverAndCacheAll();
153
+
154
+ // Step 3: Listen to watcher events and broadcast via leading-edge debounce
155
+ // Watcher already invalidates the specific run cache in handleJournalChange.
156
+ // Additionally, when a journal change is detected, force-refresh all breakpoint
157
+ // cache entries. This ensures that EFFECT_RESOLVED events for breakpoints
158
+ // immediately clear stale breakpoint data across all cached runs — not just
159
+ // the specific run that changed.
160
+ //
161
+ // run-changed events are routed through enqueueRunChanged() which implements
162
+ // a 500ms leading-edge debounce: the first event fires immediately, then
163
+ // subsequent events within the window are batched and flushed once.
164
+
165
+ // Dedup window for transient watcher errors — suppress duplicates within 5s
166
+ // to prevent cascading SSE error events that trigger client-side flash
167
+ let lastWatcherErrorTime = 0;
168
+ const WATCHER_ERROR_DEDUP_MS = 5000;
169
+
170
+ watcherEvents.on("change", (event: WatcherEvent) => {
171
+ if (event.type === "run-changed") {
172
+ enqueueRunChanged(event);
173
+ } else if (event.type === "new-run") {
174
+ serverEvents.emit("new-run", event);
175
+ } else if (event.type === "error") {
176
+ // Suppress transient watcher errors within dedup window
177
+ const now = Date.now();
178
+ if (now - lastWatcherErrorTime >= WATCHER_ERROR_DEDUP_MS) {
179
+ lastWatcherErrorTime = now;
180
+ serverEvents.emit("watcher-error", event);
181
+ }
182
+ }
183
+ });
184
+
185
+ state.initialized = true;
186
+ console.log("Server initialization complete");
187
+ } catch (err) {
188
+ console.error("Server initialization failed:", err);
189
+ state.initPromise = null;
190
+ throw err;
191
+ }
192
+ })();
193
+
194
+ return state.initPromise;
195
+ }
196
+
197
+ // Cleanup function for graceful shutdown
198
+ export async function shutdownServer(): Promise<void> {
199
+ const state = getInitState();
200
+
201
+ if (state.cleanup) {
202
+ state.cleanup();
203
+ state.cleanup = null;
204
+ }
205
+
206
+ state.initialized = false;
207
+ state.initPromise = null;
208
+
209
+ // Clear SSE debounce timers
210
+ resetDebounceState();
211
+
212
+ // Remove all event listeners
213
+ serverEvents.removeAllListeners();
214
+
215
+ console.log("Server shutdown complete");
216
+ }
217
+
218
+ // Get initialization status for debugging
219
+ export function getInitStatus() {
220
+ const state = getInitState();
221
+ return {
222
+ initialized: state.initialized,
223
+ hasCleanup: !!state.cleanup,
224
+ serverEventListeners: serverEvents.eventNames().length,
225
+ };
226
+ }