@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,591 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { promises as fs } from 'fs';
3
+
4
+ // Mock dependencies before importing the module under test
5
+ vi.mock('../parser', () => ({
6
+ getRunDigest: vi.fn(),
7
+ parseRunDir: vi.fn(),
8
+ }));
9
+
10
+ vi.mock('../source-discovery', () => ({
11
+ discoverAllRunDirs: vi.fn(),
12
+ }));
13
+
14
+ vi.mock('../config-loader', () => ({
15
+ getConfig: vi.fn(),
16
+ }));
17
+
18
+ import { getRunDigest, parseRunDir } from '../parser';
19
+ import { discoverAllRunDirs } from '../source-discovery';
20
+ import type { WatchSource } from '../config-loader';
21
+ import type { RunDigest, Run } from '@/types';
22
+ import {
23
+ getDigestCached,
24
+ getRunCached,
25
+ invalidateRun,
26
+ invalidateAll,
27
+ getProjectSummaries,
28
+ discoverAndCacheAll,
29
+ getCacheStats,
30
+ forceRefreshBreakpointRuns,
31
+ } from '../run-cache';
32
+
33
+ const mockGetRunDigest = vi.mocked(getRunDigest);
34
+ const mockParseRunDir = vi.mocked(parseRunDir);
35
+ const mockDiscoverAllRunDirs = vi.mocked(discoverAllRunDirs);
36
+ // Use vi.spyOn for fs methods so the same mock is shared with run-cache module
37
+ const mockReadFile = vi.spyOn(fs, 'readFile');
38
+
39
+ const defaultSource: WatchSource = { path: '/projects', depth: 2, label: 'test' };
40
+
41
+ function makeDigest(overrides: Partial<RunDigest> = {}): RunDigest {
42
+ return {
43
+ runId: 'run-001',
44
+ latestSeq: 5,
45
+ status: 'completed',
46
+ taskCount: 3,
47
+ completedTasks: 3,
48
+ updatedAt: '2024-01-15T10:00:00Z',
49
+ ...overrides,
50
+ };
51
+ }
52
+
53
+ function makeRun(overrides: Partial<Run> & { _journalFileCount?: number } = {}): Run & { _journalFileCount?: number } {
54
+ return {
55
+ runId: 'run-001',
56
+ processId: 'data-pipeline',
57
+ status: 'completed',
58
+ createdAt: '2024-01-15T10:00:00Z',
59
+ updatedAt: '2024-01-15T10:00:05Z',
60
+ tasks: [],
61
+ events: [],
62
+ totalTasks: 3,
63
+ completedTasks: 3,
64
+ failedTasks: 0,
65
+ duration: 5000,
66
+ _journalFileCount: 5,
67
+ ...overrides,
68
+ };
69
+ }
70
+
71
+ describe('run-cache', () => {
72
+ beforeEach(() => {
73
+ vi.resetAllMocks();
74
+ vi.useFakeTimers();
75
+ // Clear cache between tests
76
+ invalidateAll();
77
+ });
78
+
79
+ afterEach(() => {
80
+ vi.useRealTimers();
81
+ });
82
+
83
+ // -----------------------------------------------------------------------
84
+ // getDigestCached
85
+ // -----------------------------------------------------------------------
86
+ describe('getDigestCached', () => {
87
+ it('fetches and caches a digest on first call', async () => {
88
+ const digest = makeDigest();
89
+ mockGetRunDigest.mockResolvedValue(digest);
90
+ mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'my-proc' }));
91
+
92
+ const result = await getDigestCached('/runs/run-001', defaultSource, 'my-project');
93
+
94
+ expect(mockGetRunDigest).toHaveBeenCalledWith('/runs/run-001');
95
+ expect(result.runId).toBe('run-001');
96
+ expect(result.processId).toBe('my-proc');
97
+ expect(result.sourceLabel).toBe('test');
98
+ expect(result.projectName).toBe('my-project');
99
+ });
100
+
101
+ it('returns cached digest within TTL (completed run = 30s)', async () => {
102
+ const digest = makeDigest({ status: 'completed' });
103
+ mockGetRunDigest.mockResolvedValue(digest);
104
+ mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
105
+
106
+ await getDigestCached('/runs/run-001', defaultSource, 'proj');
107
+
108
+ // Advance time by 20s (within 30s TTL for completed runs)
109
+ vi.advanceTimersByTime(20000);
110
+
111
+ await getDigestCached('/runs/run-001', defaultSource, 'proj');
112
+
113
+ // Should only call getRunDigest once due to caching
114
+ expect(mockGetRunDigest).toHaveBeenCalledTimes(1);
115
+ });
116
+
117
+ it('refetches after TTL expires for completed runs (30s)', async () => {
118
+ const digest = makeDigest({ status: 'completed' });
119
+ mockGetRunDigest.mockResolvedValue(digest);
120
+ mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
121
+
122
+ await getDigestCached('/runs/run-001', defaultSource, 'proj');
123
+
124
+ // Advance time past 30s TTL
125
+ vi.advanceTimersByTime(31000);
126
+
127
+ await getDigestCached('/runs/run-001', defaultSource, 'proj');
128
+
129
+ expect(mockGetRunDigest).toHaveBeenCalledTimes(2);
130
+ });
131
+
132
+ it('uses shorter TTL (5s) for active runs', async () => {
133
+ const digest = makeDigest({ status: 'waiting' });
134
+ mockGetRunDigest.mockResolvedValue(digest);
135
+ mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
136
+
137
+ await getDigestCached('/runs/run-active', defaultSource, 'proj');
138
+
139
+ // At 4s it should still be cached
140
+ vi.advanceTimersByTime(4000);
141
+ await getDigestCached('/runs/run-active', defaultSource, 'proj');
142
+ expect(mockGetRunDigest).toHaveBeenCalledTimes(1);
143
+
144
+ // At 6s it should refetch
145
+ vi.advanceTimersByTime(2000);
146
+ await getDigestCached('/runs/run-active', defaultSource, 'proj');
147
+ expect(mockGetRunDigest).toHaveBeenCalledTimes(2);
148
+ });
149
+
150
+ it('uses shorter TTL (5s) for pending runs', async () => {
151
+ const digest = makeDigest({ status: 'pending' });
152
+ mockGetRunDigest.mockResolvedValue(digest);
153
+ mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
154
+
155
+ await getDigestCached('/runs/run-pending', defaultSource, 'proj');
156
+
157
+ vi.advanceTimersByTime(6000);
158
+
159
+ await getDigestCached('/runs/run-pending', defaultSource, 'proj');
160
+ expect(mockGetRunDigest).toHaveBeenCalledTimes(2);
161
+ });
162
+
163
+ it('returns "unknown" processId when run.json cannot be read', async () => {
164
+ mockGetRunDigest.mockResolvedValue(makeDigest());
165
+ mockReadFile.mockRejectedValue(new Error('ENOENT'));
166
+
167
+ const result = await getDigestCached('/runs/run-001', defaultSource, 'proj');
168
+
169
+ expect(result.processId).toBe('unknown');
170
+ });
171
+
172
+ it('returns "unknown" processId when run.json has no processId', async () => {
173
+ mockGetRunDigest.mockResolvedValue(makeDigest());
174
+ mockReadFile.mockResolvedValue(JSON.stringify({}));
175
+
176
+ const result = await getDigestCached('/runs/run-001', defaultSource, 'proj');
177
+
178
+ expect(result.processId).toBe('unknown');
179
+ });
180
+ });
181
+
182
+ // -----------------------------------------------------------------------
183
+ // getRunCached
184
+ // -----------------------------------------------------------------------
185
+ describe('getRunCached', () => {
186
+ it('fetches and caches a full run on first call', async () => {
187
+ const run = makeRun();
188
+ mockParseRunDir.mockResolvedValue(run);
189
+ mockGetRunDigest.mockResolvedValue(makeDigest());
190
+ mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
191
+
192
+ const result = await getRunCached('/runs/run-001', defaultSource, 'proj');
193
+
194
+ expect(mockParseRunDir).toHaveBeenCalledWith('/runs/run-001', undefined);
195
+ expect(result.runId).toBe('run-001');
196
+ expect(result.sourceLabel).toBe('test');
197
+ expect(result.projectName).toBe('proj');
198
+ });
199
+
200
+ it('returns cached full run within TTL', async () => {
201
+ const run = makeRun({ status: 'completed' });
202
+ mockParseRunDir.mockResolvedValue(run);
203
+ mockGetRunDigest.mockResolvedValue(makeDigest({ status: 'completed' }));
204
+ mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
205
+
206
+ await getRunCached('/runs/run-001', defaultSource, 'proj');
207
+
208
+ vi.advanceTimersByTime(20000);
209
+
210
+ await getRunCached('/runs/run-001', defaultSource, 'proj');
211
+
212
+ // parseRunDir should only be called once
213
+ expect(mockParseRunDir).toHaveBeenCalledTimes(1);
214
+ });
215
+
216
+ it('refetches after TTL expires', async () => {
217
+ const run = makeRun({ status: 'completed' });
218
+ mockParseRunDir.mockResolvedValue(run);
219
+ mockGetRunDigest.mockResolvedValue(makeDigest({ status: 'completed' }));
220
+ mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
221
+
222
+ await getRunCached('/runs/run-001', defaultSource, 'proj');
223
+
224
+ vi.advanceTimersByTime(31000);
225
+
226
+ await getRunCached('/runs/run-001', defaultSource, 'proj');
227
+
228
+ expect(mockParseRunDir).toHaveBeenCalledTimes(2);
229
+ });
230
+ });
231
+
232
+ // -----------------------------------------------------------------------
233
+ // invalidateRun
234
+ // -----------------------------------------------------------------------
235
+ describe('invalidateRun', () => {
236
+ it('removes a specific run from cache', async () => {
237
+ mockGetRunDigest.mockResolvedValue(makeDigest());
238
+ mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
239
+
240
+ await getDigestCached('/runs/run-001', defaultSource, 'proj');
241
+
242
+ expect(getCacheStats().size).toBe(1);
243
+
244
+ invalidateRun('/runs/run-001');
245
+
246
+ expect(getCacheStats().size).toBe(0);
247
+ });
248
+
249
+ it('does nothing when invalidating a non-existent key', () => {
250
+ expect(() => invalidateRun('/runs/nonexistent')).not.toThrow();
251
+ });
252
+ });
253
+
254
+ // -----------------------------------------------------------------------
255
+ // invalidateAll
256
+ // -----------------------------------------------------------------------
257
+ describe('invalidateAll', () => {
258
+ it('clears all entries from cache', async () => {
259
+ mockGetRunDigest.mockResolvedValue(makeDigest());
260
+ mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
261
+
262
+ await getDigestCached('/runs/run-001', defaultSource, 'proj');
263
+ await getDigestCached('/runs/run-002', defaultSource, 'proj');
264
+
265
+ expect(getCacheStats().size).toBe(2);
266
+
267
+ invalidateAll();
268
+
269
+ expect(getCacheStats().size).toBe(0);
270
+ });
271
+ });
272
+
273
+ // -----------------------------------------------------------------------
274
+ // getProjectSummaries
275
+ // -----------------------------------------------------------------------
276
+ describe('getProjectSummaries', () => {
277
+ it('returns empty array when cache is empty', () => {
278
+ const summaries = getProjectSummaries();
279
+ expect(summaries).toEqual([]);
280
+ });
281
+
282
+ it('groups runs by project name and counts statuses', async () => {
283
+ mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
284
+
285
+ // Add runs from different projects with different statuses
286
+ mockGetRunDigest.mockResolvedValue(makeDigest({ status: 'completed', updatedAt: '2024-01-15T10:00:00Z' }));
287
+ await getDigestCached('/runs/proj-a/run-1', defaultSource, 'project-a');
288
+
289
+ mockGetRunDigest.mockResolvedValue(makeDigest({ status: 'failed', updatedAt: '2024-01-15T11:00:00Z' }));
290
+ await getDigestCached('/runs/proj-a/run-2', defaultSource, 'project-a');
291
+
292
+ mockGetRunDigest.mockResolvedValue(makeDigest({ status: 'waiting', updatedAt: '2024-01-15T12:00:00Z' }));
293
+ await getDigestCached('/runs/proj-a/run-3', defaultSource, 'project-a');
294
+
295
+ mockGetRunDigest.mockResolvedValue(makeDigest({ status: 'completed', updatedAt: '2024-01-15T09:00:00Z' }));
296
+ await getDigestCached('/runs/proj-b/run-1', defaultSource, 'project-b');
297
+
298
+ const summaries = getProjectSummaries();
299
+
300
+ expect(summaries).toHaveLength(2);
301
+
302
+ const projA = summaries.find((s) => s.projectName === 'project-a');
303
+ expect(projA).toBeDefined();
304
+ expect(projA!.totalRuns).toBe(3);
305
+ expect(projA!.completedRuns).toBe(1);
306
+ expect(projA!.failedRuns).toBe(1);
307
+ expect(projA!.activeRuns).toBe(1);
308
+ expect(projA!.latestUpdate).toBe('2024-01-15T12:00:00Z');
309
+
310
+ const projB = summaries.find((s) => s.projectName === 'project-b');
311
+ expect(projB).toBeDefined();
312
+ expect(projB!.totalRuns).toBe(1);
313
+ expect(projB!.completedRuns).toBe(1);
314
+ });
315
+
316
+ it('uses "Unknown" for runs without projectName', async () => {
317
+ mockGetRunDigest.mockResolvedValue(makeDigest());
318
+ mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
319
+
320
+ // Pass empty string for projectName to simulate missing project
321
+ await getDigestCached('/runs/run-orphan', defaultSource, '');
322
+
323
+ const summaries = getProjectSummaries();
324
+
325
+ // The code checks for `entry.digest.projectName || "Unknown"` but the cache
326
+ // stores projectName as set — if empty string, it becomes "Unknown"
327
+ expect(summaries).toHaveLength(1);
328
+ });
329
+
330
+ it('tracks latest update across all runs in a project', async () => {
331
+ mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
332
+
333
+ mockGetRunDigest.mockResolvedValue(makeDigest({ updatedAt: '2024-01-15T08:00:00Z' }));
334
+ await getDigestCached('/runs/r1', defaultSource, 'proj');
335
+
336
+ mockGetRunDigest.mockResolvedValue(makeDigest({ updatedAt: '2024-01-15T12:00:00Z' }));
337
+ await getDigestCached('/runs/r2', defaultSource, 'proj');
338
+
339
+ mockGetRunDigest.mockResolvedValue(makeDigest({ updatedAt: '2024-01-15T10:00:00Z' }));
340
+ await getDigestCached('/runs/r3', defaultSource, 'proj');
341
+
342
+ const summaries = getProjectSummaries();
343
+ expect(summaries[0].latestUpdate).toBe('2024-01-15T12:00:00Z');
344
+ });
345
+ });
346
+
347
+ // -----------------------------------------------------------------------
348
+ // discoverAndCacheAll
349
+ // -----------------------------------------------------------------------
350
+ describe('discoverAndCacheAll', () => {
351
+ it('discovers runs and populates cache', async () => {
352
+ mockDiscoverAllRunDirs.mockResolvedValue([
353
+ { runDir: '/runs/r1', source: defaultSource, projectName: 'proj', projectPath: '/proj' },
354
+ { runDir: '/runs/r2', source: defaultSource, projectName: 'proj', projectPath: '/proj' },
355
+ ]);
356
+ mockGetRunDigest.mockResolvedValue(makeDigest());
357
+ mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
358
+
359
+ await discoverAndCacheAll();
360
+
361
+ expect(getCacheStats().size).toBe(2);
362
+ });
363
+
364
+ it('handles errors for individual runs without failing', async () => {
365
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
366
+
367
+ mockDiscoverAllRunDirs.mockResolvedValue([
368
+ { runDir: '/runs/ok', source: defaultSource, projectName: 'proj', projectPath: '/proj' },
369
+ { runDir: '/runs/bad', source: defaultSource, projectName: 'proj', projectPath: '/proj' },
370
+ ]);
371
+
372
+ mockGetRunDigest.mockImplementation(async (runDir: string) => {
373
+ if (runDir === '/runs/bad') throw new Error('corrupt');
374
+ return makeDigest();
375
+ });
376
+ mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
377
+
378
+ await discoverAndCacheAll();
379
+
380
+ // At least the successful run should be cached
381
+ expect(getCacheStats().size).toBeGreaterThanOrEqual(1);
382
+ consoleSpy.mockRestore();
383
+ });
384
+ });
385
+
386
+ // -----------------------------------------------------------------------
387
+ // forceRefreshBreakpointRuns
388
+ // -----------------------------------------------------------------------
389
+ describe('forceRefreshBreakpointRuns', () => {
390
+ it('deletes entries with pendingBreakpoints > 0', async () => {
391
+ mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
392
+
393
+ // Entry with pending breakpoints — should be deleted
394
+ mockGetRunDigest.mockResolvedValue(
395
+ makeDigest({ runId: 'bp-run', status: 'waiting', pendingBreakpoints: 2, waitingKind: 'breakpoint' })
396
+ );
397
+ await getDigestCached('/runs/bp-run', defaultSource, 'proj');
398
+
399
+ // Entry without breakpoints — should survive
400
+ mockGetRunDigest.mockResolvedValue(
401
+ makeDigest({ runId: 'normal-run', status: 'completed', pendingBreakpoints: 0 })
402
+ );
403
+ await getDigestCached('/runs/normal-run', defaultSource, 'proj');
404
+
405
+ expect(getCacheStats().size).toBe(2);
406
+
407
+ forceRefreshBreakpointRuns();
408
+
409
+ expect(getCacheStats().size).toBe(1);
410
+ const remaining = getCacheStats().entries;
411
+ expect(remaining[0].runDir).toBe('/runs/normal-run');
412
+ });
413
+
414
+ it('leaves entries intact when pendingBreakpoints is 0 or undefined', async () => {
415
+ mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
416
+
417
+ // Entry with pendingBreakpoints = 0
418
+ mockGetRunDigest.mockResolvedValue(
419
+ makeDigest({ runId: 'run-zero', status: 'waiting', pendingBreakpoints: 0 })
420
+ );
421
+ await getDigestCached('/runs/run-zero', defaultSource, 'proj');
422
+
423
+ // Entry with pendingBreakpoints undefined
424
+ mockGetRunDigest.mockResolvedValue(
425
+ makeDigest({ runId: 'run-undef', status: 'completed' })
426
+ );
427
+ await getDigestCached('/runs/run-undef', defaultSource, 'proj');
428
+
429
+ expect(getCacheStats().size).toBe(2);
430
+
431
+ forceRefreshBreakpointRuns();
432
+
433
+ expect(getCacheStats().size).toBe(2);
434
+ });
435
+
436
+ it('deletes multiple breakpoint entries in one call', async () => {
437
+ mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
438
+
439
+ mockGetRunDigest.mockResolvedValue(
440
+ makeDigest({ runId: 'bp-1', status: 'waiting', pendingBreakpoints: 1, waitingKind: 'breakpoint' })
441
+ );
442
+ await getDigestCached('/runs/bp-1', defaultSource, 'proj');
443
+
444
+ mockGetRunDigest.mockResolvedValue(
445
+ makeDigest({ runId: 'bp-2', status: 'waiting', pendingBreakpoints: 3, waitingKind: 'breakpoint' })
446
+ );
447
+ await getDigestCached('/runs/bp-2', defaultSource, 'proj');
448
+
449
+ mockGetRunDigest.mockResolvedValue(
450
+ makeDigest({ runId: 'safe', status: 'completed' })
451
+ );
452
+ await getDigestCached('/runs/safe', defaultSource, 'proj');
453
+
454
+ expect(getCacheStats().size).toBe(3);
455
+
456
+ forceRefreshBreakpointRuns();
457
+
458
+ expect(getCacheStats().size).toBe(1);
459
+ expect(getCacheStats().entries[0].runDir).toBe('/runs/safe');
460
+ });
461
+ });
462
+
463
+ // -----------------------------------------------------------------------
464
+ // Breakpoint cache behavior (v0.12.3 fix: no destructive eviction)
465
+ // -----------------------------------------------------------------------
466
+ describe('breakpoint cache behavior', () => {
467
+ it('does NOT destructively delete breakpoint entries from cache (v0.12.3 anti-flicker)', async () => {
468
+ mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
469
+
470
+ // Cache a breakpoint entry
471
+ mockGetRunDigest.mockResolvedValue(
472
+ makeDigest({
473
+ runId: 'bp-stable',
474
+ status: 'waiting',
475
+ pendingBreakpoints: 1,
476
+ waitingKind: 'breakpoint',
477
+ breakpointQuestion: 'Deploy?',
478
+ })
479
+ );
480
+ await getDigestCached('/runs/bp-stable', defaultSource, 'proj');
481
+
482
+ // Immediately, breakpoints should be counted
483
+ const before = getProjectSummaries();
484
+ expect(before).toHaveLength(1);
485
+ expect(before[0].pendingBreakpoints).toBe(1);
486
+
487
+ // Advance past old TTL_BREAKPOINT (3s) but within TTL_ACTIVE (5s)
488
+ vi.advanceTimersByTime(3500);
489
+
490
+ // Breakpoint should STILL be visible (not evicted)
491
+ const after = getProjectSummaries();
492
+ expect(after).toHaveLength(1);
493
+ expect(after[0].pendingBreakpoints).toBe(1);
494
+ expect(after[0].breakpointRuns).toHaveLength(1);
495
+ });
496
+
497
+ it('keeps counting breakpoints even after TTL_ACTIVE expires (no flickering)', async () => {
498
+ mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
499
+
500
+ mockGetRunDigest.mockResolvedValue(
501
+ makeDigest({
502
+ runId: 'bp-persistent',
503
+ status: 'waiting',
504
+ pendingBreakpoints: 2,
505
+ waitingKind: 'breakpoint',
506
+ breakpointQuestion: 'Approve?',
507
+ })
508
+ );
509
+ await getDigestCached('/runs/bp-persistent', defaultSource, 'proj');
510
+
511
+ // Within TTL_ACTIVE (5s) — should be counted
512
+ vi.advanceTimersByTime(4000);
513
+ const fresh = getProjectSummaries();
514
+ expect(fresh).toHaveLength(1);
515
+ expect(fresh[0].pendingBreakpoints).toBe(2);
516
+
517
+ // Past TTL_ACTIVE (5s) — breakpoints STILL counted (v0.12.3 fix).
518
+ // Breakpoint state only changes on explicit approval (invalidateRun),
519
+ // not on cache TTL expiry. This prevents banner flickering.
520
+ vi.advanceTimersByTime(2000); // now at 6s
521
+ const afterTtl = getProjectSummaries();
522
+ expect(afterTtl).toHaveLength(1);
523
+ expect(afterTtl[0].pendingBreakpoints).toBe(2);
524
+ expect(afterTtl[0].breakpointRuns).toHaveLength(1);
525
+ expect(afterTtl[0].totalRuns).toBe(1);
526
+ });
527
+
528
+ it('preserves both breakpoint and non-breakpoint entries regardless of TTL', async () => {
529
+ mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
530
+
531
+ // Breakpoint entry
532
+ mockGetRunDigest.mockResolvedValue(
533
+ makeDigest({
534
+ runId: 'bp-entry',
535
+ status: 'waiting',
536
+ pendingBreakpoints: 1,
537
+ waitingKind: 'breakpoint',
538
+ })
539
+ );
540
+ await getDigestCached('/runs/bp-entry', defaultSource, 'proj');
541
+
542
+ // Normal completed entry
543
+ mockGetRunDigest.mockResolvedValue(
544
+ makeDigest({ runId: 'normal', status: 'completed' })
545
+ );
546
+ await getDigestCached('/runs/normal', defaultSource, 'proj');
547
+
548
+ // Advance past TTL_ACTIVE but within completed TTL (30s)
549
+ vi.advanceTimersByTime(6000);
550
+
551
+ const summaries = getProjectSummaries();
552
+ expect(summaries).toHaveLength(1);
553
+ // Both entries still in cache, breakpoint still counted (v0.12.3)
554
+ expect(summaries[0].totalRuns).toBe(2);
555
+ expect(summaries[0].completedRuns).toBe(1);
556
+ expect(summaries[0].pendingBreakpoints).toBe(1);
557
+ });
558
+ });
559
+
560
+ // -----------------------------------------------------------------------
561
+ // getCacheStats
562
+ // -----------------------------------------------------------------------
563
+ describe('getCacheStats', () => {
564
+ it('returns size and entries info', async () => {
565
+ mockGetRunDigest.mockResolvedValue(makeDigest());
566
+ mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
567
+
568
+ await getDigestCached('/runs/run-001', defaultSource, 'proj');
569
+
570
+ const stats = getCacheStats();
571
+
572
+ expect(stats.size).toBe(1);
573
+ expect(stats.entries).toHaveLength(1);
574
+ expect(stats.entries[0].runDir).toBe('/runs/run-001');
575
+ expect(stats.entries[0].status).toBe('completed');
576
+ expect(stats.entries[0].hasFullRun).toBe(false);
577
+ });
578
+
579
+ it('shows hasFullRun=true after getRunCached', async () => {
580
+ mockParseRunDir.mockResolvedValue(makeRun());
581
+ mockGetRunDigest.mockResolvedValue(makeDigest());
582
+ mockReadFile.mockResolvedValue(JSON.stringify({ processId: 'proc' }));
583
+
584
+ await getRunCached('/runs/run-001', defaultSource, 'proj');
585
+
586
+ const stats = getCacheStats();
587
+
588
+ expect(stats.entries[0].hasFullRun).toBe(true);
589
+ });
590
+ });
591
+ });