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