@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,819 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import type { Run, ProjectSummary } from "@/types";
3
+ import type { ObserverConfig, WatchSource } from "@/lib/config-loader";
4
+ import type { DiscoveredRun } from "@/lib/source-discovery";
5
+ import {
6
+ RunQueryService,
7
+ runSortPriority,
8
+ sortRuns,
9
+ filterBySearch,
10
+ filterByStatus,
11
+ filterByRetention,
12
+ paginate,
13
+ toLightRuns,
14
+ type RunQueryDeps,
15
+ } from "../run-query-service";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Helpers
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const defaultSource: WatchSource = { path: "/projects", depth: 2, label: "test" };
22
+
23
+ // Use dates within the 30-day retention window (relative to "now")
24
+ const RECENT_DATE = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(); // 2 days ago
25
+ const RECENT_DATE_PLUS = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000 + 5000).toISOString();
26
+
27
+ function makeRun(overrides: Partial<Run> = {}): Run {
28
+ return {
29
+ runId: "run-001",
30
+ processId: "data-pipeline",
31
+ status: "completed",
32
+ createdAt: RECENT_DATE,
33
+ updatedAt: RECENT_DATE_PLUS,
34
+ tasks: [],
35
+ events: [
36
+ { seq: 1, id: "e1", ts: RECENT_DATE, type: "RUN_CREATED", payload: {} },
37
+ ],
38
+ totalTasks: 3,
39
+ completedTasks: 3,
40
+ failedTasks: 0,
41
+ duration: 5000,
42
+ ...overrides,
43
+ };
44
+ }
45
+
46
+ function makeDiscoveredRun(
47
+ runDir: string,
48
+ projectName: string,
49
+ source: WatchSource = defaultSource
50
+ ): DiscoveredRun {
51
+ return { runDir, source, projectName, projectPath: `/projects/${projectName}` };
52
+ }
53
+
54
+ function makeConfig(overrides: Partial<ObserverConfig> = {}): ObserverConfig {
55
+ return {
56
+ sources: [defaultSource],
57
+ port: 4800,
58
+ pollInterval: 2000,
59
+ theme: "dark",
60
+ staleThresholdMs: 3600000,
61
+ recentCompletionWindowMs: 14400000,
62
+ retentionDays: 30,
63
+ hiddenProjects: [],
64
+ ...overrides,
65
+ };
66
+ }
67
+
68
+ function makeSummary(overrides: Partial<ProjectSummary> = {}): ProjectSummary {
69
+ return {
70
+ projectName: "my-project",
71
+ totalRuns: 5,
72
+ activeRuns: 1,
73
+ completedRuns: 3,
74
+ failedRuns: 1,
75
+ staleRuns: 0,
76
+ totalTasks: 20,
77
+ completedTasksAggregate: 18,
78
+ latestUpdate: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
79
+ pendingBreakpoints: 0,
80
+ breakpointRuns: [],
81
+ ...overrides,
82
+ };
83
+ }
84
+
85
+ function makeMockDeps(overrides: Partial<RunQueryDeps> = {}): RunQueryDeps {
86
+ return {
87
+ getConfig: vi.fn().mockResolvedValue(makeConfig()),
88
+ discoverAllRunDirs: vi.fn().mockResolvedValue([]),
89
+ getProjectSummaries: vi.fn().mockReturnValue([]),
90
+ getRunCached: vi.fn().mockResolvedValue(makeRun()),
91
+ discoverAndCacheAll: vi.fn().mockResolvedValue(undefined),
92
+ ...overrides,
93
+ };
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Utility function tests
98
+ // ---------------------------------------------------------------------------
99
+
100
+ describe("runSortPriority", () => {
101
+ it("returns 0 for active non-stale waiting runs", () => {
102
+ expect(runSortPriority(makeRun({ status: "waiting", isStale: false }))).toBe(0);
103
+ });
104
+
105
+ it("returns 0 for active non-stale pending runs", () => {
106
+ expect(runSortPriority(makeRun({ status: "pending", isStale: false }))).toBe(0);
107
+ });
108
+
109
+ it("returns 1 for stale runs regardless of status", () => {
110
+ expect(runSortPriority(makeRun({ status: "waiting", isStale: true }))).toBe(1);
111
+ expect(runSortPriority(makeRun({ status: "completed", isStale: true }))).toBe(1);
112
+ });
113
+
114
+ it("returns 2 for failed runs", () => {
115
+ expect(runSortPriority(makeRun({ status: "failed" }))).toBe(2);
116
+ });
117
+
118
+ it("returns 3 for completed runs", () => {
119
+ expect(runSortPriority(makeRun({ status: "completed" }))).toBe(3);
120
+ });
121
+ });
122
+
123
+ describe("sortRuns", () => {
124
+ it("sorts by status priority then updatedAt DESC in 'status' mode", () => {
125
+ const runs = [
126
+ makeRun({ runId: "completed-old", status: "completed", updatedAt: "2024-01-01T00:00:00Z" }),
127
+ makeRun({ runId: "active", status: "waiting", updatedAt: "2024-01-10T00:00:00Z" }),
128
+ makeRun({ runId: "failed", status: "failed", updatedAt: "2024-01-05T00:00:00Z" }),
129
+ makeRun({ runId: "completed-new", status: "completed", updatedAt: "2024-01-15T00:00:00Z" }),
130
+ ];
131
+
132
+ sortRuns(runs, "status");
133
+
134
+ expect(runs.map((r) => r.runId)).toEqual([
135
+ "active", // priority 0
136
+ "failed", // priority 2
137
+ "completed-new", // priority 3, newer
138
+ "completed-old", // priority 3, older
139
+ ]);
140
+ });
141
+
142
+ it("sorts by updatedAt DESC in 'activity' mode", () => {
143
+ const runs = [
144
+ makeRun({ runId: "old", updatedAt: "2024-01-01T00:00:00Z" }),
145
+ makeRun({ runId: "newest", updatedAt: "2024-01-15T00:00:00Z" }),
146
+ makeRun({ runId: "middle", updatedAt: "2024-01-10T00:00:00Z" }),
147
+ ];
148
+
149
+ sortRuns(runs, "activity");
150
+
151
+ expect(runs.map((r) => r.runId)).toEqual(["newest", "middle", "old"]);
152
+ });
153
+
154
+ it("uses runId as tiebreaker in 'status' mode for stable ordering", () => {
155
+ // All runs have same status and same updatedAt — only runId differs
156
+ const runs = [
157
+ makeRun({ runId: "run-charlie", status: "completed", updatedAt: "2024-01-15T00:00:00Z" }),
158
+ makeRun({ runId: "run-alpha", status: "completed", updatedAt: "2024-01-15T00:00:00Z" }),
159
+ makeRun({ runId: "run-bravo", status: "completed", updatedAt: "2024-01-15T00:00:00Z" }),
160
+ ];
161
+
162
+ sortRuns(runs, "status");
163
+
164
+ // Should be deterministic: runId ascending as tiebreaker
165
+ expect(runs.map((r) => r.runId)).toEqual([
166
+ "run-alpha",
167
+ "run-bravo",
168
+ "run-charlie",
169
+ ]);
170
+ });
171
+
172
+ it("uses runId as tiebreaker in 'activity' mode for stable ordering", () => {
173
+ // All runs have same updatedAt — only runId differs
174
+ const runs = [
175
+ makeRun({ runId: "run-zebra", updatedAt: "2024-01-15T00:00:00Z" }),
176
+ makeRun({ runId: "run-alpha", updatedAt: "2024-01-15T00:00:00Z" }),
177
+ makeRun({ runId: "run-mango", updatedAt: "2024-01-15T00:00:00Z" }),
178
+ ];
179
+
180
+ sortRuns(runs, "activity");
181
+
182
+ // Should be deterministic: runId ascending as tiebreaker
183
+ expect(runs.map((r) => r.runId)).toEqual([
184
+ "run-alpha",
185
+ "run-mango",
186
+ "run-zebra",
187
+ ]);
188
+ });
189
+
190
+ it("produces the same order regardless of initial array order", () => {
191
+ const makeRunSet = () => [
192
+ makeRun({ runId: "run-3", status: "completed", updatedAt: "2024-01-10T00:00:00Z" }),
193
+ makeRun({ runId: "run-1", status: "completed", updatedAt: "2024-01-10T00:00:00Z" }),
194
+ makeRun({ runId: "run-2", status: "completed", updatedAt: "2024-01-10T00:00:00Z" }),
195
+ ];
196
+
197
+ const set1 = makeRunSet();
198
+ const set2 = makeRunSet().reverse();
199
+
200
+ sortRuns(set1, "status");
201
+ sortRuns(set2, "status");
202
+
203
+ expect(set1.map((r) => r.runId)).toEqual(set2.map((r) => r.runId));
204
+ });
205
+ });
206
+
207
+ describe("filterBySearch", () => {
208
+ it("returns all runs when search is empty", () => {
209
+ const runs = [makeRun({ runId: "a" }), makeRun({ runId: "b" })];
210
+ expect(filterBySearch(runs, "")).toEqual(runs);
211
+ });
212
+
213
+ it("filters by runId (case-insensitive)", () => {
214
+ const runs = [makeRun({ runId: "ABC-123" }), makeRun({ runId: "DEF-456" })];
215
+ const result = filterBySearch(runs, "abc");
216
+ expect(result).toHaveLength(1);
217
+ expect(result[0].runId).toBe("ABC-123");
218
+ });
219
+
220
+ it("filters by processId", () => {
221
+ const runs = [
222
+ makeRun({ processId: "data-pipeline" }),
223
+ makeRun({ processId: "web-server" }),
224
+ ];
225
+ const result = filterBySearch(runs, "pipeline");
226
+ expect(result).toHaveLength(1);
227
+ expect(result[0].processId).toBe("data-pipeline");
228
+ });
229
+
230
+ it("filters by projectName", () => {
231
+ const runs = [
232
+ makeRun({ projectName: "my-app" }),
233
+ makeRun({ projectName: "other-app" }),
234
+ ];
235
+ const result = filterBySearch(runs, "my-app");
236
+ expect(result).toHaveLength(1);
237
+ expect(result[0].projectName).toBe("my-app");
238
+ });
239
+
240
+ it("filters by status", () => {
241
+ const runs = [
242
+ makeRun({ status: "completed" }),
243
+ makeRun({ status: "failed" }),
244
+ ];
245
+ const result = filterBySearch(runs, "failed");
246
+ expect(result).toHaveLength(1);
247
+ expect(result[0].status).toBe("failed");
248
+ });
249
+
250
+ it("filters by task title", () => {
251
+ const runs = [
252
+ makeRun({
253
+ runId: "r1",
254
+ tasks: [
255
+ {
256
+ effectId: "e1", kind: "node", title: "Deploy service",
257
+ label: "deploy", status: "resolved", invocationKey: "k1",
258
+ stepId: "s1", taskId: "t1", requestedAt: "2024-01-15T10:00:00Z",
259
+ },
260
+ ],
261
+ }),
262
+ makeRun({ runId: "r2", tasks: [] }),
263
+ ];
264
+ const result = filterBySearch(runs, "Deploy");
265
+ expect(result).toHaveLength(1);
266
+ expect(result[0].runId).toBe("r1");
267
+ });
268
+
269
+ it("filters by task error message", () => {
270
+ const runs = [
271
+ makeRun({
272
+ runId: "r1",
273
+ tasks: [
274
+ {
275
+ effectId: "e1", kind: "node", title: "step1",
276
+ label: "step1", status: "error", invocationKey: "k1",
277
+ stepId: "s1", taskId: "t1", requestedAt: "2024-01-15T10:00:00Z",
278
+ error: { name: "TimeoutError", message: "Connection timed out" },
279
+ },
280
+ ],
281
+ }),
282
+ makeRun({ runId: "r2", tasks: [] }),
283
+ ];
284
+ const result = filterBySearch(runs, "timed out");
285
+ expect(result).toHaveLength(1);
286
+ expect(result[0].runId).toBe("r1");
287
+ });
288
+
289
+ it("filters by task error name", () => {
290
+ const runs = [
291
+ makeRun({
292
+ runId: "r1",
293
+ tasks: [
294
+ {
295
+ effectId: "e1", kind: "node", title: "step1",
296
+ label: "step1", status: "error", invocationKey: "k1",
297
+ stepId: "s1", taskId: "t1", requestedAt: "2024-01-15T10:00:00Z",
298
+ error: { name: "TimeoutError", message: "Connection timed out" },
299
+ },
300
+ ],
301
+ }),
302
+ ];
303
+ const result = filterBySearch(runs, "timeouterror");
304
+ expect(result).toHaveLength(1);
305
+ });
306
+ });
307
+
308
+ describe("filterByStatus", () => {
309
+ it("returns all runs when status is empty", () => {
310
+ const runs = [makeRun({ status: "completed" }), makeRun({ status: "failed" })];
311
+ expect(filterByStatus(runs, "")).toEqual(runs);
312
+ });
313
+
314
+ it("filters by exact status", () => {
315
+ const runs = [
316
+ makeRun({ runId: "a", status: "completed" }),
317
+ makeRun({ runId: "b", status: "failed" }),
318
+ makeRun({ runId: "c", status: "completed" }),
319
+ ];
320
+ const result = filterByStatus(runs, "failed");
321
+ expect(result).toHaveLength(1);
322
+ expect(result[0].runId).toBe("b");
323
+ });
324
+
325
+ it("treats 'waiting' status as including 'pending'", () => {
326
+ const runs = [
327
+ makeRun({ runId: "a", status: "waiting" }),
328
+ makeRun({ runId: "b", status: "pending" }),
329
+ makeRun({ runId: "c", status: "completed" }),
330
+ ];
331
+ const result = filterByStatus(runs, "waiting");
332
+ expect(result).toHaveLength(2);
333
+ expect(result.map((r) => r.runId)).toEqual(["a", "b"]);
334
+ });
335
+ });
336
+
337
+ describe("filterByRetention", () => {
338
+ it("keeps active/stale runs regardless of age", () => {
339
+ const runs = [
340
+ makeRun({ runId: "active", status: "waiting", updatedAt: "2020-01-01T00:00:00Z" }),
341
+ makeRun({ runId: "stale", status: "completed", isStale: true, updatedAt: "2020-01-01T00:00:00Z" }),
342
+ makeRun({ runId: "pending", status: "pending", updatedAt: "2020-01-01T00:00:00Z" }),
343
+ ];
344
+ const result = filterByRetention(runs, 30);
345
+ expect(result).toHaveLength(3);
346
+ });
347
+
348
+ it("excludes old completed runs beyond retention period", () => {
349
+ const now = Date.now();
350
+ const oldDate = new Date(now - 31 * 24 * 60 * 60 * 1000).toISOString(); // 31 days ago
351
+ const recentDate = new Date(now - 5 * 24 * 60 * 60 * 1000).toISOString(); // 5 days ago
352
+
353
+ const runs = [
354
+ makeRun({ runId: "old", status: "completed", updatedAt: oldDate }),
355
+ makeRun({ runId: "recent", status: "completed", updatedAt: recentDate }),
356
+ ];
357
+ const result = filterByRetention(runs, 30);
358
+ expect(result).toHaveLength(1);
359
+ expect(result[0].runId).toBe("recent");
360
+ });
361
+ });
362
+
363
+ describe("paginate", () => {
364
+ it("returns all items when limit is 0", () => {
365
+ const items = [1, 2, 3, 4, 5];
366
+ expect(paginate(items, 0, 0)).toEqual([1, 2, 3, 4, 5]);
367
+ });
368
+
369
+ it("applies offset and limit correctly", () => {
370
+ const items = [1, 2, 3, 4, 5];
371
+ expect(paginate(items, 1, 2)).toEqual([2, 3]);
372
+ });
373
+
374
+ it("handles offset beyond array length", () => {
375
+ const items = [1, 2, 3];
376
+ expect(paginate(items, 10, 5)).toEqual([]);
377
+ });
378
+
379
+ it("handles limit larger than remaining items", () => {
380
+ const items = [1, 2, 3];
381
+ expect(paginate(items, 1, 100)).toEqual([2, 3]);
382
+ });
383
+ });
384
+
385
+ describe("toLightRuns", () => {
386
+ it("strips events and adds totalEvents count", () => {
387
+ const runs = [
388
+ makeRun({
389
+ runId: "r1",
390
+ events: [
391
+ { seq: 1, id: "e1", ts: "2024-01-15T10:00:00Z", type: "RUN_CREATED", payload: {} },
392
+ { seq: 2, id: "e2", ts: "2024-01-15T10:00:01Z", type: "RUN_COMPLETED", payload: {} },
393
+ ],
394
+ }),
395
+ ];
396
+
397
+ const light = toLightRuns(runs);
398
+
399
+ expect(light).toHaveLength(1);
400
+ expect(light[0].runId).toBe("r1");
401
+ expect(light[0].events).toEqual([]);
402
+ expect(light[0].totalEvents).toBe(2);
403
+ });
404
+
405
+ it("preserves all other run fields", () => {
406
+ const run = makeRun({ runId: "r1", processId: "proc", status: "failed" });
407
+ const [light] = toLightRuns([run]);
408
+
409
+ expect(light.runId).toBe("r1");
410
+ expect(light.processId).toBe("proc");
411
+ expect(light.status).toBe("failed");
412
+ expect(light.totalTasks).toBe(3);
413
+ });
414
+ });
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // RunQueryService (uses dependency injection for reliable testing)
418
+ // ---------------------------------------------------------------------------
419
+
420
+ describe("RunQueryService", () => {
421
+ let deps: RunQueryDeps;
422
+ let service: RunQueryService;
423
+
424
+ beforeEach(() => {
425
+ deps = makeMockDeps();
426
+ service = new RunQueryService(deps);
427
+ });
428
+
429
+ // -----------------------------------------------------------------------
430
+ // listProjects
431
+ // -----------------------------------------------------------------------
432
+ describe("listProjects", () => {
433
+ it("returns project summaries after discovering all runs", async () => {
434
+ (deps.getProjectSummaries as ReturnType<typeof vi.fn>).mockReturnValue([
435
+ makeSummary({ projectName: "alpha", latestUpdate: "2024-01-15T12:00:00Z" }),
436
+ makeSummary({ projectName: "beta", latestUpdate: "2024-01-15T11:00:00Z" }),
437
+ ]);
438
+
439
+ const result = await service.listProjects();
440
+
441
+ expect(deps.discoverAndCacheAll).toHaveBeenCalled();
442
+ expect(result.projects).toHaveLength(2);
443
+ expect(result.projects[0].projectName).toBe("alpha");
444
+ expect(result.projects[1].projectName).toBe("beta");
445
+ expect(result.recentCompletionWindowMs).toBe(14400000);
446
+ });
447
+
448
+ it("filters out hidden projects", async () => {
449
+ (deps.getConfig as ReturnType<typeof vi.fn>).mockResolvedValue(
450
+ makeConfig({ hiddenProjects: ["secret"] })
451
+ );
452
+ (deps.getProjectSummaries as ReturnType<typeof vi.fn>).mockReturnValue([
453
+ makeSummary({ projectName: "visible" }),
454
+ makeSummary({ projectName: "secret" }),
455
+ ]);
456
+
457
+ const result = await service.listProjects();
458
+
459
+ expect(result.projects).toHaveLength(1);
460
+ expect(result.projects[0].projectName).toBe("visible");
461
+ });
462
+
463
+ it("applies retention filter on projects", async () => {
464
+ (deps.getConfig as ReturnType<typeof vi.fn>).mockResolvedValue(
465
+ makeConfig({ retentionDays: 7 })
466
+ );
467
+
468
+ const oldDate = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString();
469
+ const recentDate = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString();
470
+
471
+ (deps.getProjectSummaries as ReturnType<typeof vi.fn>).mockReturnValue([
472
+ makeSummary({ projectName: "old", latestUpdate: oldDate, activeRuns: 0, staleRuns: 0 }),
473
+ makeSummary({ projectName: "recent", latestUpdate: recentDate, activeRuns: 0, staleRuns: 0 }),
474
+ ]);
475
+
476
+ const result = await service.listProjects();
477
+
478
+ expect(result.projects).toHaveLength(1);
479
+ expect(result.projects[0].projectName).toBe("recent");
480
+ });
481
+
482
+ it("keeps old projects that still have active runs", async () => {
483
+ (deps.getConfig as ReturnType<typeof vi.fn>).mockResolvedValue(
484
+ makeConfig({ retentionDays: 7 })
485
+ );
486
+
487
+ const oldDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
488
+
489
+ (deps.getProjectSummaries as ReturnType<typeof vi.fn>).mockReturnValue([
490
+ makeSummary({ projectName: "old-active", latestUpdate: oldDate, activeRuns: 1, staleRuns: 0 }),
491
+ ]);
492
+
493
+ const result = await service.listProjects();
494
+ expect(result.projects).toHaveLength(1);
495
+ });
496
+
497
+ it("keeps old projects that have stale runs", async () => {
498
+ (deps.getConfig as ReturnType<typeof vi.fn>).mockResolvedValue(
499
+ makeConfig({ retentionDays: 7 })
500
+ );
501
+
502
+ const oldDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
503
+
504
+ (deps.getProjectSummaries as ReturnType<typeof vi.fn>).mockReturnValue([
505
+ makeSummary({ projectName: "stale-proj", latestUpdate: oldDate, activeRuns: 0, staleRuns: 2 }),
506
+ ]);
507
+
508
+ const result = await service.listProjects();
509
+ expect(result.projects).toHaveLength(1);
510
+ });
511
+
512
+ it("sorts projects: active first, then by latest update", async () => {
513
+ const d3 = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString();
514
+ const d5 = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString();
515
+ const d10 = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString();
516
+ (deps.getProjectSummaries as ReturnType<typeof vi.fn>).mockReturnValue([
517
+ makeSummary({ projectName: "no-active-newer", activeRuns: 0, latestUpdate: d3 }),
518
+ makeSummary({ projectName: "active", activeRuns: 2, latestUpdate: d10 }),
519
+ makeSummary({ projectName: "no-active-older", activeRuns: 0, latestUpdate: d5 }),
520
+ ]);
521
+
522
+ const result = await service.listProjects();
523
+
524
+ expect(result.projects.map((p) => p.projectName)).toEqual([
525
+ "active",
526
+ "no-active-newer",
527
+ "no-active-older",
528
+ ]);
529
+ });
530
+ });
531
+
532
+ // -----------------------------------------------------------------------
533
+ // listProjectRuns
534
+ // -----------------------------------------------------------------------
535
+ describe("listProjectRuns", () => {
536
+ it("returns runs filtered by project name", async () => {
537
+ (deps.discoverAllRunDirs as ReturnType<typeof vi.fn>).mockResolvedValue([
538
+ makeDiscoveredRun("/runs/r1", "proj-a"),
539
+ makeDiscoveredRun("/runs/r2", "proj-b"),
540
+ makeDiscoveredRun("/runs/r3", "proj-a"),
541
+ ]);
542
+
543
+ const d1 = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString();
544
+ const d2 = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString();
545
+ const runA1 = makeRun({ runId: "r1", updatedAt: d1 });
546
+ const runA2 = makeRun({ runId: "r3", updatedAt: d2 });
547
+
548
+ (deps.getRunCached as ReturnType<typeof vi.fn>).mockImplementation(
549
+ async (runDir: string) => {
550
+ if (runDir === "/runs/r1") return runA1;
551
+ return runA2;
552
+ }
553
+ );
554
+
555
+ // Debug: verify the deps are wired to the service
556
+ const directResult = await deps.discoverAllRunDirs();
557
+ console.log("DEBUG direct call:", directResult.length, "items");
558
+ console.log("DEBUG deps === service deps?", deps.discoverAllRunDirs === (service as any).deps.discoverAllRunDirs);
559
+ const cached = await deps.getRunCached("/runs/r1", defaultSource, "proj-a");
560
+ console.log("DEBUG getRunCached direct:", cached?.runId);
561
+
562
+ const result = await service.listProjectRuns({
563
+ project: "proj-a",
564
+ limit: 0, offset: 0, search: "", status: "", sort: "status",
565
+ });
566
+
567
+ console.log("DEBUG result runs:", result.runs.length, "totalCount:", result.totalCount);
568
+
569
+ expect(result.runs).toHaveLength(2);
570
+ expect(result.totalCount).toBe(2);
571
+ expect(result.project).toBe("proj-a");
572
+ });
573
+
574
+ it("applies retention filter", async () => {
575
+ (deps.getConfig as ReturnType<typeof vi.fn>).mockResolvedValue(
576
+ makeConfig({ retentionDays: 7 })
577
+ );
578
+
579
+ const now = Date.now();
580
+ const oldDate = new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString();
581
+ const recentDate = new Date(now - 1 * 24 * 60 * 60 * 1000).toISOString();
582
+
583
+ (deps.discoverAllRunDirs as ReturnType<typeof vi.fn>).mockResolvedValue([
584
+ makeDiscoveredRun("/runs/old", "proj"),
585
+ makeDiscoveredRun("/runs/recent", "proj"),
586
+ ]);
587
+
588
+ (deps.getRunCached as ReturnType<typeof vi.fn>).mockImplementation(
589
+ async (runDir: string) => {
590
+ if (runDir === "/runs/old") return makeRun({ runId: "old", status: "completed", updatedAt: oldDate });
591
+ return makeRun({ runId: "recent", status: "completed", updatedAt: recentDate });
592
+ }
593
+ );
594
+
595
+ const result = await service.listProjectRuns({
596
+ project: "proj",
597
+ limit: 0, offset: 0, search: "", status: "", sort: "status",
598
+ });
599
+
600
+ expect(result.runs).toHaveLength(1);
601
+ expect(result.runs[0].runId).toBe("recent");
602
+ });
603
+
604
+ it("applies status filter", async () => {
605
+ (deps.discoverAllRunDirs as ReturnType<typeof vi.fn>).mockResolvedValue([
606
+ makeDiscoveredRun("/runs/r1", "proj"),
607
+ makeDiscoveredRun("/runs/r2", "proj"),
608
+ ]);
609
+
610
+ (deps.getRunCached as ReturnType<typeof vi.fn>).mockImplementation(
611
+ async (runDir: string) => {
612
+ if (runDir === "/runs/r1") return makeRun({ runId: "r1", status: "completed" });
613
+ return makeRun({ runId: "r2", status: "failed" });
614
+ }
615
+ );
616
+
617
+ const result = await service.listProjectRuns({
618
+ project: "proj",
619
+ limit: 0, offset: 0, search: "", status: "failed", sort: "status",
620
+ });
621
+
622
+ expect(result.runs).toHaveLength(1);
623
+ expect(result.runs[0].runId).toBe("r2");
624
+ });
625
+
626
+ it("applies search filter", async () => {
627
+ (deps.discoverAllRunDirs as ReturnType<typeof vi.fn>).mockResolvedValue([
628
+ makeDiscoveredRun("/runs/r1", "proj"),
629
+ makeDiscoveredRun("/runs/r2", "proj"),
630
+ ]);
631
+
632
+ (deps.getRunCached as ReturnType<typeof vi.fn>).mockImplementation(
633
+ async (runDir: string) => {
634
+ if (runDir === "/runs/r1") return makeRun({ runId: "deploy-001", processId: "deployer" });
635
+ return makeRun({ runId: "test-002", processId: "tester" });
636
+ }
637
+ );
638
+
639
+ const result = await service.listProjectRuns({
640
+ project: "proj",
641
+ limit: 0, offset: 0, search: "deploy", status: "", sort: "status",
642
+ });
643
+
644
+ expect(result.runs).toHaveLength(1);
645
+ expect(result.runs[0].runId).toBe("deploy-001");
646
+ });
647
+
648
+ it("applies pagination", async () => {
649
+ const d1 = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString();
650
+ const d2 = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString();
651
+ const d3 = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString();
652
+
653
+ (deps.discoverAllRunDirs as ReturnType<typeof vi.fn>).mockResolvedValue([
654
+ makeDiscoveredRun("/runs/r1", "proj"),
655
+ makeDiscoveredRun("/runs/r2", "proj"),
656
+ makeDiscoveredRun("/runs/r3", "proj"),
657
+ ]);
658
+
659
+ (deps.getRunCached as ReturnType<typeof vi.fn>).mockImplementation(
660
+ async (runDir: string) => {
661
+ if (runDir === "/runs/r1") return makeRun({ runId: "r1", updatedAt: d1 });
662
+ if (runDir === "/runs/r2") return makeRun({ runId: "r2", updatedAt: d2 });
663
+ return makeRun({ runId: "r3", updatedAt: d3 });
664
+ }
665
+ );
666
+
667
+ const result = await service.listProjectRuns({
668
+ project: "proj",
669
+ limit: 1, offset: 1, search: "", status: "", sort: "activity",
670
+ });
671
+
672
+ // Sorted by activity: r3, r2, r1 => offset 1 + limit 1 => [r2]
673
+ expect(result.totalCount).toBe(3);
674
+ expect(result.runs).toHaveLength(1);
675
+ expect(result.runs[0].runId).toBe("r2");
676
+ });
677
+
678
+ it("strips events from runs in response", async () => {
679
+ (deps.discoverAllRunDirs as ReturnType<typeof vi.fn>).mockResolvedValue([
680
+ makeDiscoveredRun("/runs/r1", "proj"),
681
+ ]);
682
+
683
+ (deps.getRunCached as ReturnType<typeof vi.fn>).mockResolvedValue(
684
+ makeRun({
685
+ runId: "r1",
686
+ events: [
687
+ { seq: 1, id: "e1", ts: RECENT_DATE, type: "RUN_CREATED", payload: {} },
688
+ { seq: 2, id: "e2", ts: RECENT_DATE_PLUS, type: "RUN_COMPLETED", payload: {} },
689
+ ],
690
+ })
691
+ );
692
+
693
+ const result = await service.listProjectRuns({
694
+ project: "proj",
695
+ limit: 0, offset: 0, search: "", status: "", sort: "status",
696
+ });
697
+
698
+ expect(result.runs[0].events).toEqual([]);
699
+ expect(result.runs[0].totalEvents).toBe(2);
700
+ });
701
+ });
702
+
703
+ // -----------------------------------------------------------------------
704
+ // listAllRuns
705
+ // -----------------------------------------------------------------------
706
+ describe("listAllRuns", () => {
707
+ it("returns all runs from all projects", async () => {
708
+ (deps.discoverAllRunDirs as ReturnType<typeof vi.fn>).mockResolvedValue([
709
+ makeDiscoveredRun("/runs/r1", "proj-a"),
710
+ makeDiscoveredRun("/runs/r2", "proj-b"),
711
+ ]);
712
+
713
+ (deps.getRunCached as ReturnType<typeof vi.fn>).mockImplementation(
714
+ async (runDir: string) => {
715
+ if (runDir === "/runs/r1") return makeRun({ runId: "r1", projectName: "proj-a" });
716
+ return makeRun({ runId: "r2", projectName: "proj-b" });
717
+ }
718
+ );
719
+
720
+ const result = await service.listAllRuns({
721
+ limit: 0, offset: 0, search: "", status: "", sort: "status",
722
+ });
723
+
724
+ expect(result.runs).toHaveLength(2);
725
+ expect(result.totalCount).toBe(2);
726
+ expect(result.project).toBeUndefined();
727
+ });
728
+
729
+ it("applies search filter", async () => {
730
+ (deps.discoverAllRunDirs as ReturnType<typeof vi.fn>).mockResolvedValue([
731
+ makeDiscoveredRun("/runs/r1", "proj-a"),
732
+ makeDiscoveredRun("/runs/r2", "proj-b"),
733
+ ]);
734
+
735
+ (deps.getRunCached as ReturnType<typeof vi.fn>).mockImplementation(
736
+ async (runDir: string) => {
737
+ if (runDir === "/runs/r1") return makeRun({ runId: "deploy-001" });
738
+ return makeRun({ runId: "test-002" });
739
+ }
740
+ );
741
+
742
+ const result = await service.listAllRuns({
743
+ limit: 0, offset: 0, search: "test", status: "", sort: "status",
744
+ });
745
+
746
+ expect(result.runs).toHaveLength(1);
747
+ expect(result.runs[0].runId).toBe("test-002");
748
+ });
749
+
750
+ it("applies sort in activity mode", async () => {
751
+ (deps.discoverAllRunDirs as ReturnType<typeof vi.fn>).mockResolvedValue([
752
+ makeDiscoveredRun("/runs/r1", "proj"),
753
+ makeDiscoveredRun("/runs/r2", "proj"),
754
+ ]);
755
+
756
+ (deps.getRunCached as ReturnType<typeof vi.fn>).mockImplementation(
757
+ async (runDir: string) => {
758
+ if (runDir === "/runs/r1")
759
+ return makeRun({ runId: "old", updatedAt: "2024-01-01T00:00:00Z" });
760
+ return makeRun({ runId: "new", updatedAt: "2024-01-15T00:00:00Z" });
761
+ }
762
+ );
763
+
764
+ const result = await service.listAllRuns({
765
+ limit: 0, offset: 0, search: "", status: "", sort: "activity",
766
+ });
767
+
768
+ expect(result.runs[0].runId).toBe("new");
769
+ expect(result.runs[1].runId).toBe("old");
770
+ });
771
+
772
+ it("applies pagination", async () => {
773
+ (deps.discoverAllRunDirs as ReturnType<typeof vi.fn>).mockResolvedValue([
774
+ makeDiscoveredRun("/runs/r1", "proj"),
775
+ makeDiscoveredRun("/runs/r2", "proj"),
776
+ makeDiscoveredRun("/runs/r3", "proj"),
777
+ ]);
778
+
779
+ (deps.getRunCached as ReturnType<typeof vi.fn>).mockImplementation(
780
+ async (runDir: string) => {
781
+ if (runDir === "/runs/r1") return makeRun({ runId: "r1", updatedAt: "2024-01-01T00:00:00Z" });
782
+ if (runDir === "/runs/r2") return makeRun({ runId: "r2", updatedAt: "2024-01-10T00:00:00Z" });
783
+ return makeRun({ runId: "r3", updatedAt: "2024-01-15T00:00:00Z" });
784
+ }
785
+ );
786
+
787
+ const result = await service.listAllRuns({
788
+ limit: 2, offset: 0, search: "", status: "", sort: "activity",
789
+ });
790
+
791
+ expect(result.totalCount).toBe(3);
792
+ expect(result.runs).toHaveLength(2);
793
+ expect(result.runs[0].runId).toBe("r3");
794
+ expect(result.runs[1].runId).toBe("r2");
795
+ });
796
+
797
+ it("strips events from runs in response", async () => {
798
+ (deps.discoverAllRunDirs as ReturnType<typeof vi.fn>).mockResolvedValue([
799
+ makeDiscoveredRun("/runs/r1", "proj"),
800
+ ]);
801
+
802
+ (deps.getRunCached as ReturnType<typeof vi.fn>).mockResolvedValue(
803
+ makeRun({
804
+ runId: "r1",
805
+ events: [
806
+ { seq: 1, id: "e1", ts: "2024-01-15T10:00:00Z", type: "RUN_CREATED", payload: {} },
807
+ ],
808
+ })
809
+ );
810
+
811
+ const result = await service.listAllRuns({
812
+ limit: 0, offset: 0, search: "", status: "", sort: "status",
813
+ });
814
+
815
+ expect(result.runs[0].events).toEqual([]);
816
+ expect(result.runs[0].totalEvents).toBe(1);
817
+ });
818
+ });
819
+ });