@a5c-ai/babysitter-observer-dashboard 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +490 -0
  3. package/next.config.mjs +25 -0
  4. package/package.json +104 -0
  5. package/postcss.config.mjs +8 -0
  6. package/src/app/actions/__tests__/approve-breakpoint.test.ts +246 -0
  7. package/src/app/actions/approve-breakpoint.ts +145 -0
  8. package/src/app/api/config/route.ts +137 -0
  9. package/src/app/api/digest/route.ts +45 -0
  10. package/src/app/api/runs/[runId]/events/route.ts +56 -0
  11. package/src/app/api/runs/[runId]/route.ts +84 -0
  12. package/src/app/api/runs/[runId]/tasks/[effectId]/route.ts +44 -0
  13. package/src/app/api/runs/route.ts +48 -0
  14. package/src/app/api/stream/route.ts +136 -0
  15. package/src/app/api/test/route.ts +1 -0
  16. package/src/app/api/version/route.ts +57 -0
  17. package/src/app/globals.css +555 -0
  18. package/src/app/icon.svg +20 -0
  19. package/src/app/layout.tsx +39 -0
  20. package/src/app/not-found.tsx +16 -0
  21. package/src/app/page.tsx +120 -0
  22. package/src/app/runs/[runId]/page.tsx +279 -0
  23. package/src/cli.ts +271 -0
  24. package/src/components/breakpoint/__tests__/breakpoint-approval.test.tsx +212 -0
  25. package/src/components/breakpoint/__tests__/breakpoint-panel.test.tsx +130 -0
  26. package/src/components/breakpoint/__tests__/file-preview.test.tsx +313 -0
  27. package/src/components/breakpoint/breakpoint-approval.tsx +138 -0
  28. package/src/components/breakpoint/breakpoint-panel.tsx +95 -0
  29. package/src/components/breakpoint/file-preview.tsx +215 -0
  30. package/src/components/dashboard/.gitkeep +0 -0
  31. package/src/components/dashboard/__tests__/breakpoint-banner.test.tsx +177 -0
  32. package/src/components/dashboard/__tests__/catch-up-banner.test.tsx +141 -0
  33. package/src/components/dashboard/__tests__/executive-summary-banner.test.tsx +164 -0
  34. package/src/components/dashboard/__tests__/kpi-grid.test.tsx +101 -0
  35. package/src/components/dashboard/__tests__/pagination-controls.test.tsx +125 -0
  36. package/src/components/dashboard/__tests__/project-accordion.test.tsx +97 -0
  37. package/src/components/dashboard/__tests__/project-list-view.test.tsx +174 -0
  38. package/src/components/dashboard/__tests__/project-search-input.test.tsx +110 -0
  39. package/src/components/dashboard/__tests__/project-section-header.test.tsx +91 -0
  40. package/src/components/dashboard/__tests__/project-section.test.tsx +151 -0
  41. package/src/components/dashboard/__tests__/run-card.test.tsx +164 -0
  42. package/src/components/dashboard/__tests__/run-filter-bar.test.tsx +109 -0
  43. package/src/components/dashboard/__tests__/run-list.test.tsx +123 -0
  44. package/src/components/dashboard/__tests__/search-filter.test.tsx +150 -0
  45. package/src/components/dashboard/__tests__/virtualized-run-list.test.tsx +179 -0
  46. package/src/components/dashboard/breakpoint-banner.tsx +301 -0
  47. package/src/components/dashboard/catch-up-banner.tsx +88 -0
  48. package/src/components/dashboard/executive-summary-banner.tsx +174 -0
  49. package/src/components/dashboard/global-search.tsx +323 -0
  50. package/src/components/dashboard/kpi-grid.tsx +140 -0
  51. package/src/components/dashboard/pagination-controls.tsx +100 -0
  52. package/src/components/dashboard/project-accordion.tsx +72 -0
  53. package/src/components/dashboard/project-health-card.tsx +536 -0
  54. package/src/components/dashboard/project-list-view.tsx +246 -0
  55. package/src/components/dashboard/project-search-input.tsx +41 -0
  56. package/src/components/dashboard/project-section-header.tsx +73 -0
  57. package/src/components/dashboard/project-section.tsx +89 -0
  58. package/src/components/dashboard/run-card.tsx +218 -0
  59. package/src/components/dashboard/run-filter-bar.tsx +100 -0
  60. package/src/components/dashboard/run-list.tsx +77 -0
  61. package/src/components/dashboard/search-filter.tsx +69 -0
  62. package/src/components/dashboard/virtualized-run-list.tsx +130 -0
  63. package/src/components/details/.gitkeep +0 -0
  64. package/src/components/details/__tests__/agent-panel.test.tsx +236 -0
  65. package/src/components/details/__tests__/json-tree.test.tsx +347 -0
  66. package/src/components/details/__tests__/log-viewer.test.tsx +168 -0
  67. package/src/components/details/__tests__/task-detail.test.tsx +212 -0
  68. package/src/components/details/__tests__/timing-panel.test.tsx +271 -0
  69. package/src/components/details/agent-panel.tsx +234 -0
  70. package/src/components/details/json-tree/categorize.ts +131 -0
  71. package/src/components/details/json-tree/index.tsx +120 -0
  72. package/src/components/details/json-tree/json-node.tsx +223 -0
  73. package/src/components/details/json-tree/smart-summary.tsx +596 -0
  74. package/src/components/details/json-tree/tree-controls.tsx +47 -0
  75. package/src/components/details/json-tree.tsx +9 -0
  76. package/src/components/details/log-viewer.tsx +140 -0
  77. package/src/components/details/task-detail.tsx +114 -0
  78. package/src/components/details/timing-panel.tsx +247 -0
  79. package/src/components/events/.gitkeep +0 -0
  80. package/src/components/events/__tests__/event-item.test.tsx +211 -0
  81. package/src/components/events/__tests__/event-stream.test.tsx +225 -0
  82. package/src/components/events/event-item.tsx +121 -0
  83. package/src/components/events/event-stream.tsx +260 -0
  84. package/src/components/notifications/.gitkeep +0 -0
  85. package/src/components/notifications/__tests__/notification-panel.test.tsx +287 -0
  86. package/src/components/notifications/__tests__/notification-provider.test.tsx +585 -0
  87. package/src/components/notifications/__tests__/toast-stack.test.tsx +217 -0
  88. package/src/components/notifications/notification-panel.tsx +124 -0
  89. package/src/components/notifications/notification-provider.tsx +175 -0
  90. package/src/components/notifications/toast-stack.tsx +75 -0
  91. package/src/components/pipeline/.gitkeep +0 -0
  92. package/src/components/pipeline/__tests__/parallel-group.test.tsx +88 -0
  93. package/src/components/pipeline/__tests__/pipeline-view.test.tsx +345 -0
  94. package/src/components/pipeline/__tests__/step-card.test.tsx +330 -0
  95. package/src/components/pipeline/parallel-group.tsx +39 -0
  96. package/src/components/pipeline/pipeline-view.tsx +197 -0
  97. package/src/components/pipeline/step-card.tsx +166 -0
  98. package/src/components/providers/event-stream-provider.tsx +29 -0
  99. package/src/components/providers.tsx +24 -0
  100. package/src/components/shared/.gitkeep +0 -0
  101. package/src/components/shared/__tests__/empty-state.test.tsx +49 -0
  102. package/src/components/shared/__tests__/friendly-id.test.tsx +47 -0
  103. package/src/components/shared/__tests__/kbd.test.tsx +45 -0
  104. package/src/components/shared/__tests__/kind-badge.test.tsx +71 -0
  105. package/src/components/shared/__tests__/metrics-row.test.tsx +74 -0
  106. package/src/components/shared/__tests__/outcome-banner.test.tsx +71 -0
  107. package/src/components/shared/__tests__/progress-bar.test.tsx +89 -0
  108. package/src/components/shared/__tests__/session-pill.test.tsx +62 -0
  109. package/src/components/shared/__tests__/settings-modal.test.tsx +201 -0
  110. package/src/components/shared/__tests__/shortcuts-help.test.tsx +103 -0
  111. package/src/components/shared/__tests__/status-badge.test.tsx +98 -0
  112. package/src/components/shared/__tests__/theme-provider.test.tsx +100 -0
  113. package/src/components/shared/__tests__/truncated-id.test.tsx +53 -0
  114. package/src/components/shared/app-footer.tsx +80 -0
  115. package/src/components/shared/app-header.tsx +160 -0
  116. package/src/components/shared/empty-state.tsx +18 -0
  117. package/src/components/shared/error-boundary.tsx +81 -0
  118. package/src/components/shared/friendly-id.tsx +48 -0
  119. package/src/components/shared/kbd.tsx +15 -0
  120. package/src/components/shared/kind-badge.tsx +51 -0
  121. package/src/components/shared/metrics-row.tsx +106 -0
  122. package/src/components/shared/outcome-banner.tsx +56 -0
  123. package/src/components/shared/progress-bar.tsx +42 -0
  124. package/src/components/shared/session-pill.tsx +69 -0
  125. package/src/components/shared/settings-modal.tsx +509 -0
  126. package/src/components/shared/shortcuts-help.tsx +113 -0
  127. package/src/components/shared/status-badge.tsx +110 -0
  128. package/src/components/shared/theme-provider.tsx +46 -0
  129. package/src/components/shared/truncated-id.tsx +51 -0
  130. package/src/components/ui/.gitkeep +0 -0
  131. package/src/components/ui/__tests__/accordion.test.tsx +96 -0
  132. package/src/components/ui/__tests__/badge.test.tsx +69 -0
  133. package/src/components/ui/__tests__/button.test.tsx +113 -0
  134. package/src/components/ui/__tests__/tabs.test.tsx +75 -0
  135. package/src/components/ui/__tests__/tooltip.test.tsx +90 -0
  136. package/src/components/ui/accordion.tsx +61 -0
  137. package/src/components/ui/badge.tsx +25 -0
  138. package/src/components/ui/button.tsx +40 -0
  139. package/src/components/ui/card.tsx +21 -0
  140. package/src/components/ui/scroll-area.tsx +35 -0
  141. package/src/components/ui/separator.tsx +24 -0
  142. package/src/components/ui/tabs.tsx +64 -0
  143. package/src/components/ui/tooltip.tsx +37 -0
  144. package/src/hooks/.gitkeep +0 -0
  145. package/src/hooks/__tests__/use-animated-number.test.ts +184 -0
  146. package/src/hooks/__tests__/use-batched-updates.test.ts +315 -0
  147. package/src/hooks/__tests__/use-event-stream.test.ts +243 -0
  148. package/src/hooks/__tests__/use-keyboard.test.ts +217 -0
  149. package/src/hooks/__tests__/use-notifications.test.ts +230 -0
  150. package/src/hooks/__tests__/use-polling.test.ts +274 -0
  151. package/src/hooks/__tests__/use-project-runs.test.ts +163 -0
  152. package/src/hooks/__tests__/use-projects.test.ts +248 -0
  153. package/src/hooks/__tests__/use-run-dashboard.test.ts +168 -0
  154. package/src/hooks/__tests__/use-run-detail.test.ts +273 -0
  155. package/src/hooks/__tests__/use-smart-polling.test.ts +305 -0
  156. package/src/hooks/use-animated-number.ts +87 -0
  157. package/src/hooks/use-batched-updates.ts +150 -0
  158. package/src/hooks/use-event-stream.ts +150 -0
  159. package/src/hooks/use-keyboard.ts +45 -0
  160. package/src/hooks/use-notifications.ts +82 -0
  161. package/src/hooks/use-persisted-state.ts +60 -0
  162. package/src/hooks/use-polling.ts +60 -0
  163. package/src/hooks/use-project-runs.ts +51 -0
  164. package/src/hooks/use-projects.ts +26 -0
  165. package/src/hooks/use-run-dashboard.ts +207 -0
  166. package/src/hooks/use-run-detail.ts +77 -0
  167. package/src/hooks/use-smart-polling.ts +144 -0
  168. package/src/lib/.gitkeep +0 -0
  169. package/src/lib/__tests__/cn.test.ts +69 -0
  170. package/src/lib/__tests__/config-loader.test.ts +210 -0
  171. package/src/lib/__tests__/config.test.ts +561 -0
  172. package/src/lib/__tests__/error-handler.test.ts +143 -0
  173. package/src/lib/__tests__/fetcher.test.ts +517 -0
  174. package/src/lib/__tests__/global-registry.test.ts +214 -0
  175. package/src/lib/__tests__/parser.test.ts +1532 -0
  176. package/src/lib/__tests__/path-resolver.test.ts +112 -0
  177. package/src/lib/__tests__/run-cache.test.ts +591 -0
  178. package/src/lib/__tests__/server-init.test.ts +512 -0
  179. package/src/lib/__tests__/source-discovery.test.ts +246 -0
  180. package/src/lib/__tests__/utils.test.ts +160 -0
  181. package/src/lib/__tests__/watcher.test.ts +227 -0
  182. package/src/lib/cn.ts +6 -0
  183. package/src/lib/config-loader.ts +195 -0
  184. package/src/lib/config.ts +20 -0
  185. package/src/lib/error-handler.ts +76 -0
  186. package/src/lib/fetcher.ts +394 -0
  187. package/src/lib/global-registry.ts +117 -0
  188. package/src/lib/parser.ts +794 -0
  189. package/src/lib/path-resolver.ts +16 -0
  190. package/src/lib/run-cache.ts +404 -0
  191. package/src/lib/server-init.ts +226 -0
  192. package/src/lib/services/__tests__/run-query-service.test.ts +819 -0
  193. package/src/lib/services/run-query-service.ts +286 -0
  194. package/src/lib/source-discovery.ts +216 -0
  195. package/src/lib/utils.ts +103 -0
  196. package/src/lib/watcher.ts +265 -0
  197. package/src/test/fixtures.ts +269 -0
  198. package/src/test/mocks/handlers.ts +110 -0
  199. package/src/test/mocks/server.ts +17 -0
  200. package/src/test/setup.ts +200 -0
  201. package/src/test/test-utils.tsx +36 -0
  202. package/src/types/.gitkeep +0 -0
  203. package/src/types/breakpoint.ts +17 -0
  204. package/src/types/index.ts +214 -0
  205. package/tsconfig.json +50 -0
@@ -0,0 +1,195 @@
1
+ import { promises as fs } from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+
5
+ /** Return true when err represents a "file/directory not found" filesystem error. */
6
+ export function isNotFoundError(err: unknown): boolean {
7
+ if (!(err instanceof Error)) return false;
8
+ const code = (err as NodeJS.ErrnoException).code;
9
+ return code === "ENOENT" || code === "ENOTDIR" || err.message.includes("ENOENT");
10
+ }
11
+
12
+ export interface WatchSource {
13
+ path: string;
14
+ depth: number; // how many levels deep to search for .a5c/runs/
15
+ label?: string;
16
+ }
17
+
18
+ export interface ObserverConfig {
19
+ sources: WatchSource[];
20
+ port: number;
21
+ pollInterval: number;
22
+ theme: "dark" | "light";
23
+ staleThresholdMs: number;
24
+ recentCompletionWindowMs: number;
25
+ retentionDays: number;
26
+ hiddenProjects: string[];
27
+ }
28
+
29
+ // Default registry path
30
+ const REGISTRY_PATH =
31
+ process.env.OBSERVER_REGISTRY ||
32
+ path.join(os.homedir(), ".a5c", "observer.json");
33
+
34
+ let cachedConfig: ObserverConfig | null = null;
35
+ let cacheTime = 0;
36
+ const CACHE_TTL = 10000; // 10s
37
+
38
+ // Invalidate the config cache (called after POST /api/config writes new values)
39
+ export function invalidateConfigCache(): void {
40
+ cachedConfig = null;
41
+ cacheTime = 0;
42
+ }
43
+
44
+ // Write config to the registry file (~/.a5c/observer.json)
45
+ export async function writeConfig(data: {
46
+ sources: WatchSource[];
47
+ pollInterval?: number;
48
+ theme?: string;
49
+ staleThresholdMs?: number;
50
+ recentCompletionWindowMs?: number;
51
+ retentionDays?: number;
52
+ hiddenProjects?: string[];
53
+ }): Promise<void> {
54
+ const dir = path.dirname(REGISTRY_PATH);
55
+ await fs.mkdir(dir, { recursive: true });
56
+
57
+ // Read existing file to preserve any extra fields
58
+ let existing: Record<string, unknown> = {};
59
+ try {
60
+ const content = await fs.readFile(REGISTRY_PATH, "utf-8");
61
+ existing = JSON.parse(content);
62
+ } catch (err) {
63
+ // Expected when writing config for the first time; warn if the file exists but is unreadable
64
+ if (!isNotFoundError(err)) {
65
+ console.warn(`[config] Failed to read existing config at ${REGISTRY_PATH} before merge:`, err);
66
+ }
67
+ }
68
+
69
+ const merged = {
70
+ ...existing,
71
+ sources: data.sources,
72
+ ...(data.pollInterval !== undefined ? { pollInterval: data.pollInterval } : {}),
73
+ ...(data.theme !== undefined ? { theme: data.theme } : {}),
74
+ ...(data.staleThresholdMs !== undefined ? { staleThresholdMs: data.staleThresholdMs } : {}),
75
+ ...(data.recentCompletionWindowMs !== undefined ? { recentCompletionWindowMs: data.recentCompletionWindowMs } : {}),
76
+ ...(data.retentionDays !== undefined ? { retentionDays: data.retentionDays } : {}),
77
+ ...(data.hiddenProjects !== undefined ? { hiddenProjects: data.hiddenProjects } : {}),
78
+ };
79
+
80
+ await fs.writeFile(REGISTRY_PATH, JSON.stringify(merged, null, 2) + "\n", "utf-8");
81
+ }
82
+
83
+ interface RegistryData {
84
+ sources: WatchSource[];
85
+ pollInterval?: number;
86
+ theme?: "dark" | "light";
87
+ staleThresholdMs?: number;
88
+ recentCompletionWindowMs?: number;
89
+ retentionDays?: number;
90
+ hiddenProjects?: string[];
91
+ }
92
+
93
+ async function loadRegistry(): Promise<RegistryData> {
94
+ try {
95
+ const content = await fs.readFile(REGISTRY_PATH, "utf-8");
96
+ const parsed = JSON.parse(content);
97
+ const sources = Array.isArray(parsed.sources)
98
+ ? parsed.sources.map((s: Record<string, unknown>) => ({
99
+ path: String(s.path || ""),
100
+ depth: typeof s.depth === "number" ? s.depth : 2,
101
+ label: s.label ? String(s.label) : undefined,
102
+ }))
103
+ : [];
104
+ return {
105
+ sources,
106
+ pollInterval: typeof parsed.pollInterval === "number" ? parsed.pollInterval : undefined,
107
+ theme: parsed.theme === "dark" || parsed.theme === "light" ? parsed.theme : undefined,
108
+ staleThresholdMs: typeof parsed.staleThresholdMs === "number" ? parsed.staleThresholdMs : undefined,
109
+ recentCompletionWindowMs: typeof parsed.recentCompletionWindowMs === "number" ? parsed.recentCompletionWindowMs : undefined,
110
+ retentionDays: typeof parsed.retentionDays === "number" ? parsed.retentionDays : undefined,
111
+ hiddenProjects: Array.isArray(parsed.hiddenProjects) ? parsed.hiddenProjects.filter((s: unknown) => typeof s === "string") : undefined,
112
+ };
113
+ } catch (err) {
114
+ if (!isNotFoundError(err)) {
115
+ console.warn(`[config] Failed to load registry from ${REGISTRY_PATH} — using defaults:`, err);
116
+ }
117
+ return { sources: [] };
118
+ }
119
+ }
120
+
121
+ function getDefaultSources(): WatchSource[] {
122
+ const sources: WatchSource[] = [];
123
+
124
+ // CLI flag via OBSERVER_WATCH_DIR (set by src/cli.ts — defaults to user's cwd)
125
+ if (process.env.OBSERVER_WATCH_DIR) {
126
+ sources.push({ path: process.env.OBSERVER_WATCH_DIR, depth: 3, label: "cli" });
127
+ }
128
+
129
+ // WATCH_DIR env (backwards-compatible single dir)
130
+ if (process.env.WATCH_DIR) {
131
+ sources.push({ path: process.env.WATCH_DIR, depth: 0, label: "env" });
132
+ }
133
+
134
+ // WATCH_DIRS env (comma-separated)
135
+ if (process.env.WATCH_DIRS) {
136
+ for (const dir of process.env.WATCH_DIRS.split(",")) {
137
+ const trimmed = dir.trim();
138
+ if (trimmed) sources.push({ path: trimmed, depth: 2 });
139
+ }
140
+ }
141
+
142
+ // Default: parent of cwd — users typically run from inside a project dir
143
+ // but want to observe ALL sibling projects in the parent folder
144
+ if (sources.length === 0) {
145
+ sources.push({
146
+ path: path.resolve(process.cwd(), ".."),
147
+ depth: 3,
148
+ label: "parent",
149
+ });
150
+ }
151
+
152
+ return sources;
153
+ }
154
+
155
+ export async function getConfig(): Promise<ObserverConfig> {
156
+ const now = Date.now();
157
+ if (cachedConfig && now - cacheTime < CACHE_TTL) {
158
+ return cachedConfig;
159
+ }
160
+
161
+ const registry = await loadRegistry();
162
+ const defaultSources = getDefaultSources();
163
+
164
+ // Merge: registry sources take priority, defaults as fallback
165
+ // Deduplicate sources by normalized path to prevent duplicate discovery
166
+ const rawSources = registry.sources.length > 0 ? registry.sources : defaultSources;
167
+ const seen = new Set<string>();
168
+ const sources = rawSources.filter((s) => {
169
+ const normalized = path.resolve(s.path);
170
+ if (seen.has(normalized)) return false;
171
+ seen.add(normalized);
172
+ return true;
173
+ });
174
+
175
+ // Priority: registry file > env vars > defaults
176
+ const envPollInterval = process.env.OBSERVER_POLL_INTERVAL || process.env.POLL_INTERVAL;
177
+ const envTheme = process.env.OBSERVER_DEFAULT_THEME || process.env.THEME;
178
+ const envStaleThreshold = process.env.OBSERVER_STALE_THRESHOLD_MS;
179
+ const envRecentWindow = process.env.OBSERVER_RECENT_WINDOW_MS;
180
+ const envRetentionDays = process.env.OBSERVER_RETENTION_DAYS;
181
+
182
+ cachedConfig = {
183
+ sources,
184
+ port: parseInt(process.env.OBSERVER_PORT || process.env.PORT || "4800", 10),
185
+ pollInterval: registry.pollInterval ?? (envPollInterval ? parseInt(envPollInterval, 10) : 2000),
186
+ theme: registry.theme ?? ((envTheme === "dark" || envTheme === "light" ? envTheme : "dark") as "dark" | "light"),
187
+ staleThresholdMs: registry.staleThresholdMs ?? (envStaleThreshold ? parseInt(envStaleThreshold, 10) : 3600000),
188
+ recentCompletionWindowMs: registry.recentCompletionWindowMs ?? (envRecentWindow ? parseInt(envRecentWindow, 10) : 14400000),
189
+ retentionDays: registry.retentionDays ?? (envRetentionDays ? parseInt(envRetentionDays, 10) : 30),
190
+ hiddenProjects: registry.hiddenProjects ?? [],
191
+ };
192
+ cacheTime = now;
193
+
194
+ return cachedConfig;
195
+ }
@@ -0,0 +1,20 @@
1
+ // Thin re-export facade — preserves backward compatibility for all existing
2
+ // import sites while the actual logic lives in focused modules.
3
+
4
+ export {
5
+ isNotFoundError,
6
+ type WatchSource,
7
+ type ObserverConfig,
8
+ invalidateConfigCache,
9
+ writeConfig,
10
+ getConfig,
11
+ } from "./config-loader";
12
+
13
+ export {
14
+ type DiscoveredRun,
15
+ invalidateDiscoveryCache,
16
+ discoverAllRunDirs,
17
+ discoverAllRunsParentDirs,
18
+ } from "./source-discovery";
19
+
20
+ export { findRunDir } from "./path-resolver";
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Centralized error handling utilities.
3
+ *
4
+ * - `AppError` – typed error with HTTP status and machine-readable code.
5
+ * - `normalizeError` – converts any thrown value (Error, string, object,
6
+ * undefined, etc.) into a consistent `{ message, code, status }` shape
7
+ * safe for API responses (never leaks stack traces).
8
+ */
9
+
10
+ export class AppError extends Error {
11
+ readonly code: string;
12
+ readonly status: number;
13
+
14
+ constructor(message: string, code: string, status: number) {
15
+ super(message);
16
+ this.name = "AppError";
17
+ this.code = code;
18
+ this.status = status;
19
+
20
+ // Fix prototype chain for instanceof checks (TS class extending Error)
21
+ Object.setPrototypeOf(this, AppError.prototype);
22
+ }
23
+ }
24
+
25
+ export interface NormalizedError {
26
+ message: string;
27
+ code: string;
28
+ status: number;
29
+ }
30
+
31
+ /**
32
+ * Convert any thrown value into a consistent error shape.
33
+ *
34
+ * - `AppError` → preserves code & status.
35
+ * - Standard `Error` → maps common system errors (ENOENT, EACCES, ENOTDIR)
36
+ * to appropriate HTTP status codes; defaults to 500.
37
+ * - `string` → wraps as 500 internal error.
38
+ * - Anything else (null, undefined, number, object) → generic 500.
39
+ */
40
+ export function normalizeError(err: unknown): NormalizedError {
41
+ // Already a well-typed AppError
42
+ if (err instanceof AppError) {
43
+ return { message: err.message, code: err.code, status: err.status };
44
+ }
45
+
46
+ // Standard Error (or subclass)
47
+ if (err instanceof Error) {
48
+ const nodeErr = err as NodeJS.ErrnoException;
49
+
50
+ // Map common Node.js filesystem error codes
51
+ if (nodeErr.code === "ENOENT") {
52
+ return { message: "Resource not found", code: "NOT_FOUND", status: 404 };
53
+ }
54
+ if (nodeErr.code === "EACCES" || nodeErr.code === "EPERM") {
55
+ return { message: "Permission denied", code: "PERMISSION_DENIED", status: 403 };
56
+ }
57
+ if (nodeErr.code === "ENOTDIR" || nodeErr.code === "EISDIR") {
58
+ return { message: "Invalid resource path", code: "INVALID_PATH", status: 400 };
59
+ }
60
+
61
+ // SyntaxError from JSON.parse
62
+ if (err instanceof SyntaxError) {
63
+ return { message: "Failed to parse data", code: "PARSE_ERROR", status: 400 };
64
+ }
65
+
66
+ return { message: err.message || "Internal server error", code: "INTERNAL_ERROR", status: 500 };
67
+ }
68
+
69
+ // Plain string throw
70
+ if (typeof err === "string") {
71
+ return { message: err, code: "INTERNAL_ERROR", status: 500 };
72
+ }
73
+
74
+ // Anything else (null, undefined, number, object without Error prototype)
75
+ return { message: "An unexpected error occurred", code: "UNKNOWN_ERROR", status: 500 };
76
+ }
@@ -0,0 +1,394 @@
1
+ // ---------------------------------------------------------------------------
2
+ // resilientFetch -- shared HTTP client with retry, timeout & abort support
3
+ // ---------------------------------------------------------------------------
4
+
5
+ /** Normalized error shape returned on failure. */
6
+ export interface FetchError {
7
+ /** HTTP status code, or 0 for network / timeout / abort errors. */
8
+ status: number;
9
+ /** Human-readable description of what went wrong. */
10
+ message: string;
11
+ /** Whether the request was (or could have been) retried. */
12
+ isRetryable: boolean;
13
+ /** Whether the request was cancelled via an AbortSignal. */
14
+ isAborted: boolean;
15
+ }
16
+
17
+ /** Options accepted by {@link resilientFetch}. */
18
+ export interface FetchOptions {
19
+ /** Optional external AbortSignal (e.g. from a hook cleanup). */
20
+ signal?: AbortSignal;
21
+ /** Maximum number of retry attempts for retryable errors (default 2). */
22
+ retries?: number;
23
+ /** Base delay in ms for exponential backoff (default 1000). */
24
+ retryDelay?: number;
25
+ /** Request timeout in ms (default 10000). */
26
+ timeout?: number;
27
+ /** HTTP method (default "GET"). */
28
+ method?: string;
29
+ /** Additional request headers. */
30
+ headers?: Record<string, string>;
31
+ /** Request body (stringified JSON, form data, etc.). */
32
+ body?: string;
33
+ }
34
+
35
+ /** Discriminated union representing a successful or failed fetch. */
36
+ export type FetchResult<T> =
37
+ | { ok: true; data: T; status: number }
38
+ | { ok: false; error: FetchError };
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Helpers
42
+ // ---------------------------------------------------------------------------
43
+
44
+ function isRetryableStatus(status: number): boolean {
45
+ // 5xx server errors are always retryable.
46
+ // 404 is retryable because Next.js dev server returns transient 404s
47
+ // during HMR recompilation when API route handlers are momentarily unavailable.
48
+ return status >= 500 || status === 404;
49
+ }
50
+
51
+ function isAbortError(err: unknown): boolean {
52
+ return err instanceof DOMException && err.name === "AbortError";
53
+ }
54
+
55
+ /**
56
+ * Detect if response text is HTML rather than JSON.
57
+ * Next.js dev server can return various HTML fragments during HMR:
58
+ * - Full HTML pages (<!DOCTYPE html>, <html>)
59
+ * - Error fragments (<pre>missing required error components...</pre>)
60
+ * - Script-only recovery pages (<script>...)
61
+ */
62
+ function looksLikeHtml(text: string): boolean {
63
+ const trimmed = text.trimStart();
64
+ return (
65
+ trimmed.startsWith("<!DOCTYPE") ||
66
+ trimmed.startsWith("<html") ||
67
+ trimmed.startsWith("<pre") ||
68
+ trimmed.startsWith("<script") ||
69
+ trimmed.startsWith("<div") ||
70
+ trimmed.startsWith("<head")
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Sleep for `ms` milliseconds. Resolves early (with rejection) when the
76
+ * provided signal is aborted so we don't keep waiting between retries after
77
+ * the caller has cancelled the request.
78
+ */
79
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
80
+ return new Promise<void>((resolve, reject) => {
81
+ if (signal?.aborted) {
82
+ reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
83
+ return;
84
+ }
85
+
86
+ const timer = setTimeout(() => {
87
+ signal?.removeEventListener("abort", onAbort);
88
+ resolve();
89
+ }, ms);
90
+
91
+ const onAbort = () => {
92
+ clearTimeout(timer);
93
+ reject(signal!.reason ?? new DOMException("Aborted", "AbortError"));
94
+ };
95
+
96
+ signal?.addEventListener("abort", onAbort, { once: true });
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Create a merged AbortSignal that fires when *either* the timeout elapses
102
+ * **or** the external signal is aborted. Returns the merged signal together
103
+ * with a cleanup function that **must** be called after every attempt to
104
+ * prevent timer leaks.
105
+ */
106
+ function createMergedSignal(
107
+ timeout: number,
108
+ externalSignal?: AbortSignal,
109
+ ): { signal: AbortSignal; cleanup: () => void } {
110
+ const controller = new AbortController();
111
+ const timer: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
112
+ controller.abort(new DOMException("Request timed out", "TimeoutError"));
113
+ }, timeout);
114
+
115
+ const onExternalAbort = () => {
116
+ clearTimeout(timer);
117
+ controller.abort(
118
+ externalSignal!.reason ??
119
+ new DOMException("Aborted", "AbortError"),
120
+ );
121
+ };
122
+
123
+ if (externalSignal?.aborted) {
124
+ clearTimeout(timer);
125
+ controller.abort(
126
+ externalSignal.reason ??
127
+ new DOMException("Aborted", "AbortError"),
128
+ );
129
+ } else {
130
+ externalSignal?.addEventListener("abort", onExternalAbort, { once: true });
131
+ }
132
+
133
+ const cleanup = () => {
134
+ clearTimeout(timer);
135
+ externalSignal?.removeEventListener("abort", onExternalAbort);
136
+ };
137
+
138
+ return { signal: controller.signal, cleanup };
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // ETag cache — stores last ETag + response body per URL for 304 handling
143
+ // ---------------------------------------------------------------------------
144
+
145
+ interface ETagCacheEntry<T = unknown> {
146
+ etag: string;
147
+ data: T;
148
+ }
149
+
150
+ const etagCache = new Map<string, ETagCacheEntry>();
151
+
152
+ // Limit ETag cache size to avoid unbounded growth
153
+ const MAX_ETAG_CACHE_SIZE = 100;
154
+
155
+ function pruneEtagCache(): void {
156
+ if (etagCache.size <= MAX_ETAG_CACHE_SIZE) return;
157
+ // Delete the oldest entries (first inserted)
158
+ const keysToDelete = Array.from(etagCache.keys()).slice(0, etagCache.size - MAX_ETAG_CACHE_SIZE);
159
+ for (const key of keysToDelete) {
160
+ etagCache.delete(key);
161
+ }
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Main export
166
+ // ---------------------------------------------------------------------------
167
+
168
+ /**
169
+ * Perform an HTTP fetch with built-in **retry**, **timeout**, **abort**,
170
+ * and **ETag** support. Returns a discriminated-union result so callers
171
+ * never need to catch exceptions.
172
+ *
173
+ * ETag behaviour:
174
+ * - On successful responses with an ETag header, the response data and ETag
175
+ * are cached. On subsequent requests to the same URL, an `If-None-Match`
176
+ * header is sent. If the server returns 304, the cached data is returned
177
+ * without re-parsing JSON, saving bandwidth and CPU.
178
+ *
179
+ * Retry behaviour:
180
+ * - Network errors and 5xx responses are retried up to `options.retries`
181
+ * times (default 2) with exponential backoff starting at
182
+ * `options.retryDelay` ms (default 1000).
183
+ * - 4xx (client) errors are **not** retried.
184
+ *
185
+ * Abort / timeout:
186
+ * - An internal AbortController enforces `options.timeout` (default 10 000 ms).
187
+ * - If `options.signal` is provided it is merged with the internal timeout
188
+ * signal; aborting either one cancels the in-flight request immediately.
189
+ *
190
+ * @example
191
+ * ```ts
192
+ * const result = await resilientFetch<Run[]>("/api/runs");
193
+ * if (result.ok) {
194
+ * console.log(result.data);
195
+ * } else {
196
+ * console.error(result.error.message);
197
+ * }
198
+ * ```
199
+ */
200
+ export async function resilientFetch<T>(
201
+ url: string,
202
+ options: FetchOptions = {},
203
+ ): Promise<FetchResult<T>> {
204
+ const {
205
+ signal: externalSignal,
206
+ retries = 2,
207
+ retryDelay = 1000,
208
+ timeout = 10_000,
209
+ method = "GET",
210
+ headers: userHeaders,
211
+ body,
212
+ } = options;
213
+
214
+ // Build headers, injecting If-None-Match for ETag-based caching
215
+ const mergedHeaders: Record<string, string> = { ...userHeaders };
216
+ const cachedEntry = etagCache.get(url);
217
+ if (cachedEntry && method === "GET") {
218
+ mergedHeaders["If-None-Match"] = cachedEntry.etag;
219
+ }
220
+
221
+ let lastError: FetchError | undefined;
222
+
223
+ for (let attempt = 0; attempt <= retries; attempt++) {
224
+ // Create a per-attempt merged signal (timeout + external abort).
225
+ const { signal, cleanup } = createMergedSignal(timeout, externalSignal);
226
+
227
+ try {
228
+ const response = await fetch(url, {
229
+ method,
230
+ headers: mergedHeaders,
231
+ body,
232
+ signal,
233
+ });
234
+
235
+ // 304 Not Modified — return cached data without re-parsing
236
+ if (response.status === 304 && cachedEntry) {
237
+ cleanup();
238
+ return { ok: true, data: cachedEntry.data as T, status: 304 };
239
+ }
240
+
241
+ if (response.ok) {
242
+ // Guard against HTML responses served with 200 status (e.g. Next.js
243
+ // serving a page shell during HMR when the API route is recompiling).
244
+ const contentType = response.headers.get("Content-Type") || "";
245
+ if (!contentType.includes("application/json") && contentType.includes("text/html")) {
246
+ cleanup();
247
+ // Treat as retryable — the API route will be back after recompilation.
248
+ lastError = {
249
+ status: response.status,
250
+ message: "Server temporarily unavailable (recompiling)",
251
+ isRetryable: true,
252
+ isAborted: false,
253
+ };
254
+ if (attempt < retries) {
255
+ const delay = retryDelay * Math.pow(2, attempt);
256
+ await sleep(delay, externalSignal);
257
+ }
258
+ continue;
259
+ }
260
+
261
+ let data: T;
262
+ try {
263
+ data = (await response.json()) as T;
264
+ } catch {
265
+ // JSON parse failed — likely an HTML response that slipped past the
266
+ // content-type check (e.g. "missing required error components").
267
+ // Treat as retryable since the API will recover after recompilation.
268
+ cleanup();
269
+ lastError = {
270
+ status: response.status,
271
+ message: "Server temporarily unavailable (recompiling)",
272
+ isRetryable: true,
273
+ isAborted: false,
274
+ };
275
+ if (attempt < retries) {
276
+ const delay = retryDelay * Math.pow(2, attempt);
277
+ try {
278
+ await sleep(delay, externalSignal);
279
+ } catch {
280
+ return { ok: false, error: { status: 0, message: "Request aborted", isRetryable: false, isAborted: true } };
281
+ }
282
+ }
283
+ continue;
284
+ }
285
+
286
+ // Cache the ETag and response data for future 304 handling
287
+ const etag = response.headers.get("ETag");
288
+ if (etag) {
289
+ etagCache.set(url, { etag, data });
290
+ pruneEtagCache();
291
+ }
292
+
293
+ cleanup();
294
+ return { ok: true, data, status: response.status };
295
+ }
296
+
297
+ // Non-OK response -- build an error object.
298
+ const retryable = isRetryableStatus(response.status);
299
+ let errorMessage: string;
300
+ try {
301
+ const text = await response.text();
302
+ // Detect HTML responses (e.g. Next.js 404 page or "missing required
303
+ // error components" during HMR) and replace with a clean message.
304
+ if (text && looksLikeHtml(text)) {
305
+ errorMessage = `Server temporarily unavailable (HTTP ${response.status})`;
306
+ } else {
307
+ errorMessage = text || `HTTP ${response.status}`;
308
+ }
309
+ } catch {
310
+ errorMessage = `HTTP ${response.status}`;
311
+ }
312
+
313
+ cleanup();
314
+
315
+ lastError = {
316
+ status: response.status,
317
+ message: errorMessage,
318
+ isRetryable: retryable,
319
+ isAborted: false,
320
+ };
321
+
322
+ // 4xx errors are not retried -- return immediately.
323
+ if (!retryable) {
324
+ return { ok: false, error: lastError };
325
+ }
326
+
327
+ // If this was the final attempt, don't sleep -- fall through to return.
328
+ if (attempt < retries) {
329
+ const delay = retryDelay * Math.pow(2, attempt);
330
+ await sleep(delay, externalSignal);
331
+ }
332
+ } catch (err: unknown) {
333
+ cleanup();
334
+
335
+ // Determine if this was a timeout by inspecting the merged signal's
336
+ // abort reason. We set a DOMException with name "TimeoutError" as
337
+ // the reason in createMergedSignal, so we can reliably distinguish
338
+ // timeout from user-initiated abort regardless of how the environment
339
+ // surfaces the thrown error.
340
+ if (isAbortError(err) || (err instanceof DOMException && err.name === "TimeoutError")) {
341
+ const reason = signal.reason;
342
+ const isTimeout =
343
+ reason instanceof DOMException && reason.name === "TimeoutError";
344
+
345
+ return {
346
+ ok: false,
347
+ error: {
348
+ status: 0,
349
+ message: isTimeout ? "Request timed out" : "Request aborted",
350
+ isRetryable: false,
351
+ isAborted: !isTimeout,
352
+ },
353
+ };
354
+ }
355
+
356
+ // Network error -- retryable.
357
+ lastError = {
358
+ status: 0,
359
+ message: err instanceof Error ? err.message : "Network error",
360
+ isRetryable: true,
361
+ isAborted: false,
362
+ };
363
+
364
+ if (attempt < retries) {
365
+ try {
366
+ const delay = retryDelay * Math.pow(2, attempt);
367
+ await sleep(delay, externalSignal);
368
+ } catch {
369
+ // Sleep was aborted -- the caller cancelled.
370
+ return {
371
+ ok: false,
372
+ error: {
373
+ status: 0,
374
+ message: "Request aborted",
375
+ isRetryable: false,
376
+ isAborted: true,
377
+ },
378
+ };
379
+ }
380
+ }
381
+ }
382
+ }
383
+
384
+ // All retries exhausted.
385
+ return {
386
+ ok: false,
387
+ error: lastError ?? {
388
+ status: 0,
389
+ message: "Unknown error",
390
+ isRetryable: false,
391
+ isAborted: false,
392
+ },
393
+ };
394
+ }