@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,1532 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import path from 'path';
3
+ import { promises as fs } from 'fs';
4
+ import {
5
+ parseJournalDir,
6
+ parseJournalDirIncremental,
7
+ parseRunDir,
8
+ parseTaskDetail,
9
+ getRunDigest,
10
+ getRunIds,
11
+ } from '../parser';
12
+ import type { JournalEvent } from '@/types';
13
+
14
+ // Use vi.spyOn to replace methods on the actual promises object
15
+ // This ensures both the test file and parser module share the same reference
16
+ const mockReadFile = vi.spyOn(fs, 'readFile');
17
+ const mockReaddir = vi.spyOn(fs, 'readdir');
18
+ const mockAccess = vi.spyOn(fs, 'access');
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Helpers for building realistic journal event files
22
+ // ---------------------------------------------------------------------------
23
+
24
+ function makeRunCreatedRaw(runId: string, processId: string, recordedAt: string) {
25
+ return {
26
+ type: 'RUN_CREATED',
27
+ recordedAt,
28
+ data: { runId, processId },
29
+ };
30
+ }
31
+
32
+ function makeEffectRequestedRaw(
33
+ effectId: string,
34
+ kind: string,
35
+ label: string,
36
+ recordedAt: string,
37
+ extras: Record<string, unknown> = {},
38
+ ) {
39
+ return {
40
+ type: 'EFFECT_REQUESTED',
41
+ recordedAt,
42
+ data: {
43
+ effectId,
44
+ kind,
45
+ label,
46
+ invocationKey: `inv-${effectId}`,
47
+ stepId: `step-${effectId}`,
48
+ taskId: `task-${effectId}`,
49
+ ...extras,
50
+ },
51
+ };
52
+ }
53
+
54
+ function makeEffectResolvedRaw(
55
+ effectId: string,
56
+ status: 'ok' | 'error',
57
+ recordedAt: string,
58
+ extras: Record<string, unknown> = {},
59
+ ) {
60
+ return {
61
+ type: 'EFFECT_RESOLVED',
62
+ recordedAt,
63
+ data: {
64
+ effectId,
65
+ status,
66
+ startedAt: '2024-01-15T10:00:01Z',
67
+ finishedAt: '2024-01-15T10:00:05Z',
68
+ ...extras,
69
+ },
70
+ };
71
+ }
72
+
73
+ function makeRunCompletedRaw(recordedAt: string) {
74
+ return {
75
+ type: 'RUN_COMPLETED',
76
+ recordedAt,
77
+ data: {},
78
+ };
79
+ }
80
+
81
+ function makeRunFailedRaw(recordedAt: string) {
82
+ return {
83
+ type: 'RUN_FAILED',
84
+ recordedAt,
85
+ data: {},
86
+ };
87
+ }
88
+
89
+ describe('parser', () => {
90
+ beforeEach(() => {
91
+ vi.resetAllMocks();
92
+ });
93
+
94
+ // -----------------------------------------------------------------------
95
+ // parseJournalDir
96
+ // -----------------------------------------------------------------------
97
+ describe('parseJournalDir', () => {
98
+ it('returns empty array when journal directory does not exist', async () => {
99
+ mockAccess.mockRejectedValue(new Error('ENOENT'));
100
+
101
+ const events = await parseJournalDir('/nonexistent/journal');
102
+
103
+ expect(events).toEqual([]);
104
+ });
105
+
106
+ it('returns empty array when journal directory has no json files', async () => {
107
+ mockAccess.mockResolvedValue(undefined);
108
+ mockReaddir.mockResolvedValue(['readme.txt', '.gitkeep'] as any);
109
+
110
+ const events = await parseJournalDir('/run/journal');
111
+
112
+ expect(events).toEqual([]);
113
+ });
114
+
115
+ it('parses journal event files sorted by sequence number', async () => {
116
+ mockAccess.mockResolvedValue(undefined);
117
+ mockReaddir.mockResolvedValue([
118
+ '000002.ULID2.json',
119
+ '000001.ULID1.json',
120
+ ] as any);
121
+
122
+ mockReadFile.mockImplementation(async (filePath: any) => {
123
+ const p = filePath.toString();
124
+ if (p.includes('000001')) {
125
+ return JSON.stringify(
126
+ makeRunCreatedRaw('run-1', 'process-1', '2024-01-15T10:00:00Z'),
127
+ );
128
+ }
129
+ if (p.includes('000002')) {
130
+ return JSON.stringify(
131
+ makeEffectRequestedRaw('eff-1', 'node', 'step-label', '2024-01-15T10:00:01Z'),
132
+ );
133
+ }
134
+ return '{}';
135
+ });
136
+
137
+ const events = await parseJournalDir('/run/journal');
138
+
139
+ expect(events).toHaveLength(2);
140
+ expect(events[0].seq).toBe(1);
141
+ expect(events[0].type).toBe('RUN_CREATED');
142
+ expect(events[0].id).toBe('ULID1');
143
+ expect(events[1].seq).toBe(2);
144
+ expect(events[1].type).toBe('EFFECT_REQUESTED');
145
+ });
146
+
147
+ it('normalizes recordedAt to ts field', async () => {
148
+ mockAccess.mockResolvedValue(undefined);
149
+ mockReaddir.mockResolvedValue(['000001.ABC.json'] as any);
150
+
151
+ mockReadFile.mockResolvedValue(
152
+ JSON.stringify({
153
+ type: 'RUN_CREATED',
154
+ recordedAt: '2024-01-15T10:00:00Z',
155
+ data: { runId: 'r1' },
156
+ }),
157
+ );
158
+
159
+ const events = await parseJournalDir('/run/journal');
160
+
161
+ expect(events[0].ts).toBe('2024-01-15T10:00:00Z');
162
+ });
163
+
164
+ it('normalizes data field to payload', async () => {
165
+ mockAccess.mockResolvedValue(undefined);
166
+ mockReaddir.mockResolvedValue(['000001.ABC.json'] as any);
167
+
168
+ mockReadFile.mockResolvedValue(
169
+ JSON.stringify({
170
+ type: 'RUN_CREATED',
171
+ recordedAt: '2024-01-15T10:00:00Z',
172
+ data: { runId: 'r1', processId: 'p1' },
173
+ }),
174
+ );
175
+
176
+ const events = await parseJournalDir('/run/journal');
177
+
178
+ expect(events[0].payload).toEqual({ runId: 'r1', processId: 'p1' });
179
+ });
180
+
181
+ it('skips entries with no type field', async () => {
182
+ mockAccess.mockResolvedValue(undefined);
183
+ mockReaddir.mockResolvedValue([
184
+ '000001.A.json',
185
+ '000002.B.json',
186
+ ] as any);
187
+
188
+ mockReadFile.mockImplementation(async (filePath: any) => {
189
+ const p = filePath.toString();
190
+ if (p.includes('000001')) {
191
+ return JSON.stringify({ noType: true });
192
+ }
193
+ return JSON.stringify({
194
+ type: 'RUN_CREATED',
195
+ recordedAt: '2024-01-15T10:00:00Z',
196
+ data: {},
197
+ });
198
+ });
199
+
200
+ const events = await parseJournalDir('/run/journal');
201
+
202
+ expect(events).toHaveLength(1);
203
+ expect(events[0].type).toBe('RUN_CREATED');
204
+ });
205
+
206
+ it('skips entries with malformed JSON', async () => {
207
+ mockAccess.mockResolvedValue(undefined);
208
+ mockReaddir.mockResolvedValue([
209
+ '000001.A.json',
210
+ '000002.B.json',
211
+ ] as any);
212
+
213
+ mockReadFile.mockImplementation(async (filePath: any) => {
214
+ const p = filePath.toString();
215
+ if (p.includes('000001')) {
216
+ return 'not valid json{{{';
217
+ }
218
+ return JSON.stringify({
219
+ type: 'RUN_CREATED',
220
+ recordedAt: '2024-01-15T10:00:00Z',
221
+ data: {},
222
+ });
223
+ });
224
+
225
+ const events = await parseJournalDir('/run/journal');
226
+
227
+ expect(events).toHaveLength(1);
228
+ });
229
+
230
+ it('handles ts field as fallback when recordedAt is missing', async () => {
231
+ mockAccess.mockResolvedValue(undefined);
232
+ mockReaddir.mockResolvedValue(['000001.A.json'] as any);
233
+
234
+ mockReadFile.mockResolvedValue(
235
+ JSON.stringify({
236
+ type: 'RUN_CREATED',
237
+ ts: '2024-06-01T12:00:00Z',
238
+ payload: { runId: 'r1' },
239
+ }),
240
+ );
241
+
242
+ const events = await parseJournalDir('/run/journal');
243
+
244
+ expect(events[0].ts).toBe('2024-06-01T12:00:00Z');
245
+ });
246
+
247
+ it('handles payload field as fallback when data is missing', async () => {
248
+ mockAccess.mockResolvedValue(undefined);
249
+ mockReaddir.mockResolvedValue(['000001.A.json'] as any);
250
+
251
+ mockReadFile.mockResolvedValue(
252
+ JSON.stringify({
253
+ type: 'RUN_CREATED',
254
+ ts: '2024-06-01T12:00:00Z',
255
+ payload: { runId: 'r1' },
256
+ }),
257
+ );
258
+
259
+ const events = await parseJournalDir('/run/journal');
260
+
261
+ expect(events[0].payload).toEqual({ runId: 'r1' });
262
+ });
263
+ });
264
+
265
+ // -----------------------------------------------------------------------
266
+ // parseRunDir
267
+ // -----------------------------------------------------------------------
268
+ describe('parseRunDir', () => {
269
+ function setupCompleteRun() {
270
+ // run.json
271
+ mockReadFile.mockImplementation(async (filePath: any) => {
272
+ const p = filePath.toString();
273
+
274
+ if (p.endsWith('run.json')) {
275
+ return JSON.stringify({ processId: 'data-pipeline' });
276
+ }
277
+
278
+ // Journal files
279
+ if (p.includes('000001')) {
280
+ return JSON.stringify(
281
+ makeRunCreatedRaw('run-123', 'data-pipeline', '2024-01-15T10:00:00Z'),
282
+ );
283
+ }
284
+ if (p.includes('000002')) {
285
+ return JSON.stringify(
286
+ makeEffectRequestedRaw('eff-1', 'node', 'fetch-data', '2024-01-15T10:00:01Z'),
287
+ );
288
+ }
289
+ if (p.includes('000003')) {
290
+ return JSON.stringify(
291
+ makeEffectResolvedRaw('eff-1', 'ok', '2024-01-15T10:00:05Z'),
292
+ );
293
+ }
294
+ if (p.includes('000004')) {
295
+ return JSON.stringify(
296
+ makeRunCompletedRaw('2024-01-15T10:00:06Z'),
297
+ );
298
+ }
299
+
300
+ // task.json for eff-1
301
+ if (p.includes(path.join('tasks', 'eff-1', 'task.json'))) {
302
+ return JSON.stringify({
303
+ title: 'Fetch Data',
304
+ kind: 'node',
305
+ });
306
+ }
307
+
308
+ throw new Error('ENOENT');
309
+ });
310
+
311
+ // Journal dir exists
312
+ mockAccess.mockImplementation(async (p: any) => {
313
+ const pathStr = p.toString();
314
+ if (pathStr.includes('journal')) return undefined;
315
+ throw new Error('ENOENT');
316
+ });
317
+
318
+ // Journal file listing
319
+ mockReaddir.mockImplementation(async (dir: any) => {
320
+ const d = typeof dir === 'string' ? dir : dir.toString();
321
+ if (d.includes('journal')) {
322
+ return [
323
+ '000001.ULID1.json',
324
+ '000002.ULID2.json',
325
+ '000003.ULID3.json',
326
+ '000004.ULID4.json',
327
+ ] as any;
328
+ }
329
+ return [];
330
+ });
331
+ }
332
+
333
+ it('parses a completed run with tasks', async () => {
334
+ setupCompleteRun();
335
+
336
+ const run = await parseRunDir('/runs/run-123');
337
+
338
+ expect(run.runId).toBe('run-123');
339
+ expect(run.processId).toBe('data-pipeline');
340
+ expect(run.status).toBe('completed');
341
+ expect(run.tasks).toHaveLength(1);
342
+ expect(run.tasks[0].effectId).toBe('eff-1');
343
+ expect(run.tasks[0].status).toBe('resolved');
344
+ expect(run.totalTasks).toBe(1);
345
+ expect(run.completedTasks).toBe(1);
346
+ expect(run.failedTasks).toBe(0);
347
+ });
348
+
349
+ it('computes run duration from task execution windows', async () => {
350
+ setupCompleteRun();
351
+
352
+ const run = await parseRunDir('/runs/run-123');
353
+
354
+ // EFFECT_RESOLVED carries startedAt 10:00:01Z and finishedAt 10:00:05Z
355
+ expect(run.duration).toBe(4000);
356
+ });
357
+
358
+ it('excludes idle gaps from run duration', async () => {
359
+ mockAccess.mockImplementation(async (p: any) => {
360
+ if (p.toString().includes('journal')) return undefined;
361
+ throw new Error('ENOENT');
362
+ });
363
+
364
+ mockReaddir.mockImplementation(async (dir: any) => {
365
+ if (dir.toString().includes('journal')) {
366
+ return [
367
+ '000001.A.json',
368
+ '000002.B.json',
369
+ '000003.C.json',
370
+ '000004.D.json',
371
+ '000005.E.json',
372
+ '000006.F.json',
373
+ ] as any;
374
+ }
375
+ return [];
376
+ });
377
+
378
+ mockReadFile.mockImplementation(async (filePath: any) => {
379
+ const p = filePath.toString();
380
+ if (p.endsWith('run.json')) return JSON.stringify({ processId: 'proc' });
381
+ if (p.includes('000001')) {
382
+ return JSON.stringify(makeRunCreatedRaw('run-gap', 'proc', '2024-01-15T10:00:00Z'));
383
+ }
384
+ if (p.includes('000002')) {
385
+ return JSON.stringify(
386
+ makeEffectRequestedRaw('eff-1', 'agent', 'phase-1', '2024-01-15T10:00:01Z'),
387
+ );
388
+ }
389
+ if (p.includes('000003')) {
390
+ return JSON.stringify(
391
+ makeEffectResolvedRaw('eff-1', 'ok', '2024-01-15T10:00:10Z', {
392
+ startedAt: '2024-01-15T10:00:02Z',
393
+ finishedAt: '2024-01-15T10:00:04Z',
394
+ }),
395
+ );
396
+ }
397
+ if (p.includes('000004')) {
398
+ return JSON.stringify(
399
+ makeEffectRequestedRaw('eff-2', 'agent', 'phase-2', '2024-01-15T10:01:00Z'),
400
+ );
401
+ }
402
+ if (p.includes('000005')) {
403
+ return JSON.stringify(
404
+ makeEffectResolvedRaw('eff-2', 'ok', '2024-01-15T10:01:20Z', {
405
+ startedAt: '2024-01-15T10:01:05Z',
406
+ finishedAt: '2024-01-15T10:01:08Z',
407
+ }),
408
+ );
409
+ }
410
+ if (p.includes('000006')) {
411
+ return JSON.stringify(makeRunCompletedRaw('2024-01-15T10:01:25Z'));
412
+ }
413
+ if (p.includes(path.join('tasks', 'eff-1', 'task.json'))) {
414
+ return JSON.stringify({ title: 'Phase 1', kind: 'agent' });
415
+ }
416
+ if (p.includes(path.join('tasks', 'eff-2', 'task.json'))) {
417
+ return JSON.stringify({ title: 'Phase 2', kind: 'agent' });
418
+ }
419
+ throw new Error('ENOENT');
420
+ });
421
+
422
+ const run = await parseRunDir('/runs/run-gap');
423
+
424
+ expect(run.duration).toBe(5000);
425
+ });
426
+
427
+ it('sets status to failed when RUN_FAILED event exists', async () => {
428
+ mockAccess.mockImplementation(async (p: any) => {
429
+ if (p.toString().includes('journal')) return undefined;
430
+ throw new Error('ENOENT');
431
+ });
432
+
433
+ mockReaddir.mockImplementation(async (dir: any) => {
434
+ if (dir.toString().includes('journal')) {
435
+ return ['000001.A.json', '000002.B.json', '000003.C.json'] as any;
436
+ }
437
+ return [];
438
+ });
439
+
440
+ mockReadFile.mockImplementation(async (filePath: any) => {
441
+ const p = filePath.toString();
442
+ if (p.endsWith('run.json')) return JSON.stringify({});
443
+ if (p.includes('000001')) {
444
+ return JSON.stringify(makeRunCreatedRaw('run-f', 'proc', '2024-01-15T10:00:00Z'));
445
+ }
446
+ if (p.includes('000002')) {
447
+ return JSON.stringify(
448
+ makeEffectRequestedRaw('eff-f', 'node', 'fail-step', '2024-01-15T10:00:01Z'),
449
+ );
450
+ }
451
+ if (p.includes('000003')) {
452
+ return JSON.stringify(makeRunFailedRaw('2024-01-15T10:00:05Z'));
453
+ }
454
+ throw new Error('ENOENT');
455
+ });
456
+
457
+ const run = await parseRunDir('/runs/run-fail');
458
+
459
+ expect(run.status).toBe('failed');
460
+ });
461
+
462
+ it('sets status to waiting when there are requested tasks and no completion event', async () => {
463
+ mockAccess.mockImplementation(async (p: any) => {
464
+ if (p.toString().includes('journal')) return undefined;
465
+ throw new Error('ENOENT');
466
+ });
467
+
468
+ mockReaddir.mockImplementation(async (dir: any) => {
469
+ if (dir.toString().includes('journal')) {
470
+ return ['000001.A.json', '000002.B.json'] as any;
471
+ }
472
+ return [];
473
+ });
474
+
475
+ mockReadFile.mockImplementation(async (filePath: any) => {
476
+ const p = filePath.toString();
477
+ if (p.endsWith('run.json')) return JSON.stringify({});
478
+ if (p.includes('000001')) {
479
+ return JSON.stringify(makeRunCreatedRaw('run-w', 'proc', '2024-01-15T10:00:00Z'));
480
+ }
481
+ if (p.includes('000002')) {
482
+ return JSON.stringify(
483
+ makeEffectRequestedRaw('eff-w', 'agent', 'waiting-step', '2024-01-15T10:00:01Z'),
484
+ );
485
+ }
486
+ throw new Error('ENOENT');
487
+ });
488
+
489
+ const run = await parseRunDir('/runs/run-waiting');
490
+
491
+ expect(run.status).toBe('waiting');
492
+ });
493
+
494
+ it('sets status to pending when no events exist', async () => {
495
+ // No journal access
496
+ mockAccess.mockRejectedValue(new Error('ENOENT'));
497
+ mockReadFile.mockImplementation(async (filePath: any) => {
498
+ if (filePath.toString().endsWith('run.json')) return JSON.stringify({});
499
+ throw new Error('ENOENT');
500
+ });
501
+ mockReaddir.mockResolvedValue([] as any);
502
+
503
+ const run = await parseRunDir('/runs/run-empty');
504
+
505
+ expect(run.status).toBe('pending');
506
+ expect(run.tasks).toHaveLength(0);
507
+ });
508
+
509
+ it('extracts failedStep from first error task', async () => {
510
+ mockAccess.mockImplementation(async (p: any) => {
511
+ if (p.toString().includes('journal')) return undefined;
512
+ throw new Error('ENOENT');
513
+ });
514
+
515
+ mockReaddir.mockImplementation(async (dir: any) => {
516
+ if (dir.toString().includes('journal')) {
517
+ return [
518
+ '000001.A.json',
519
+ '000002.B.json',
520
+ '000003.C.json',
521
+ '000004.D.json',
522
+ ] as any;
523
+ }
524
+ return [];
525
+ });
526
+
527
+ mockReadFile.mockImplementation(async (filePath: any) => {
528
+ const p = filePath.toString();
529
+ if (p.endsWith('run.json')) return JSON.stringify({});
530
+ if (p.includes('000001')) {
531
+ return JSON.stringify(makeRunCreatedRaw('run-err', 'proc', '2024-01-15T10:00:00Z'));
532
+ }
533
+ if (p.includes('000002')) {
534
+ return JSON.stringify(
535
+ makeEffectRequestedRaw('eff-err', 'shell', 'deploy-step', '2024-01-15T10:00:01Z'),
536
+ );
537
+ }
538
+ if (p.includes('000003')) {
539
+ return JSON.stringify(
540
+ makeEffectResolvedRaw('eff-err', 'error', '2024-01-15T10:00:03Z', {
541
+ error: { name: 'Error', message: 'deploy failed', stack: '' },
542
+ }),
543
+ );
544
+ }
545
+ if (p.includes('000004')) {
546
+ return JSON.stringify(makeRunFailedRaw('2024-01-15T10:00:04Z'));
547
+ }
548
+ throw new Error('ENOENT');
549
+ });
550
+
551
+ const run = await parseRunDir('/runs/run-err');
552
+
553
+ expect(run.failedStep).toBeDefined();
554
+ expect(run.failedTasks).toBe(1);
555
+ });
556
+
557
+ it('extracts breakpointQuestion from pending breakpoint task', async () => {
558
+ mockAccess.mockImplementation(async (p: any) => {
559
+ if (p.toString().includes('journal')) return undefined;
560
+ throw new Error('ENOENT');
561
+ });
562
+
563
+ mockReaddir.mockImplementation(async (dir: any) => {
564
+ if (dir.toString().includes('journal')) {
565
+ return ['000001.A.json', '000002.B.json'] as any;
566
+ }
567
+ return [];
568
+ });
569
+
570
+ mockReadFile.mockImplementation(async (filePath: any) => {
571
+ const p = filePath.toString();
572
+ if (p.endsWith('run.json')) return JSON.stringify({});
573
+ if (p.includes('000001')) {
574
+ return JSON.stringify(makeRunCreatedRaw('run-bp', 'proc', '2024-01-15T10:00:00Z'));
575
+ }
576
+ if (p.includes('000002')) {
577
+ return JSON.stringify(
578
+ makeEffectRequestedRaw('eff-bp', 'breakpoint', 'approval', '2024-01-15T10:00:01Z'),
579
+ );
580
+ }
581
+ if (p.includes(path.join('tasks', 'eff-bp', 'task.json'))) {
582
+ return JSON.stringify({
583
+ kind: 'breakpoint',
584
+ inputs: { question: 'Proceed with deployment?' },
585
+ });
586
+ }
587
+ throw new Error('ENOENT');
588
+ });
589
+
590
+ const run = await parseRunDir('/runs/run-bp');
591
+
592
+ expect(run.status).toBe('waiting');
593
+ expect(run.breakpointQuestion).toBe('Proceed with deployment?');
594
+ });
595
+
596
+ it('falls back to path.basename for runId when no RUN_CREATED event', async () => {
597
+ mockAccess.mockRejectedValue(new Error('ENOENT'));
598
+ mockReadFile.mockImplementation(async (filePath: any) => {
599
+ if (filePath.toString().endsWith('run.json')) return JSON.stringify({});
600
+ throw new Error('ENOENT');
601
+ });
602
+ mockReaddir.mockResolvedValue([] as any);
603
+
604
+ const run = await parseRunDir('/runs/my-run-id');
605
+
606
+ expect(run.runId).toBe('my-run-id');
607
+ });
608
+
609
+ it('computes task duration from startedAt and finishedAt', async () => {
610
+ mockAccess.mockImplementation(async (p: any) => {
611
+ if (p.toString().includes('journal')) return undefined;
612
+ throw new Error('ENOENT');
613
+ });
614
+
615
+ mockReaddir.mockImplementation(async (dir: any) => {
616
+ if (dir.toString().includes('journal')) {
617
+ return ['000001.A.json', '000002.B.json', '000003.C.json'] as any;
618
+ }
619
+ return [];
620
+ });
621
+
622
+ mockReadFile.mockImplementation(async (filePath: any) => {
623
+ const p = filePath.toString();
624
+ if (p.endsWith('run.json')) return JSON.stringify({});
625
+ if (p.includes('000001')) {
626
+ return JSON.stringify(makeRunCreatedRaw('r', 'p', '2024-01-15T10:00:00Z'));
627
+ }
628
+ if (p.includes('000002')) {
629
+ return JSON.stringify(
630
+ makeEffectRequestedRaw('eff-t', 'node', 'step', '2024-01-15T10:00:01Z'),
631
+ );
632
+ }
633
+ if (p.includes('000003')) {
634
+ return JSON.stringify(
635
+ makeEffectResolvedRaw('eff-t', 'ok', '2024-01-15T10:00:05Z', {
636
+ startedAt: '2024-01-15T10:00:01Z',
637
+ finishedAt: '2024-01-15T10:00:05Z',
638
+ }),
639
+ );
640
+ }
641
+ throw new Error('ENOENT');
642
+ });
643
+
644
+ const run = await parseRunDir('/runs/r');
645
+
646
+ expect(run.tasks[0].duration).toBe(4000);
647
+ });
648
+
649
+ it('stores error details on failed tasks', async () => {
650
+ mockAccess.mockImplementation(async (p: any) => {
651
+ if (p.toString().includes('journal')) return undefined;
652
+ throw new Error('ENOENT');
653
+ });
654
+
655
+ mockReaddir.mockImplementation(async (dir: any) => {
656
+ if (dir.toString().includes('journal')) {
657
+ return ['000001.A.json', '000002.B.json', '000003.C.json'] as any;
658
+ }
659
+ return [];
660
+ });
661
+
662
+ mockReadFile.mockImplementation(async (filePath: any) => {
663
+ const p = filePath.toString();
664
+ if (p.endsWith('run.json')) return JSON.stringify({});
665
+ if (p.includes('000001')) {
666
+ return JSON.stringify(makeRunCreatedRaw('r', 'p', '2024-01-15T10:00:00Z'));
667
+ }
668
+ if (p.includes('000002')) {
669
+ return JSON.stringify(
670
+ makeEffectRequestedRaw('eff-e', 'shell', 'cmd', '2024-01-15T10:00:01Z'),
671
+ );
672
+ }
673
+ if (p.includes('000003')) {
674
+ return JSON.stringify(
675
+ makeEffectResolvedRaw('eff-e', 'error', '2024-01-15T10:00:03Z', {
676
+ error: {
677
+ name: 'ExecError',
678
+ message: 'command not found',
679
+ stack: 'at line 1',
680
+ },
681
+ }),
682
+ );
683
+ }
684
+ throw new Error('ENOENT');
685
+ });
686
+
687
+ const run = await parseRunDir('/runs/r');
688
+
689
+ expect(run.tasks[0].status).toBe('error');
690
+ expect(run.tasks[0].error).toEqual({
691
+ name: 'ExecError',
692
+ message: 'command not found',
693
+ stack: 'at line 1',
694
+ });
695
+ });
696
+
697
+ it('extracts agent info from task.json', async () => {
698
+ mockAccess.mockImplementation(async (p: any) => {
699
+ if (p.toString().includes('journal')) return undefined;
700
+ throw new Error('ENOENT');
701
+ });
702
+
703
+ mockReaddir.mockImplementation(async (dir: any) => {
704
+ if (dir.toString().includes('journal')) {
705
+ return ['000001.A.json', '000002.B.json'] as any;
706
+ }
707
+ return [];
708
+ });
709
+
710
+ mockReadFile.mockImplementation(async (filePath: any) => {
711
+ const p = filePath.toString();
712
+ if (p.endsWith('run.json')) return JSON.stringify({});
713
+ if (p.includes('000001')) {
714
+ return JSON.stringify(makeRunCreatedRaw('r', 'p', '2024-01-15T10:00:00Z'));
715
+ }
716
+ if (p.includes('000002')) {
717
+ return JSON.stringify(
718
+ makeEffectRequestedRaw('eff-agent', 'agent', 'ai-step', '2024-01-15T10:00:01Z'),
719
+ );
720
+ }
721
+ if (p.includes(path.join('tasks', 'eff-agent', 'task.json'))) {
722
+ return JSON.stringify({
723
+ title: 'AI Analysis',
724
+ kind: 'agent',
725
+ agent: {
726
+ name: 'analyst',
727
+ prompt: { role: 'analyzer', task: 'analyze data', instructions: ['be thorough'] },
728
+ },
729
+ });
730
+ }
731
+ throw new Error('ENOENT');
732
+ });
733
+
734
+ const run = await parseRunDir('/runs/r');
735
+
736
+ expect(run.tasks[0].title).toBe('AI Analysis');
737
+ expect(run.tasks[0].agent).toEqual({
738
+ name: 'analyst',
739
+ prompt: { role: 'analyzer', task: 'analyze data', instructions: ['be thorough'] },
740
+ });
741
+ });
742
+ });
743
+
744
+ // -----------------------------------------------------------------------
745
+ // parseTaskDetail
746
+ // -----------------------------------------------------------------------
747
+ describe('parseTaskDetail', () => {
748
+ it('returns null when task directory does not exist', async () => {
749
+ mockAccess.mockRejectedValue(new Error('ENOENT'));
750
+
751
+ const detail = await parseTaskDetail('/run', 'nonexistent-effect');
752
+
753
+ expect(detail).toBeNull();
754
+ });
755
+
756
+ it('parses a complete task detail with all fields', async () => {
757
+ mockAccess.mockImplementation(async (_p: any) => {
758
+ // task dir exists, journal dir exists
759
+ return undefined;
760
+ });
761
+
762
+ mockReaddir.mockImplementation(async (dir: any) => {
763
+ if (dir.toString().includes('journal')) {
764
+ return ['000001.A.json', '000002.B.json', '000003.C.json'] as any;
765
+ }
766
+ return [];
767
+ });
768
+
769
+ mockReadFile.mockImplementation(async (filePath: any) => {
770
+ const p = filePath.toString();
771
+ if (p.includes(path.join('tasks', 'eff-1', 'task.json'))) {
772
+ return JSON.stringify({
773
+ title: 'Fetch Data',
774
+ kind: 'node',
775
+ invocationKey: 'inv-1',
776
+ stepId: 'step-1',
777
+ taskId: 'task-1',
778
+ });
779
+ }
780
+ if (p.includes(path.join('tasks', 'eff-1', 'input.json'))) {
781
+ return JSON.stringify({ url: 'https://api.example.com' });
782
+ }
783
+ if (p.includes(path.join('tasks', 'eff-1', 'result.json'))) {
784
+ return JSON.stringify({
785
+ output: { data: [1, 2, 3] },
786
+ status: 'ok',
787
+ startedAt: '2024-01-15T10:00:01Z',
788
+ finishedAt: '2024-01-15T10:00:04Z',
789
+ });
790
+ }
791
+ if (p.includes(path.join('tasks', 'eff-1', 'stdout.log'))) {
792
+ return 'Fetching data...\nDone.';
793
+ }
794
+ if (p.includes(path.join('tasks', 'eff-1', 'stderr.log'))) {
795
+ return '';
796
+ }
797
+ // Journal files
798
+ if (p.includes('000001')) {
799
+ return JSON.stringify(makeRunCreatedRaw('run-1', 'proc', '2024-01-15T10:00:00Z'));
800
+ }
801
+ if (p.includes('000002')) {
802
+ return JSON.stringify(
803
+ makeEffectRequestedRaw('eff-1', 'node', 'Fetch Data', '2024-01-15T10:00:01Z'),
804
+ );
805
+ }
806
+ if (p.includes('000003')) {
807
+ return JSON.stringify(
808
+ makeEffectResolvedRaw('eff-1', 'ok', '2024-01-15T10:00:05Z'),
809
+ );
810
+ }
811
+ throw new Error('ENOENT');
812
+ });
813
+
814
+ const detail = await parseTaskDetail('/run', 'eff-1');
815
+
816
+ expect(detail).not.toBeNull();
817
+ expect(detail!.effectId).toBe('eff-1');
818
+ expect(detail!.kind).toBe('node');
819
+ expect(detail!.title).toBe('Fetch Data');
820
+ expect(detail!.status).toBe('resolved');
821
+ expect(detail!.input).toEqual({ url: 'https://api.example.com' });
822
+ expect(detail!.result).toBeDefined();
823
+ expect(detail!.stdout).toBe('Fetching data...\nDone.');
824
+ expect(detail!.stderr).toBe('');
825
+ // duration from result startedAt/finishedAt: 3000ms
826
+ expect(detail!.duration).toBe(3000);
827
+ });
828
+
829
+ it('sets status to error when result has error status', async () => {
830
+ mockAccess.mockResolvedValue(undefined);
831
+
832
+ mockReaddir.mockImplementation(async (dir: any) => {
833
+ if (dir.toString().includes('journal')) {
834
+ return ['000001.A.json', '000002.B.json', '000003.C.json'] as any;
835
+ }
836
+ return [];
837
+ });
838
+
839
+ mockReadFile.mockImplementation(async (filePath: any) => {
840
+ const p = filePath.toString();
841
+ if (p.includes('task.json')) {
842
+ return JSON.stringify({ title: 'Fail Task', kind: 'shell' });
843
+ }
844
+ if (p.includes('input.json')) throw new Error('ENOENT');
845
+ if (p.includes('result.json')) {
846
+ return JSON.stringify({ status: 'error', error: 'timeout' });
847
+ }
848
+ if (p.includes('stdout.log')) throw new Error('ENOENT');
849
+ if (p.includes('stderr.log')) throw new Error('ENOENT');
850
+ if (p.includes('000001')) {
851
+ return JSON.stringify(makeRunCreatedRaw('r', 'p', '2024-01-15T10:00:00Z'));
852
+ }
853
+ if (p.includes('000002')) {
854
+ return JSON.stringify(
855
+ makeEffectRequestedRaw('eff-err', 'shell', 'cmd', '2024-01-15T10:00:01Z'),
856
+ );
857
+ }
858
+ if (p.includes('000003')) {
859
+ return JSON.stringify(
860
+ makeEffectResolvedRaw('eff-err', 'error', '2024-01-15T10:00:03Z'),
861
+ );
862
+ }
863
+ throw new Error('ENOENT');
864
+ });
865
+
866
+ const detail = await parseTaskDetail('/run', 'eff-err');
867
+
868
+ expect(detail).not.toBeNull();
869
+ expect(detail!.status).toBe('error');
870
+ });
871
+
872
+ it('sets status to requested when no resolved event exists', async () => {
873
+ mockAccess.mockResolvedValue(undefined);
874
+
875
+ mockReaddir.mockImplementation(async (dir: any) => {
876
+ if (dir.toString().includes('journal')) {
877
+ return ['000001.A.json', '000002.B.json'] as any;
878
+ }
879
+ return [];
880
+ });
881
+
882
+ mockReadFile.mockImplementation(async (filePath: any) => {
883
+ const p = filePath.toString();
884
+ if (p.includes('task.json')) {
885
+ return JSON.stringify({ title: 'Pending Task', kind: 'agent' });
886
+ }
887
+ if (p.includes('input.json')) throw new Error('ENOENT');
888
+ if (p.includes('result.json')) throw new Error('ENOENT');
889
+ if (p.includes('stdout.log')) throw new Error('ENOENT');
890
+ if (p.includes('stderr.log')) throw new Error('ENOENT');
891
+ if (p.includes('000001')) {
892
+ return JSON.stringify(makeRunCreatedRaw('r', 'p', '2024-01-15T10:00:00Z'));
893
+ }
894
+ if (p.includes('000002')) {
895
+ return JSON.stringify(
896
+ makeEffectRequestedRaw('eff-pending', 'agent', 'analysis', '2024-01-15T10:00:01Z'),
897
+ );
898
+ }
899
+ throw new Error('ENOENT');
900
+ });
901
+
902
+ const detail = await parseTaskDetail('/run', 'eff-pending');
903
+
904
+ expect(detail).not.toBeNull();
905
+ expect(detail!.status).toBe('requested');
906
+ });
907
+
908
+ it('extracts breakpoint payload for breakpoint tasks', async () => {
909
+ mockAccess.mockResolvedValue(undefined);
910
+
911
+ mockReaddir.mockImplementation(async (dir: any) => {
912
+ if (dir.toString().includes('journal')) {
913
+ return ['000001.A.json', '000002.B.json'] as any;
914
+ }
915
+ return [];
916
+ });
917
+
918
+ mockReadFile.mockImplementation(async (filePath: any) => {
919
+ const p = filePath.toString();
920
+ if (p.includes('task.json')) {
921
+ return JSON.stringify({
922
+ title: 'Approval Gate',
923
+ kind: 'breakpoint',
924
+ inputs: {
925
+ question: 'Deploy to production?',
926
+ title: 'Deploy Approval',
927
+ context: { files: [{ path: 'deploy.yaml', format: 'yaml' }] },
928
+ },
929
+ });
930
+ }
931
+ if (p.includes('input.json')) throw new Error('ENOENT');
932
+ if (p.includes('result.json')) throw new Error('ENOENT');
933
+ if (p.includes('stdout.log')) throw new Error('ENOENT');
934
+ if (p.includes('stderr.log')) throw new Error('ENOENT');
935
+ if (p.includes('000001')) {
936
+ return JSON.stringify(makeRunCreatedRaw('r', 'p', '2024-01-15T10:00:00Z'));
937
+ }
938
+ if (p.includes('000002')) {
939
+ return JSON.stringify(
940
+ makeEffectRequestedRaw('eff-bp', 'breakpoint', 'approval', '2024-01-15T10:00:01Z'),
941
+ );
942
+ }
943
+ throw new Error('ENOENT');
944
+ });
945
+
946
+ const detail = await parseTaskDetail('/run', 'eff-bp');
947
+
948
+ expect(detail).not.toBeNull();
949
+ expect(detail!.kind).toBe('breakpoint');
950
+ expect(detail!.breakpoint).toBeDefined();
951
+ expect(detail!.breakpoint!.question).toBe('Deploy to production?');
952
+ expect(detail!.breakpoint!.title).toBe('Deploy Approval');
953
+ expect(detail!.breakpointQuestion).toBe('Deploy to production?');
954
+ });
955
+
956
+ it('uses inputs from task.json when input.json does not exist', async () => {
957
+ mockAccess.mockResolvedValue(undefined);
958
+
959
+ mockReaddir.mockImplementation(async (dir: any) => {
960
+ if (dir.toString().includes('journal')) {
961
+ return ['000001.A.json'] as any;
962
+ }
963
+ return [];
964
+ });
965
+
966
+ mockReadFile.mockImplementation(async (filePath: any) => {
967
+ const p = filePath.toString();
968
+ if (p.includes('task.json')) {
969
+ return JSON.stringify({
970
+ title: 'Task',
971
+ kind: 'node',
972
+ inputs: { key: 'value' },
973
+ });
974
+ }
975
+ if (p.includes('input.json')) throw new Error('ENOENT');
976
+ if (p.includes('result.json')) throw new Error('ENOENT');
977
+ if (p.includes('stdout.log')) throw new Error('ENOENT');
978
+ if (p.includes('stderr.log')) throw new Error('ENOENT');
979
+ if (p.includes('000001')) {
980
+ return JSON.stringify(makeRunCreatedRaw('r', 'p', '2024-01-15T10:00:00Z'));
981
+ }
982
+ throw new Error('ENOENT');
983
+ });
984
+
985
+ const detail = await parseTaskDetail('/run', 'eff-inp');
986
+
987
+ expect(detail).not.toBeNull();
988
+ expect(detail!.input).toEqual({ key: 'value' });
989
+ });
990
+
991
+ it('keeps zero execution duration when result timestamps are equal', async () => {
992
+ mockAccess.mockResolvedValue(undefined);
993
+
994
+ mockReaddir.mockImplementation(async (dir: any) => {
995
+ if (dir.toString().includes('journal')) {
996
+ return ['000001.A.json', '000002.B.json', '000003.C.json'] as any;
997
+ }
998
+ return [];
999
+ });
1000
+
1001
+ mockReadFile.mockImplementation(async (filePath: any) => {
1002
+ const p = filePath.toString();
1003
+ if (p.includes('task.json')) {
1004
+ return JSON.stringify({ title: 'Task', kind: 'node' });
1005
+ }
1006
+ if (p.includes('input.json')) throw new Error('ENOENT');
1007
+ if (p.includes('result.json')) {
1008
+ return JSON.stringify({
1009
+ status: 'ok',
1010
+ startedAt: '2024-01-15T10:00:02Z',
1011
+ finishedAt: '2024-01-15T10:00:02Z', // same time
1012
+ });
1013
+ }
1014
+ if (p.includes('stdout.log')) throw new Error('ENOENT');
1015
+ if (p.includes('stderr.log')) throw new Error('ENOENT');
1016
+ if (p.includes('000001')) {
1017
+ return JSON.stringify(makeRunCreatedRaw('r', 'p', '2024-01-15T10:00:00Z'));
1018
+ }
1019
+ if (p.includes('000002')) {
1020
+ return JSON.stringify(
1021
+ makeEffectRequestedRaw('eff-dur', 'node', 'step', '2024-01-15T10:00:01Z'),
1022
+ );
1023
+ }
1024
+ if (p.includes('000003')) {
1025
+ return JSON.stringify(
1026
+ makeEffectResolvedRaw('eff-dur', 'ok', '2024-01-15T10:00:05Z'),
1027
+ );
1028
+ }
1029
+ throw new Error('ENOENT');
1030
+ });
1031
+
1032
+ const detail = await parseTaskDetail('/run', 'eff-dur');
1033
+
1034
+ expect(detail).not.toBeNull();
1035
+ expect(detail!.duration).toBe(0);
1036
+ });
1037
+
1038
+ it('falls back to request/resolve timing when execution timestamps are absent', async () => {
1039
+ mockAccess.mockResolvedValue(undefined);
1040
+
1041
+ mockReaddir.mockImplementation(async (dir: any) => {
1042
+ if (dir.toString().includes('journal')) {
1043
+ return ['000001.A.json', '000002.B.json', '000003.C.json'] as any;
1044
+ }
1045
+ return [];
1046
+ });
1047
+
1048
+ mockReadFile.mockImplementation(async (filePath: any) => {
1049
+ const p = filePath.toString();
1050
+ if (p.includes('task.json')) {
1051
+ return JSON.stringify({ title: 'Task', kind: 'node' });
1052
+ }
1053
+ if (p.includes('input.json')) throw new Error('ENOENT');
1054
+ if (p.includes('result.json')) {
1055
+ return JSON.stringify({
1056
+ status: 'ok',
1057
+ });
1058
+ }
1059
+ if (p.includes('stdout.log')) throw new Error('ENOENT');
1060
+ if (p.includes('stderr.log')) throw new Error('ENOENT');
1061
+ if (p.includes('000001')) {
1062
+ return JSON.stringify(makeRunCreatedRaw('r', 'p', '2024-01-15T10:00:00Z'));
1063
+ }
1064
+ if (p.includes('000002')) {
1065
+ return JSON.stringify(
1066
+ makeEffectRequestedRaw('eff-wall', 'node', 'step', '2024-01-15T10:00:01Z'),
1067
+ );
1068
+ }
1069
+ if (p.includes('000003')) {
1070
+ return JSON.stringify(
1071
+ makeEffectResolvedRaw('eff-wall', 'ok', '2024-01-15T10:00:05Z', {
1072
+ startedAt: undefined,
1073
+ finishedAt: undefined,
1074
+ }),
1075
+ );
1076
+ }
1077
+ throw new Error('ENOENT');
1078
+ });
1079
+
1080
+ const detail = await parseTaskDetail('/run', 'eff-wall');
1081
+
1082
+ expect(detail).not.toBeNull();
1083
+ expect(detail!.duration).toBe(4000);
1084
+ });
1085
+ });
1086
+
1087
+ // -----------------------------------------------------------------------
1088
+ // getRunDigest
1089
+ // -----------------------------------------------------------------------
1090
+ describe('getRunDigest', () => {
1091
+ it('returns default digest when journal does not exist', async () => {
1092
+ mockAccess.mockRejectedValue(new Error('ENOENT'));
1093
+
1094
+ const digest = await getRunDigest('/runs/empty-run');
1095
+
1096
+ expect(digest.runId).toBe('empty-run');
1097
+ expect(digest.latestSeq).toBe(0);
1098
+ expect(digest.status).toBe('pending');
1099
+ expect(digest.taskCount).toBe(0);
1100
+ expect(digest.completedTasks).toBe(0);
1101
+ });
1102
+
1103
+ it('returns accurate counts for a completed run', async () => {
1104
+ mockAccess.mockResolvedValue(undefined);
1105
+
1106
+ mockReaddir.mockResolvedValue([
1107
+ '000001.A.json',
1108
+ '000002.B.json',
1109
+ '000003.C.json',
1110
+ '000004.D.json',
1111
+ ] as any);
1112
+
1113
+ mockReadFile.mockImplementation(async (filePath: any) => {
1114
+ const p = filePath.toString();
1115
+ if (p.includes('000001')) {
1116
+ return JSON.stringify(makeRunCreatedRaw('r', 'p', '2024-01-15T10:00:00Z'));
1117
+ }
1118
+ if (p.includes('000002')) {
1119
+ return JSON.stringify(
1120
+ makeEffectRequestedRaw('eff-1', 'node', 'step1', '2024-01-15T10:00:01Z'),
1121
+ );
1122
+ }
1123
+ if (p.includes('000003')) {
1124
+ return JSON.stringify(
1125
+ makeEffectResolvedRaw('eff-1', 'ok', '2024-01-15T10:00:03Z'),
1126
+ );
1127
+ }
1128
+ if (p.includes('000004')) {
1129
+ return JSON.stringify(makeRunCompletedRaw('2024-01-15T10:00:05Z'));
1130
+ }
1131
+ throw new Error('ENOENT');
1132
+ });
1133
+
1134
+ const digest = await getRunDigest('/runs/run-complete');
1135
+
1136
+ expect(digest.runId).toBe('run-complete');
1137
+ expect(digest.latestSeq).toBe(4);
1138
+ expect(digest.status).toBe('completed');
1139
+ expect(digest.taskCount).toBe(1);
1140
+ expect(digest.completedTasks).toBe(1);
1141
+ expect(digest.updatedAt).toBe('2024-01-15T10:00:05Z');
1142
+ });
1143
+
1144
+ it('sets status to waiting when tasks exist but run is not completed', async () => {
1145
+ mockAccess.mockResolvedValue(undefined);
1146
+
1147
+ mockReaddir.mockResolvedValue([
1148
+ '000001.A.json',
1149
+ '000002.B.json',
1150
+ ] as any);
1151
+
1152
+ mockReadFile.mockImplementation(async (filePath: any) => {
1153
+ const p = filePath.toString();
1154
+ if (p.includes('000001')) {
1155
+ return JSON.stringify(makeRunCreatedRaw('r', 'p', '2024-01-15T10:00:00Z'));
1156
+ }
1157
+ if (p.includes('000002')) {
1158
+ return JSON.stringify(
1159
+ makeEffectRequestedRaw('eff-1', 'agent', 'step1', '2024-01-15T10:00:01Z'),
1160
+ );
1161
+ }
1162
+ throw new Error('ENOENT');
1163
+ });
1164
+
1165
+ const digest = await getRunDigest('/runs/run-wait');
1166
+
1167
+ expect(digest.status).toBe('waiting');
1168
+ expect(digest.taskCount).toBe(1);
1169
+ expect(digest.completedTasks).toBe(0);
1170
+ });
1171
+
1172
+ it('counts pending breakpoints correctly', async () => {
1173
+ mockAccess.mockResolvedValue(undefined);
1174
+
1175
+ mockReaddir.mockImplementation(async (dir: any) => {
1176
+ const d = dir.toString();
1177
+ if (d.includes('journal')) {
1178
+ return [
1179
+ '000001.A.json',
1180
+ '000002.B.json',
1181
+ '000003.C.json',
1182
+ ] as any;
1183
+ }
1184
+ return [];
1185
+ });
1186
+
1187
+ mockReadFile.mockImplementation(async (filePath: any) => {
1188
+ const p = filePath.toString();
1189
+ if (p.includes('000001')) {
1190
+ return JSON.stringify(makeRunCreatedRaw('r', 'p', '2024-01-15T10:00:00Z'));
1191
+ }
1192
+ if (p.includes('000002')) {
1193
+ return JSON.stringify(
1194
+ makeEffectRequestedRaw('eff-bp1', 'breakpoint', 'approval', '2024-01-15T10:00:01Z'),
1195
+ );
1196
+ }
1197
+ if (p.includes('000003')) {
1198
+ return JSON.stringify(
1199
+ makeEffectRequestedRaw('eff-bp2', 'breakpoint', 'review', '2024-01-15T10:00:02Z'),
1200
+ );
1201
+ }
1202
+ // task.json for breakpoint question
1203
+ if (p.includes(path.join('tasks', 'eff-bp1', 'task.json'))) {
1204
+ return JSON.stringify({
1205
+ kind: 'breakpoint',
1206
+ inputs: { question: 'Approve deployment?' },
1207
+ });
1208
+ }
1209
+ if (p.includes(path.join('tasks', 'eff-bp2', 'task.json'))) {
1210
+ return JSON.stringify({
1211
+ kind: 'breakpoint',
1212
+ inputs: { question: 'Review changes?' },
1213
+ });
1214
+ }
1215
+ throw new Error('ENOENT');
1216
+ });
1217
+
1218
+ const digest = await getRunDigest('/runs/run-bp');
1219
+
1220
+ expect(digest.pendingBreakpoints).toBe(2);
1221
+ expect(digest.breakpointQuestion).toBeDefined();
1222
+ });
1223
+
1224
+ it('does not count resolved breakpoints as pending', async () => {
1225
+ mockAccess.mockResolvedValue(undefined);
1226
+
1227
+ mockReaddir.mockResolvedValue([
1228
+ '000001.A.json',
1229
+ '000002.B.json',
1230
+ '000003.C.json',
1231
+ ] as any);
1232
+
1233
+ mockReadFile.mockImplementation(async (filePath: any) => {
1234
+ const p = filePath.toString();
1235
+ if (p.includes('000001')) {
1236
+ return JSON.stringify(makeRunCreatedRaw('r', 'p', '2024-01-15T10:00:00Z'));
1237
+ }
1238
+ if (p.includes('000002')) {
1239
+ return JSON.stringify(
1240
+ makeEffectRequestedRaw('eff-bp', 'breakpoint', 'approval', '2024-01-15T10:00:01Z'),
1241
+ );
1242
+ }
1243
+ if (p.includes('000003')) {
1244
+ return JSON.stringify(
1245
+ makeEffectResolvedRaw('eff-bp', 'ok', '2024-01-15T10:00:03Z'),
1246
+ );
1247
+ }
1248
+ throw new Error('ENOENT');
1249
+ });
1250
+
1251
+ const digest = await getRunDigest('/runs/run-bp-resolved');
1252
+
1253
+ expect(digest.pendingBreakpoints).toBe(0);
1254
+ });
1255
+
1256
+ it('sets status to failed when RUN_FAILED event exists', async () => {
1257
+ mockAccess.mockResolvedValue(undefined);
1258
+
1259
+ mockReaddir.mockResolvedValue([
1260
+ '000001.A.json',
1261
+ '000002.B.json',
1262
+ ] as any);
1263
+
1264
+ mockReadFile.mockImplementation(async (filePath: any) => {
1265
+ const p = filePath.toString();
1266
+ if (p.includes('000001')) {
1267
+ return JSON.stringify(makeRunCreatedRaw('r', 'p', '2024-01-15T10:00:00Z'));
1268
+ }
1269
+ if (p.includes('000002')) {
1270
+ return JSON.stringify(makeRunFailedRaw('2024-01-15T10:00:05Z'));
1271
+ }
1272
+ throw new Error('ENOENT');
1273
+ });
1274
+
1275
+ const digest = await getRunDigest('/runs/run-failed');
1276
+
1277
+ expect(digest.status).toBe('failed');
1278
+ });
1279
+ });
1280
+
1281
+ // -----------------------------------------------------------------------
1282
+ // getRunIds
1283
+ // -----------------------------------------------------------------------
1284
+ describe('getRunIds', () => {
1285
+ it('returns empty array when directory does not exist', async () => {
1286
+ mockAccess.mockRejectedValue(new Error('ENOENT'));
1287
+
1288
+ const ids = await getRunIds('/nonexistent');
1289
+
1290
+ expect(ids).toEqual([]);
1291
+ });
1292
+
1293
+ it('returns directory names sorted in reverse order', async () => {
1294
+ mockAccess.mockResolvedValue(undefined);
1295
+ mockReaddir.mockResolvedValue([
1296
+ { name: 'run-001', isDirectory: () => true },
1297
+ { name: 'run-003', isDirectory: () => true },
1298
+ { name: 'run-002', isDirectory: () => true },
1299
+ { name: 'status.json', isDirectory: () => false },
1300
+ ] as any);
1301
+
1302
+ const ids = await getRunIds('/runs');
1303
+
1304
+ expect(ids).toEqual(['run-003', 'run-002', 'run-001']);
1305
+ });
1306
+
1307
+ it('filters out non-directory entries', async () => {
1308
+ mockAccess.mockResolvedValue(undefined);
1309
+ mockReaddir.mockResolvedValue([
1310
+ { name: 'run-001', isDirectory: () => true },
1311
+ { name: 'readme.md', isDirectory: () => false },
1312
+ { name: '.gitkeep', isDirectory: () => false },
1313
+ ] as any);
1314
+
1315
+ const ids = await getRunIds('/runs');
1316
+
1317
+ expect(ids).toEqual(['run-001']);
1318
+ });
1319
+ });
1320
+
1321
+ // -----------------------------------------------------------------------
1322
+ // parseJournalDirIncremental
1323
+ // -----------------------------------------------------------------------
1324
+ describe('parseJournalDirIncremental', () => {
1325
+ it('returns all events and fileCount on first call (no previous state)', async () => {
1326
+ mockAccess.mockResolvedValue(undefined);
1327
+ mockReaddir.mockResolvedValue([
1328
+ '000001.ULID1.json',
1329
+ '000002.ULID2.json',
1330
+ ] as any);
1331
+
1332
+ mockReadFile.mockImplementation(async (filePath: any) => {
1333
+ const p = filePath.toString();
1334
+ if (p.includes('000001')) {
1335
+ return JSON.stringify(
1336
+ makeRunCreatedRaw('run-1', 'process-1', '2024-01-15T10:00:00Z'),
1337
+ );
1338
+ }
1339
+ if (p.includes('000002')) {
1340
+ return JSON.stringify(
1341
+ makeEffectRequestedRaw('eff-1', 'node', 'step', '2024-01-15T10:00:01Z'),
1342
+ );
1343
+ }
1344
+ return '{}';
1345
+ });
1346
+
1347
+ const result = await parseJournalDirIncremental('/run/journal');
1348
+
1349
+ expect(result.events).toHaveLength(2);
1350
+ expect(result.fileCount).toBe(2);
1351
+ expect(result.events[0].type).toBe('RUN_CREATED');
1352
+ expect(result.events[1].type).toBe('EFFECT_REQUESTED');
1353
+ });
1354
+
1355
+ it('incrementally reads only new files when previous state is provided', async () => {
1356
+ mockAccess.mockResolvedValue(undefined);
1357
+
1358
+ // Simulate: 3 files now exist, but 2 were already parsed
1359
+ mockReaddir.mockResolvedValue([
1360
+ '000001.ULID1.json',
1361
+ '000002.ULID2.json',
1362
+ '000003.ULID3.json',
1363
+ ] as any);
1364
+
1365
+ const readFileCalls: string[] = [];
1366
+ mockReadFile.mockImplementation(async (filePath: any) => {
1367
+ const p = filePath.toString();
1368
+ readFileCalls.push(p);
1369
+ if (p.includes('000003')) {
1370
+ return JSON.stringify(
1371
+ makeRunCompletedRaw('2024-01-15T10:00:05Z'),
1372
+ );
1373
+ }
1374
+ // These should NOT be called during incremental reads
1375
+ if (p.includes('000001')) {
1376
+ return JSON.stringify(
1377
+ makeRunCreatedRaw('run-1', 'proc', '2024-01-15T10:00:00Z'),
1378
+ );
1379
+ }
1380
+ if (p.includes('000002')) {
1381
+ return JSON.stringify(
1382
+ makeEffectRequestedRaw('eff-1', 'node', 'step', '2024-01-15T10:00:01Z'),
1383
+ );
1384
+ }
1385
+ return '{}';
1386
+ });
1387
+
1388
+ const previousEvents: JournalEvent[] = [
1389
+ { seq: 1, id: 'ULID1', ts: '2024-01-15T10:00:00Z', type: 'RUN_CREATED', payload: { runId: 'run-1' } },
1390
+ { seq: 2, id: 'ULID2', ts: '2024-01-15T10:00:01Z', type: 'EFFECT_REQUESTED', payload: { effectId: 'eff-1' } },
1391
+ ];
1392
+
1393
+ const result = await parseJournalDirIncremental('/run/journal', previousEvents, 2);
1394
+
1395
+ // Should have all 3 events merged
1396
+ expect(result.events).toHaveLength(3);
1397
+ expect(result.fileCount).toBe(3);
1398
+ expect(result.events[2].type).toBe('RUN_COMPLETED');
1399
+
1400
+ // Only file 000003 should have been read (not 000001 or 000002)
1401
+ const journalReads = readFileCalls.filter(
1402
+ (p) => p.includes('000001') || p.includes('000002'),
1403
+ );
1404
+ expect(journalReads).toHaveLength(0);
1405
+ });
1406
+
1407
+ it('resets and re-reads from beginning when journal is truncated (fewer files than offset)', async () => {
1408
+ mockAccess.mockResolvedValue(undefined);
1409
+
1410
+ // Simulate: journal was truncated — only 1 file exists now, but we had 3
1411
+ mockReaddir.mockResolvedValue([
1412
+ '000001.NEW1.json',
1413
+ ] as any);
1414
+
1415
+ mockReadFile.mockImplementation(async (filePath: any) => {
1416
+ const p = filePath.toString();
1417
+ if (p.includes('000001')) {
1418
+ return JSON.stringify(
1419
+ makeRunCreatedRaw('run-new', 'proc', '2024-02-01T12:00:00Z'),
1420
+ );
1421
+ }
1422
+ return '{}';
1423
+ });
1424
+
1425
+ const previousEvents: JournalEvent[] = [
1426
+ { seq: 1, id: 'OLD1', ts: '2024-01-15T10:00:00Z', type: 'RUN_CREATED', payload: {} },
1427
+ { seq: 2, id: 'OLD2', ts: '2024-01-15T10:00:01Z', type: 'EFFECT_REQUESTED', payload: {} },
1428
+ { seq: 3, id: 'OLD3', ts: '2024-01-15T10:00:02Z', type: 'RUN_COMPLETED', payload: {} },
1429
+ ];
1430
+
1431
+ const result = await parseJournalDirIncremental('/run/journal', previousEvents, 3);
1432
+
1433
+ // Should have done a full re-read — only 1 event from the new file
1434
+ expect(result.events).toHaveLength(1);
1435
+ expect(result.fileCount).toBe(1);
1436
+ expect(result.events[0].payload).toEqual({ runId: 'run-new', processId: 'proc' });
1437
+ });
1438
+
1439
+ it('returns previous events unchanged when no new files are appended (empty append)', async () => {
1440
+ mockAccess.mockResolvedValue(undefined);
1441
+
1442
+ // Same number of files as before
1443
+ mockReaddir.mockResolvedValue([
1444
+ '000001.ULID1.json',
1445
+ '000002.ULID2.json',
1446
+ ] as any);
1447
+
1448
+ // readFile should NOT be called at all for incremental empty-append case
1449
+ const readFileCalls: string[] = [];
1450
+ mockReadFile.mockImplementation(async (filePath: any) => {
1451
+ readFileCalls.push(filePath.toString());
1452
+ return '{}';
1453
+ });
1454
+
1455
+ const previousEvents: JournalEvent[] = [
1456
+ { seq: 1, id: 'ULID1', ts: '2024-01-15T10:00:00Z', type: 'RUN_CREATED', payload: {} },
1457
+ { seq: 2, id: 'ULID2', ts: '2024-01-15T10:00:01Z', type: 'EFFECT_REQUESTED', payload: {} },
1458
+ ];
1459
+
1460
+ const result = await parseJournalDirIncremental('/run/journal', previousEvents, 2);
1461
+
1462
+ expect(result.events).toHaveLength(2);
1463
+ expect(result.fileCount).toBe(2);
1464
+ // The events should be the exact same references
1465
+ expect(result.events).toBe(previousEvents);
1466
+ // No files should have been read
1467
+ expect(readFileCalls).toHaveLength(0);
1468
+ });
1469
+
1470
+ it('handles concurrent incremental reads producing consistent results', async () => {
1471
+ mockAccess.mockResolvedValue(undefined);
1472
+
1473
+ mockReaddir.mockResolvedValue([
1474
+ '000001.ULID1.json',
1475
+ '000002.ULID2.json',
1476
+ '000003.ULID3.json',
1477
+ ] as any);
1478
+
1479
+ mockReadFile.mockImplementation(async (filePath: any) => {
1480
+ const p = filePath.toString();
1481
+ if (p.includes('000003')) {
1482
+ return JSON.stringify(
1483
+ makeEffectResolvedRaw('eff-1', 'ok', '2024-01-15T10:00:05Z'),
1484
+ );
1485
+ }
1486
+ if (p.includes('000001')) {
1487
+ return JSON.stringify(
1488
+ makeRunCreatedRaw('run-1', 'proc', '2024-01-15T10:00:00Z'),
1489
+ );
1490
+ }
1491
+ if (p.includes('000002')) {
1492
+ return JSON.stringify(
1493
+ makeEffectRequestedRaw('eff-1', 'node', 'step', '2024-01-15T10:00:01Z'),
1494
+ );
1495
+ }
1496
+ return '{}';
1497
+ });
1498
+
1499
+ const previousEvents: JournalEvent[] = [
1500
+ { seq: 1, id: 'ULID1', ts: '2024-01-15T10:00:00Z', type: 'RUN_CREATED', payload: { runId: 'run-1' } },
1501
+ { seq: 2, id: 'ULID2', ts: '2024-01-15T10:00:01Z', type: 'EFFECT_REQUESTED', payload: { effectId: 'eff-1' } },
1502
+ ];
1503
+
1504
+ // Launch two concurrent incremental reads with the same state
1505
+ const [result1, result2] = await Promise.all([
1506
+ parseJournalDirIncremental('/run/journal', previousEvents, 2),
1507
+ parseJournalDirIncremental('/run/journal', previousEvents, 2),
1508
+ ]);
1509
+
1510
+ // Both should produce identical results
1511
+ expect(result1.events).toHaveLength(3);
1512
+ expect(result2.events).toHaveLength(3);
1513
+ expect(result1.fileCount).toBe(3);
1514
+ expect(result2.fileCount).toBe(3);
1515
+
1516
+ // Both should have the same event types in the same order
1517
+ expect(result1.events.map((e) => e.type)).toEqual(result2.events.map((e) => e.type));
1518
+
1519
+ // Neither should have corrupted the original previousEvents array
1520
+ expect(previousEvents).toHaveLength(2);
1521
+ });
1522
+
1523
+ it('returns empty events and fileCount=0 when journal directory does not exist', async () => {
1524
+ mockAccess.mockRejectedValue(new Error('ENOENT'));
1525
+
1526
+ const result = await parseJournalDirIncremental('/nonexistent/journal');
1527
+
1528
+ expect(result.events).toEqual([]);
1529
+ expect(result.fileCount).toBe(0);
1530
+ });
1531
+ });
1532
+ });