@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,794 @@
|
|
|
1
|
+
import { promises as fs } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
/** Return true when err represents a "file/directory not found" filesystem error. */
|
|
5
|
+
function isNotFoundError(err: unknown): boolean {
|
|
6
|
+
if (!(err instanceof Error)) return false;
|
|
7
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
8
|
+
return code === "ENOENT" || code === "ENOTDIR" || err.message.includes("ENOENT");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
Run,
|
|
13
|
+
RunStatus,
|
|
14
|
+
JournalEvent,
|
|
15
|
+
TaskEffect,
|
|
16
|
+
TaskDetail,
|
|
17
|
+
TaskKind,
|
|
18
|
+
RunDigest,
|
|
19
|
+
EffectRequestedPayload,
|
|
20
|
+
EffectResolvedPayload,
|
|
21
|
+
RunCreatedPayload,
|
|
22
|
+
} from "@/types";
|
|
23
|
+
import { getConfig } from "@/lib/config-loader";
|
|
24
|
+
|
|
25
|
+
function parseTimestampMs(value: string | undefined): number | null {
|
|
26
|
+
if (!value) return null;
|
|
27
|
+
const ms = new Date(value).getTime();
|
|
28
|
+
return Number.isFinite(ms) ? ms : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeInterval(startMs: number | null, endMs: number | null): { startMs: number; endMs: number } | null {
|
|
32
|
+
if (startMs == null || endMs == null) return null;
|
|
33
|
+
return {
|
|
34
|
+
startMs,
|
|
35
|
+
endMs: Math.max(endMs, startMs),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getTaskActiveWindow(task: {
|
|
40
|
+
startedAt?: string;
|
|
41
|
+
finishedAt?: string;
|
|
42
|
+
requestedAt?: string;
|
|
43
|
+
resolvedAt?: string;
|
|
44
|
+
}): { startMs: number; endMs: number } | null {
|
|
45
|
+
const explicitWindow = normalizeInterval(
|
|
46
|
+
parseTimestampMs(task.startedAt),
|
|
47
|
+
parseTimestampMs(task.finishedAt)
|
|
48
|
+
);
|
|
49
|
+
if (explicitWindow) return explicitWindow;
|
|
50
|
+
|
|
51
|
+
return normalizeInterval(
|
|
52
|
+
parseTimestampMs(task.requestedAt),
|
|
53
|
+
parseTimestampMs(task.resolvedAt)
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getTaskActiveDuration(task: {
|
|
58
|
+
startedAt?: string;
|
|
59
|
+
finishedAt?: string;
|
|
60
|
+
requestedAt?: string;
|
|
61
|
+
resolvedAt?: string;
|
|
62
|
+
}): number | undefined {
|
|
63
|
+
const window = getTaskActiveWindow(task);
|
|
64
|
+
if (!window) return undefined;
|
|
65
|
+
return window.endMs - window.startMs;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getCoveredDurationMs(windows: Array<{ startMs: number; endMs: number }>): number {
|
|
69
|
+
if (windows.length === 0) return 0;
|
|
70
|
+
|
|
71
|
+
const sorted = [...windows].sort((a, b) => a.startMs - b.startMs);
|
|
72
|
+
let covered = 0;
|
|
73
|
+
let current = sorted[0];
|
|
74
|
+
|
|
75
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
76
|
+
const next = sorted[i];
|
|
77
|
+
if (next.startMs <= current.endMs) {
|
|
78
|
+
current.endMs = Math.max(current.endMs, next.endMs);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
covered += current.endMs - current.startMs;
|
|
82
|
+
current = next;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
covered += current.endMs - current.startMs;
|
|
86
|
+
return covered;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function fileExists(filePath: string): Promise<boolean> {
|
|
90
|
+
try {
|
|
91
|
+
await fs.access(filePath);
|
|
92
|
+
return true;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
// ENOENT is expected for non-existent paths; warn on permission or other errors
|
|
95
|
+
if (!isNotFoundError(err)) {
|
|
96
|
+
console.warn(`[parser] Unexpected error checking existence of ${filePath}:`, err);
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function readJsonSafe<T>(filePath: string, fallback: T | null | undefined): Promise<T | null | undefined> {
|
|
103
|
+
try {
|
|
104
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
105
|
+
return JSON.parse(content) as T;
|
|
106
|
+
} catch (err) {
|
|
107
|
+
// ENOENT is expected for optional files; warn on parse errors or permission issues
|
|
108
|
+
if (!isNotFoundError(err)) {
|
|
109
|
+
console.warn(`[parser] Failed to read/parse JSON from ${filePath}:`, err);
|
|
110
|
+
}
|
|
111
|
+
return fallback;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function readTextSafe(filePath: string): Promise<string | undefined> {
|
|
116
|
+
try {
|
|
117
|
+
return await fs.readFile(filePath, "utf-8");
|
|
118
|
+
} catch (err) {
|
|
119
|
+
// ENOENT is expected for optional log files; warn on permission or other errors
|
|
120
|
+
if (!isNotFoundError(err)) {
|
|
121
|
+
console.warn(`[parser] Failed to read text file ${filePath}:`, err);
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Maximum concurrent filesystem operations to prevent file descriptor exhaustion. */
|
|
128
|
+
const BATCH_CONCURRENCY_LIMIT = 50;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Execute an array of async factory functions with a concurrency limit.
|
|
132
|
+
* Returns results in the same order as the input, using Promise.allSettled
|
|
133
|
+
* semantics so that individual failures don't crash the batch.
|
|
134
|
+
*/
|
|
135
|
+
async function batchAllSettled<T>(
|
|
136
|
+
factories: Array<() => Promise<T>>,
|
|
137
|
+
limit: number = BATCH_CONCURRENCY_LIMIT
|
|
138
|
+
): Promise<PromiseSettledResult<T>[]> {
|
|
139
|
+
const results: PromiseSettledResult<T>[] = new Array(factories.length);
|
|
140
|
+
let nextIndex = 0;
|
|
141
|
+
|
|
142
|
+
async function worker() {
|
|
143
|
+
while (nextIndex < factories.length) {
|
|
144
|
+
const idx = nextIndex++;
|
|
145
|
+
try {
|
|
146
|
+
const value = await factories[idx]();
|
|
147
|
+
results[idx] = { status: "fulfilled", value };
|
|
148
|
+
} catch (reason) {
|
|
149
|
+
results[idx] = { status: "rejected", reason };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const workerCount = Math.min(limit, factories.length);
|
|
155
|
+
const workers: Promise<void>[] = [];
|
|
156
|
+
for (let i = 0; i < workerCount; i++) {
|
|
157
|
+
workers.push(worker());
|
|
158
|
+
}
|
|
159
|
+
await Promise.all(workers);
|
|
160
|
+
return results;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Normalize raw journal entry (which uses `data` and `recordedAt`) into our JournalEvent type
|
|
164
|
+
function normalizeJournalEvent(raw: Record<string, unknown>, filename: string): JournalEvent | null {
|
|
165
|
+
if (!raw || !raw.type) return null;
|
|
166
|
+
|
|
167
|
+
// Parse seq and id from filename: "000001.ULID.json"
|
|
168
|
+
const parts = filename.replace(/\.json$/, "").split(".");
|
|
169
|
+
const seq = parseInt(parts[0], 10) || 0;
|
|
170
|
+
const id = parts[1] || "";
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
seq,
|
|
174
|
+
id,
|
|
175
|
+
ts: (raw.recordedAt as string) || (raw.ts as string) || "",
|
|
176
|
+
type: raw.type as JournalEvent["type"],
|
|
177
|
+
payload: (raw.data as Record<string, unknown>) || (raw.payload as Record<string, unknown>) || {},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Result of an incremental journal parse. */
|
|
182
|
+
export interface IncrementalJournalResult {
|
|
183
|
+
events: JournalEvent[];
|
|
184
|
+
/** Number of JSON files in the journal directory after this parse. */
|
|
185
|
+
fileCount: number;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function parseJournalDir(
|
|
189
|
+
journalPath: string
|
|
190
|
+
): Promise<JournalEvent[]> {
|
|
191
|
+
const result = await parseJournalDirIncremental(journalPath);
|
|
192
|
+
return result.events;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Incrementally parse a journal directory.
|
|
197
|
+
*
|
|
198
|
+
* When `previousEvents` and `previousFileCount` are supplied the function
|
|
199
|
+
* skips files that were already parsed in a previous call. If the
|
|
200
|
+
* directory now has *fewer* files than `previousFileCount` (truncation /
|
|
201
|
+
* rotation) the journal is re-read from scratch.
|
|
202
|
+
*
|
|
203
|
+
* @param journalPath Path to the journal directory.
|
|
204
|
+
* @param previousEvents Events returned by a prior call (used as base for merge).
|
|
205
|
+
* @param previousFileCount Number of JSON files that existed during the prior call.
|
|
206
|
+
* @returns Merged events array (sorted by seq) and the current file count.
|
|
207
|
+
*/
|
|
208
|
+
export async function parseJournalDirIncremental(
|
|
209
|
+
journalPath: string,
|
|
210
|
+
previousEvents?: JournalEvent[],
|
|
211
|
+
previousFileCount?: number
|
|
212
|
+
): Promise<IncrementalJournalResult> {
|
|
213
|
+
if (!(await fileExists(journalPath))) return { events: [], fileCount: 0 };
|
|
214
|
+
|
|
215
|
+
const files = await fs.readdir(journalPath);
|
|
216
|
+
const jsonFiles = files.filter((f) => f.endsWith(".json")).sort();
|
|
217
|
+
const currentFileCount = jsonFiles.length;
|
|
218
|
+
|
|
219
|
+
// Determine whether we can do an incremental read.
|
|
220
|
+
// Incremental is possible when we have cached state AND the file count
|
|
221
|
+
// has not shrunk (truncation / rotation guard).
|
|
222
|
+
const canIncremental =
|
|
223
|
+
previousEvents !== undefined &&
|
|
224
|
+
previousFileCount !== undefined &&
|
|
225
|
+
previousFileCount >= 0 &&
|
|
226
|
+
currentFileCount >= previousFileCount;
|
|
227
|
+
|
|
228
|
+
if (canIncremental) {
|
|
229
|
+
const newFilesStartIdx = previousFileCount!;
|
|
230
|
+
|
|
231
|
+
// No new files — return the previous result as-is.
|
|
232
|
+
if (newFilesStartIdx >= currentFileCount) {
|
|
233
|
+
return { events: previousEvents!, fileCount: currentFileCount };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const newFiles = jsonFiles.slice(newFilesStartIdx);
|
|
237
|
+
|
|
238
|
+
// Batch-read only the new files
|
|
239
|
+
const readFactories = newFiles.map(
|
|
240
|
+
(file) => () =>
|
|
241
|
+
readJsonSafe<Record<string, unknown>>(path.join(journalPath, file), null)
|
|
242
|
+
);
|
|
243
|
+
const settled = await batchAllSettled(readFactories);
|
|
244
|
+
|
|
245
|
+
const newEvents: JournalEvent[] = [];
|
|
246
|
+
for (let i = 0; i < newFiles.length; i++) {
|
|
247
|
+
const result = settled[i];
|
|
248
|
+
const raw = result.status === "fulfilled" ? result.value : null;
|
|
249
|
+
if (raw) {
|
|
250
|
+
const event = normalizeJournalEvent(raw, newFiles[i]);
|
|
251
|
+
if (event) newEvents.push(event);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Merge: previousEvents is already sorted; new events are appended and
|
|
256
|
+
// the full array is re-sorted to guarantee correctness.
|
|
257
|
+
const merged = [...previousEvents!, ...newEvents].sort((a, b) => a.seq - b.seq);
|
|
258
|
+
return { events: merged, fileCount: currentFileCount };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Full re-read (first call, or truncation detected).
|
|
262
|
+
const readFactories = jsonFiles.map(
|
|
263
|
+
(file) => () =>
|
|
264
|
+
readJsonSafe<Record<string, unknown>>(path.join(journalPath, file), null)
|
|
265
|
+
);
|
|
266
|
+
const settled = await batchAllSettled(readFactories);
|
|
267
|
+
|
|
268
|
+
const events: JournalEvent[] = [];
|
|
269
|
+
for (let i = 0; i < jsonFiles.length; i++) {
|
|
270
|
+
const result = settled[i];
|
|
271
|
+
const raw = result.status === "fulfilled" ? result.value : null;
|
|
272
|
+
if (raw) {
|
|
273
|
+
const event = normalizeJournalEvent(raw, jsonFiles[i]);
|
|
274
|
+
if (event) events.push(event);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return { events: events.sort((a, b) => a.seq - b.seq), fileCount: currentFileCount };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Options for incremental run parsing. */
|
|
282
|
+
export interface IncrementalRunOptions {
|
|
283
|
+
previousEvents?: JournalEvent[];
|
|
284
|
+
previousFileCount?: number;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Extended Run result that includes the journal file count for caching. */
|
|
288
|
+
export interface ParseRunResult extends Run {
|
|
289
|
+
/** Number of journal files parsed — used by the cache layer for incremental reads. */
|
|
290
|
+
_journalFileCount: number;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export async function parseRunDir(
|
|
294
|
+
runPath: string,
|
|
295
|
+
incremental?: IncrementalRunOptions
|
|
296
|
+
): Promise<ParseRunResult> {
|
|
297
|
+
const runJson = await readJsonSafe<Record<string, unknown>>(
|
|
298
|
+
path.join(runPath, "run.json"),
|
|
299
|
+
{}
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const journalResult = await parseJournalDirIncremental(
|
|
303
|
+
path.join(runPath, "journal"),
|
|
304
|
+
incremental?.previousEvents,
|
|
305
|
+
incremental?.previousFileCount
|
|
306
|
+
);
|
|
307
|
+
const events = journalResult.events;
|
|
308
|
+
|
|
309
|
+
// Extract run info from events
|
|
310
|
+
const runCreated = events.find((e) => e.type === "RUN_CREATED");
|
|
311
|
+
const runCompleted = events.find((e) => e.type === "RUN_COMPLETED");
|
|
312
|
+
const runFailed = events.find((e) => e.type === "RUN_FAILED");
|
|
313
|
+
|
|
314
|
+
const createdPayload = (runCreated?.payload ||
|
|
315
|
+
{}) as unknown as RunCreatedPayload;
|
|
316
|
+
|
|
317
|
+
// Build task map from events — first pass: collect all requested/resolved info
|
|
318
|
+
const taskMap = new Map<string, TaskEffect>();
|
|
319
|
+
const requestedPayloads: EffectRequestedPayload[] = [];
|
|
320
|
+
|
|
321
|
+
for (const event of events) {
|
|
322
|
+
if (event.type === "EFFECT_REQUESTED") {
|
|
323
|
+
const p = event.payload as unknown as EffectRequestedPayload;
|
|
324
|
+
requestedPayloads.push(p);
|
|
325
|
+
taskMap.set(p.effectId, {
|
|
326
|
+
effectId: p.effectId,
|
|
327
|
+
kind: p.kind,
|
|
328
|
+
title: p.label || p.taskId,
|
|
329
|
+
label: p.label || p.taskId,
|
|
330
|
+
status: "requested",
|
|
331
|
+
invocationKey: p.invocationKey,
|
|
332
|
+
stepId: p.stepId,
|
|
333
|
+
taskId: p.taskId,
|
|
334
|
+
requestedAt: event.ts,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (event.type === "EFFECT_RESOLVED") {
|
|
339
|
+
const p = event.payload as unknown as EffectResolvedPayload;
|
|
340
|
+
const task = taskMap.get(p.effectId);
|
|
341
|
+
if (task) {
|
|
342
|
+
task.status = p.status === "ok" ? "resolved" : "error";
|
|
343
|
+
task.resolvedAt = event.ts;
|
|
344
|
+
task.startedAt = p.startedAt;
|
|
345
|
+
task.finishedAt = p.finishedAt;
|
|
346
|
+
task.duration = getTaskActiveDuration({
|
|
347
|
+
startedAt: p.startedAt,
|
|
348
|
+
finishedAt: p.finishedAt,
|
|
349
|
+
requestedAt: task.requestedAt,
|
|
350
|
+
resolvedAt: event.ts,
|
|
351
|
+
});
|
|
352
|
+
if (p.error) {
|
|
353
|
+
task.error = {
|
|
354
|
+
name: p.error.name,
|
|
355
|
+
message: p.error.message,
|
|
356
|
+
stack: p.error.stack,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Batch-read all task.json files in parallel for EFFECT_REQUESTED tasks
|
|
364
|
+
if (requestedPayloads.length > 0) {
|
|
365
|
+
const taskDefFactories = requestedPayloads.map(
|
|
366
|
+
(p) => () =>
|
|
367
|
+
readJsonSafe<Record<string, unknown>>(
|
|
368
|
+
path.join(runPath, "tasks", p.effectId, "task.json"),
|
|
369
|
+
null
|
|
370
|
+
)
|
|
371
|
+
);
|
|
372
|
+
const taskDefResults = await batchAllSettled(taskDefFactories);
|
|
373
|
+
|
|
374
|
+
for (let i = 0; i < requestedPayloads.length; i++) {
|
|
375
|
+
const p = requestedPayloads[i];
|
|
376
|
+
const result = taskDefResults[i];
|
|
377
|
+
const taskDef = result.status === "fulfilled" ? result.value : null;
|
|
378
|
+
if (taskDef) {
|
|
379
|
+
const task = taskMap.get(p.effectId)!;
|
|
380
|
+
task.title = (taskDef.title as string) || task.title;
|
|
381
|
+
if (taskDef.agent && typeof taskDef.agent === "object") {
|
|
382
|
+
const agentDef = taskDef.agent as Record<string, unknown>;
|
|
383
|
+
task.agent = {
|
|
384
|
+
name: (agentDef.name as string) || "unknown",
|
|
385
|
+
prompt: agentDef.prompt as NonNullable<TaskEffect["agent"]>["prompt"],
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
// Extract breakpoint question from inputs for breakpoint tasks
|
|
389
|
+
if (p.kind === "breakpoint") {
|
|
390
|
+
const inputs = taskDef.inputs as Record<string, unknown> | undefined;
|
|
391
|
+
if (inputs && typeof inputs.question === "string") {
|
|
392
|
+
task.breakpointQuestion = inputs.question;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const tasks = Array.from(taskMap.values());
|
|
400
|
+
const completedTasks = tasks.filter((t) => t.status === "resolved").length;
|
|
401
|
+
const failedTasks = tasks.filter((t) => t.status === "error").length;
|
|
402
|
+
|
|
403
|
+
// Task 1.2: Extract failed step name from the first task that resolved with error
|
|
404
|
+
const firstFailedTask = tasks.find((t) => t.status === "error");
|
|
405
|
+
const failedStep = firstFailedTask
|
|
406
|
+
? firstFailedTask.title || firstFailedTask.label || firstFailedTask.stepId
|
|
407
|
+
: undefined;
|
|
408
|
+
|
|
409
|
+
// Extract failure details from RUN_FAILED event or last failed EFFECT_RESOLVED
|
|
410
|
+
let failureError: string | undefined;
|
|
411
|
+
let failureMessage: string | undefined;
|
|
412
|
+
|
|
413
|
+
if (runFailed) {
|
|
414
|
+
const failPayload = runFailed.payload as Record<string, unknown>;
|
|
415
|
+
const runError = failPayload.error as { name?: string; message?: string; stack?: string } | undefined;
|
|
416
|
+
if (runError) {
|
|
417
|
+
failureError = runError.name || "Error";
|
|
418
|
+
failureMessage = runError.message || runError.stack || undefined;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// If we still don't have a message, look at the last EFFECT_RESOLVED with error status
|
|
423
|
+
if (!failureMessage) {
|
|
424
|
+
const lastFailedEffect = [...events]
|
|
425
|
+
.reverse()
|
|
426
|
+
.find((e) => e.type === "EFFECT_RESOLVED" && (e.payload as Record<string, unknown>).status === "error");
|
|
427
|
+
if (lastFailedEffect) {
|
|
428
|
+
const effectPayload = lastFailedEffect.payload as Record<string, unknown>;
|
|
429
|
+
const effectError = effectPayload.error as { name?: string; message?: string; stack?: string } | undefined;
|
|
430
|
+
if (effectError) {
|
|
431
|
+
failureError = failureError || effectError.name || "Error";
|
|
432
|
+
failureMessage = effectError.message || effectError.stack || undefined;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
let status: RunStatus = "pending";
|
|
438
|
+
if (runCompleted) status = "completed";
|
|
439
|
+
else if (runFailed) status = "failed";
|
|
440
|
+
else if (tasks.some((t) => t.status === "requested")) status = "waiting";
|
|
441
|
+
|
|
442
|
+
// Task 1.3: Extract breakpoint question from pending breakpoint tasks
|
|
443
|
+
let breakpointQuestion: string | undefined;
|
|
444
|
+
if (status === "waiting") {
|
|
445
|
+
const pendingBreakpoint = tasks.find(
|
|
446
|
+
(t) => t.kind === "breakpoint" && t.status === "requested"
|
|
447
|
+
);
|
|
448
|
+
if (pendingBreakpoint?.breakpointQuestion) {
|
|
449
|
+
breakpointQuestion = pendingBreakpoint.breakpointQuestion;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Determine waitingKind: check the last requested (pending) task
|
|
454
|
+
let waitingKind: 'breakpoint' | 'task' | undefined;
|
|
455
|
+
if (status === "waiting") {
|
|
456
|
+
const requestedTasks = tasks.filter((t) => t.status === "requested");
|
|
457
|
+
const lastRequested = requestedTasks[requestedTasks.length - 1];
|
|
458
|
+
if (lastRequested) {
|
|
459
|
+
waitingKind = lastRequested.kind === "breakpoint" ? "breakpoint" : "task";
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const createdAt = runCreated?.ts || "";
|
|
464
|
+
const lastEvent = events[events.length - 1];
|
|
465
|
+
|
|
466
|
+
let duration: number | undefined;
|
|
467
|
+
const taskWindows = tasks
|
|
468
|
+
.map((task) => getTaskActiveWindow(task))
|
|
469
|
+
.filter((window): window is { startMs: number; endMs: number } => window !== null);
|
|
470
|
+
if (taskWindows.length > 0) {
|
|
471
|
+
duration = getCoveredDurationMs(taskWindows);
|
|
472
|
+
} else if (createdAt && (runCompleted || runFailed)) {
|
|
473
|
+
const endTs = (runCompleted || runFailed)!.ts;
|
|
474
|
+
duration = new Date(endTs).getTime() - new Date(createdAt).getTime();
|
|
475
|
+
} else if (createdAt && lastEvent) {
|
|
476
|
+
duration =
|
|
477
|
+
new Date(lastEvent.ts).getTime() - new Date(createdAt).getTime();
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Detect staleness for waiting or pending runs
|
|
481
|
+
let isStale: boolean | undefined;
|
|
482
|
+
if (status === "waiting" || status === "pending") {
|
|
483
|
+
const updatedAtTs = lastEvent?.ts || createdAt;
|
|
484
|
+
if (updatedAtTs) {
|
|
485
|
+
const config = await getConfig();
|
|
486
|
+
const timeSinceUpdate = Date.now() - new Date(updatedAtTs).getTime();
|
|
487
|
+
if (timeSinceUpdate > config.staleThresholdMs) {
|
|
488
|
+
isStale = true;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Detect orphaned runs: all tasks resolved but no terminal event
|
|
494
|
+
// (process likely crashed before writing RUN_COMPLETED)
|
|
495
|
+
if (status === "pending" && tasks.length > 0 && !tasks.some((t) => t.status === "requested")) {
|
|
496
|
+
isStale = true;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
runId: createdPayload.runId || path.basename(runPath),
|
|
501
|
+
processId:
|
|
502
|
+
createdPayload.processId ||
|
|
503
|
+
(runJson?.processId as string) ||
|
|
504
|
+
"unknown",
|
|
505
|
+
status,
|
|
506
|
+
createdAt,
|
|
507
|
+
updatedAt: lastEvent?.ts || createdAt,
|
|
508
|
+
completedAt: (runCompleted || runFailed)?.ts,
|
|
509
|
+
tasks,
|
|
510
|
+
events,
|
|
511
|
+
totalTasks: tasks.length,
|
|
512
|
+
completedTasks,
|
|
513
|
+
failedTasks,
|
|
514
|
+
duration,
|
|
515
|
+
failedStep,
|
|
516
|
+
failureError,
|
|
517
|
+
failureMessage,
|
|
518
|
+
breakpointQuestion,
|
|
519
|
+
isStale,
|
|
520
|
+
waitingKind,
|
|
521
|
+
_journalFileCount: journalResult.fileCount,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export async function parseTaskDetail(
|
|
526
|
+
runPath: string,
|
|
527
|
+
effectId: string
|
|
528
|
+
): Promise<TaskDetail | null> {
|
|
529
|
+
const taskDir = path.join(runPath, "tasks", effectId);
|
|
530
|
+
if (!(await fileExists(taskDir))) return null;
|
|
531
|
+
|
|
532
|
+
// Read all 5 task files + journal in parallel with Promise.allSettled
|
|
533
|
+
const [
|
|
534
|
+
taskDefResult,
|
|
535
|
+
inputResult,
|
|
536
|
+
resultResult,
|
|
537
|
+
stdoutResult,
|
|
538
|
+
stderrResult,
|
|
539
|
+
journalEventsResult,
|
|
540
|
+
] = await Promise.allSettled([
|
|
541
|
+
readJsonSafe<Record<string, unknown>>(path.join(taskDir, "task.json"), null),
|
|
542
|
+
readJsonSafe<Record<string, unknown>>(path.join(taskDir, "input.json"), undefined),
|
|
543
|
+
readJsonSafe<Record<string, unknown>>(path.join(taskDir, "result.json"), undefined),
|
|
544
|
+
readTextSafe(path.join(taskDir, "stdout.log")),
|
|
545
|
+
readTextSafe(path.join(taskDir, "stderr.log")),
|
|
546
|
+
parseJournalDir(path.join(runPath, "journal")),
|
|
547
|
+
]);
|
|
548
|
+
|
|
549
|
+
const taskDef = taskDefResult.status === "fulfilled" ? taskDefResult.value : null;
|
|
550
|
+
const input = inputResult.status === "fulfilled" ? inputResult.value : undefined;
|
|
551
|
+
const result = resultResult.status === "fulfilled" ? resultResult.value : undefined;
|
|
552
|
+
const stdout = stdoutResult.status === "fulfilled" ? stdoutResult.value : undefined;
|
|
553
|
+
const stderr = stderrResult.status === "fulfilled" ? stderrResult.value : undefined;
|
|
554
|
+
const journalEvents = journalEventsResult.status === "fulfilled" ? journalEventsResult.value : [];
|
|
555
|
+
|
|
556
|
+
// Extract timing from result.json
|
|
557
|
+
const resultStartedAt = result?.startedAt as string | undefined;
|
|
558
|
+
const resultFinishedAt = result?.finishedAt as string | undefined;
|
|
559
|
+
const requestedEvent = journalEvents.find(
|
|
560
|
+
(e) => e.type === "EFFECT_REQUESTED" && (e.payload as Record<string, unknown>).effectId === effectId
|
|
561
|
+
);
|
|
562
|
+
const resolvedEvent = journalEvents.find(
|
|
563
|
+
(e) => e.type === "EFFECT_RESOLVED" && (e.payload as Record<string, unknown>).effectId === effectId
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
const requestedAt = requestedEvent?.ts || "";
|
|
567
|
+
const resolvedAt = resolvedEvent?.ts;
|
|
568
|
+
|
|
569
|
+
const duration = getTaskActiveDuration({
|
|
570
|
+
startedAt: resultStartedAt,
|
|
571
|
+
finishedAt: resultFinishedAt,
|
|
572
|
+
requestedAt,
|
|
573
|
+
resolvedAt,
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// Use inputs from task.json if separate input.json doesn't exist
|
|
577
|
+
const resolvedInput = input ?? (taskDef?.inputs as Record<string, unknown> | undefined);
|
|
578
|
+
|
|
579
|
+
// Extract breakpoint payload for breakpoint tasks
|
|
580
|
+
const kind = (taskDef?.kind as TaskKind) || "agent";
|
|
581
|
+
let breakpointPayload: import("@/types").BreakpointPayload | undefined;
|
|
582
|
+
if (kind === "breakpoint" && resolvedInput) {
|
|
583
|
+
breakpointPayload = {
|
|
584
|
+
question: (resolvedInput.question as string) || "Approval required",
|
|
585
|
+
title: (resolvedInput.title as string) || (taskDef?.title as string) || "Breakpoint",
|
|
586
|
+
options: Array.isArray(resolvedInput.options) ? (resolvedInput.options as string[]) : undefined,
|
|
587
|
+
context: resolvedInput.context as import("@/types").BreakpointPayload["context"],
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Determine error status from result or journal
|
|
592
|
+
const resolvedPayload = resolvedEvent?.payload as Record<string, unknown> | undefined;
|
|
593
|
+
const isError = result
|
|
594
|
+
? (result.status === "error")
|
|
595
|
+
: (resolvedPayload?.status === "error");
|
|
596
|
+
|
|
597
|
+
return {
|
|
598
|
+
effectId,
|
|
599
|
+
kind,
|
|
600
|
+
title: (taskDef?.title as string) || effectId,
|
|
601
|
+
label: (taskDef?.title as string) || effectId,
|
|
602
|
+
status: resolvedEvent ? (isError ? "error" : "resolved") : "requested",
|
|
603
|
+
invocationKey: (taskDef?.invocationKey as string) || "",
|
|
604
|
+
stepId: (taskDef?.stepId as string) || "",
|
|
605
|
+
taskId: (taskDef?.taskId as string) || "",
|
|
606
|
+
requestedAt,
|
|
607
|
+
resolvedAt,
|
|
608
|
+
startedAt: resultStartedAt,
|
|
609
|
+
finishedAt: resultFinishedAt,
|
|
610
|
+
duration,
|
|
611
|
+
input: resolvedInput,
|
|
612
|
+
result: result ?? undefined,
|
|
613
|
+
stdout,
|
|
614
|
+
stderr,
|
|
615
|
+
taskDef: taskDef ?? undefined,
|
|
616
|
+
breakpoint: breakpointPayload,
|
|
617
|
+
breakpointQuestion: breakpointPayload?.question,
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
export async function getRunDigest(runPath: string): Promise<RunDigest> {
|
|
622
|
+
const journalPath = path.join(runPath, "journal");
|
|
623
|
+
let latestSeq = 0;
|
|
624
|
+
let status: RunStatus = "pending";
|
|
625
|
+
let taskCount = 0;
|
|
626
|
+
let completedTasks = 0;
|
|
627
|
+
let updatedAt = "";
|
|
628
|
+
|
|
629
|
+
const requestedBreakpoints = new Set<string>();
|
|
630
|
+
const resolvedEffects = new Set<string>();
|
|
631
|
+
const breakpointEffectIds = new Set<string>();
|
|
632
|
+
// Track requested effects and their kinds for waitingKind determination
|
|
633
|
+
const requestedEffects: Array<{ effectId: string; kind: string }> = [];
|
|
634
|
+
|
|
635
|
+
if (await fileExists(journalPath)) {
|
|
636
|
+
const files = await fs.readdir(journalPath);
|
|
637
|
+
const jsonFiles = files.filter((f) => f.endsWith(".json")).sort();
|
|
638
|
+
latestSeq = jsonFiles.length;
|
|
639
|
+
|
|
640
|
+
// Batch-read all journal files in parallel with concurrency limit
|
|
641
|
+
const readFactories = jsonFiles.map(
|
|
642
|
+
(file) => () =>
|
|
643
|
+
readJsonSafe<Record<string, unknown>>(path.join(journalPath, file), null)
|
|
644
|
+
);
|
|
645
|
+
const settled = await batchAllSettled(readFactories);
|
|
646
|
+
|
|
647
|
+
// Process results sequentially to maintain event ordering for updatedAt
|
|
648
|
+
for (let i = 0; i < jsonFiles.length; i++) {
|
|
649
|
+
const result = settled[i];
|
|
650
|
+
const raw = result.status === "fulfilled" ? result.value : null;
|
|
651
|
+
if (!raw) continue;
|
|
652
|
+
const event = normalizeJournalEvent(raw, jsonFiles[i]);
|
|
653
|
+
if (!event) continue;
|
|
654
|
+
updatedAt = event.ts;
|
|
655
|
+
if (event.type === "EFFECT_REQUESTED") {
|
|
656
|
+
taskCount++;
|
|
657
|
+
const data = event.payload as Record<string, unknown>;
|
|
658
|
+
const effectId = data.effectId as string;
|
|
659
|
+
const kind = (data.kind as string) || "agent";
|
|
660
|
+
requestedEffects.push({ effectId, kind });
|
|
661
|
+
if (data.kind === "breakpoint") {
|
|
662
|
+
requestedBreakpoints.add(effectId);
|
|
663
|
+
breakpointEffectIds.add(effectId);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (event.type === "EFFECT_RESOLVED") {
|
|
667
|
+
completedTasks++;
|
|
668
|
+
const data = event.payload as Record<string, unknown>;
|
|
669
|
+
resolvedEffects.add(data.effectId as string);
|
|
670
|
+
}
|
|
671
|
+
if (event.type === "RUN_COMPLETED") status = "completed";
|
|
672
|
+
if (event.type === "RUN_FAILED") status = "failed";
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (status === "pending" && taskCount > 0) status = "waiting";
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Count pending breakpoints (requested but not yet resolved).
|
|
679
|
+
// Also check result.json — the dashboard writes it on approve but can't
|
|
680
|
+
// write journal events, so the journal alone may lag behind.
|
|
681
|
+
let pendingBreakpoints = 0;
|
|
682
|
+
if (requestedBreakpoints.size > 0) {
|
|
683
|
+
const unresolvedBps = [...requestedBreakpoints].filter(
|
|
684
|
+
(id) => !resolvedEffects.has(id)
|
|
685
|
+
);
|
|
686
|
+
if (unresolvedBps.length > 0) {
|
|
687
|
+
const resultChecks = await Promise.all(
|
|
688
|
+
unresolvedBps.map((id) =>
|
|
689
|
+
readJsonSafe<Record<string, unknown>>(
|
|
690
|
+
path.join(runPath, "tasks", id, "result.json"),
|
|
691
|
+
null
|
|
692
|
+
)
|
|
693
|
+
)
|
|
694
|
+
);
|
|
695
|
+
for (let i = 0; i < unresolvedBps.length; i++) {
|
|
696
|
+
if (resultChecks[i] && resultChecks[i]!.status === "ok") {
|
|
697
|
+
resolvedEffects.add(unresolvedBps[i]);
|
|
698
|
+
} else {
|
|
699
|
+
pendingBreakpoints++;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Extract breakpoint question and effectId from pending breakpoint tasks — batch-read all at once
|
|
706
|
+
let breakpointQuestion: string | undefined;
|
|
707
|
+
let breakpointEffectId: string | undefined;
|
|
708
|
+
if (status === "waiting" && breakpointEffectIds.size > 0) {
|
|
709
|
+
const pendingBpIds = [...breakpointEffectIds].filter(
|
|
710
|
+
(id) => !resolvedEffects.has(id)
|
|
711
|
+
);
|
|
712
|
+
if (pendingBpIds.length > 0) {
|
|
713
|
+
// Store the first pending breakpoint effectId regardless of question
|
|
714
|
+
breakpointEffectId = pendingBpIds[0];
|
|
715
|
+
|
|
716
|
+
const bpFactories = pendingBpIds.map(
|
|
717
|
+
(effectId) => () =>
|
|
718
|
+
readJsonSafe<Record<string, unknown>>(
|
|
719
|
+
path.join(runPath, "tasks", effectId, "task.json"),
|
|
720
|
+
null
|
|
721
|
+
)
|
|
722
|
+
);
|
|
723
|
+
const bpResults = await batchAllSettled(bpFactories);
|
|
724
|
+
|
|
725
|
+
// Use the first pending breakpoint question found
|
|
726
|
+
for (let i = 0; i < pendingBpIds.length; i++) {
|
|
727
|
+
const result = bpResults[i];
|
|
728
|
+
const taskDef = result.status === "fulfilled" ? result.value : null;
|
|
729
|
+
if (taskDef) {
|
|
730
|
+
const inputs = taskDef.inputs as Record<string, unknown> | undefined;
|
|
731
|
+
if (inputs && typeof inputs.question === "string") {
|
|
732
|
+
breakpointQuestion = inputs.question;
|
|
733
|
+
breakpointEffectId = pendingBpIds[i];
|
|
734
|
+
break;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Determine waitingKind from the last requested (pending) effect
|
|
742
|
+
let waitingKind: 'breakpoint' | 'task' | undefined;
|
|
743
|
+
if (status === "waiting") {
|
|
744
|
+
// Find the last requested effect that hasn't been resolved
|
|
745
|
+
const pendingEffects = requestedEffects.filter(
|
|
746
|
+
(e) => !resolvedEffects.has(e.effectId)
|
|
747
|
+
);
|
|
748
|
+
const lastPending = pendingEffects[pendingEffects.length - 1];
|
|
749
|
+
if (lastPending) {
|
|
750
|
+
waitingKind = lastPending.kind === "breakpoint" ? "breakpoint" : "task";
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Detect staleness for waiting or pending runs
|
|
755
|
+
let isStale: boolean | undefined;
|
|
756
|
+
if (status === "waiting" || status === "pending") {
|
|
757
|
+
if (updatedAt) {
|
|
758
|
+
const config = await getConfig();
|
|
759
|
+
const timeSinceUpdate = Date.now() - new Date(updatedAt).getTime();
|
|
760
|
+
if (timeSinceUpdate > config.staleThresholdMs) {
|
|
761
|
+
isStale = true;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Detect orphaned runs: all effects resolved but no terminal event
|
|
767
|
+
if (status === "waiting" && taskCount > 0 && completedTasks >= taskCount) {
|
|
768
|
+
isStale = true;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return {
|
|
772
|
+
runId: path.basename(runPath),
|
|
773
|
+
latestSeq,
|
|
774
|
+
status,
|
|
775
|
+
taskCount,
|
|
776
|
+
completedTasks,
|
|
777
|
+
updatedAt,
|
|
778
|
+
pendingBreakpoints,
|
|
779
|
+
breakpointQuestion,
|
|
780
|
+
breakpointEffectId,
|
|
781
|
+
isStale,
|
|
782
|
+
waitingKind,
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
export async function getRunIds(runsPath: string): Promise<string[]> {
|
|
787
|
+
if (!(await fileExists(runsPath))) return [];
|
|
788
|
+
const entries = await fs.readdir(runsPath, { withFileTypes: true });
|
|
789
|
+
return entries
|
|
790
|
+
.filter((e) => e.isDirectory())
|
|
791
|
+
.map((e) => e.name)
|
|
792
|
+
.sort()
|
|
793
|
+
.reverse();
|
|
794
|
+
}
|