@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,84 @@
1
+ import { NextResponse } from "next/server";
2
+ import { findRunDir } from "@/lib/path-resolver";
3
+ import { ensureInitialized } from "@/lib/server-init";
4
+ import { getRunCached } from "@/lib/run-cache";
5
+ import { normalizeError } from "@/lib/error-handler";
6
+ import { createHash } from "crypto";
7
+
8
+ export const dynamic = "force-dynamic";
9
+
10
+ function isValidId(id: string): boolean {
11
+ return /^[a-zA-Z0-9_\-]+$/.test(id);
12
+ }
13
+
14
+ const DEFAULT_MAX_EVENTS = 50;
15
+
16
+ /**
17
+ * Generate a lightweight ETag from run state.
18
+ * Uses status, task count, event count, and updatedAt as a fingerprint
19
+ * to avoid re-serializing unchanged data to the client.
20
+ */
21
+ function generateETag(run: { status: string; updatedAt: string; tasks: unknown[]; events: unknown[] }): string {
22
+ const fingerprint = `${run.status}:${run.tasks.length}:${run.events.length}:${run.updatedAt}`;
23
+ const hash = createHash("md5").update(fingerprint).digest("hex").slice(0, 16);
24
+ return `"${hash}"`;
25
+ }
26
+
27
+ export async function GET(
28
+ request: Request,
29
+ { params }: { params: { runId: string } }
30
+ ) {
31
+ try {
32
+ // Ensure watcher and cache are initialized
33
+ await ensureInitialized();
34
+
35
+ const { runId } = params;
36
+ if (!isValidId(runId)) {
37
+ return NextResponse.json({ error: "Invalid run ID" }, { status: 400 });
38
+ }
39
+
40
+ const found = await findRunDir(runId);
41
+ if (!found) {
42
+ return NextResponse.json({ error: "Run not found" }, { status: 404 });
43
+ }
44
+
45
+ // Use cached run for better performance
46
+ const run = await getRunCached(found.runDir, found.source, found.projectName);
47
+
48
+ // Limit events returned (keep most recent) to reduce payload size
49
+ const { searchParams } = new URL(request.url);
50
+ const maxEvents = parseInt(searchParams.get("maxEvents") || String(DEFAULT_MAX_EVENTS));
51
+ const totalEvents = run.events.length;
52
+ const limitedRun = totalEvents > maxEvents
53
+ ? { ...run, events: run.events.slice(-maxEvents), totalEvents }
54
+ : { ...run, totalEvents };
55
+
56
+ // ETag support: if client sends If-None-Match and data hasn't changed,
57
+ // return 304 Not Modified to save bandwidth and serialization cost.
58
+ const etag = generateETag(limitedRun);
59
+ const ifNoneMatch = request.headers.get("If-None-Match");
60
+ if (ifNoneMatch && ifNoneMatch === etag) {
61
+ return new NextResponse(null, {
62
+ status: 304,
63
+ headers: {
64
+ ETag: etag,
65
+ "Cache-Control": "no-cache",
66
+ },
67
+ });
68
+ }
69
+
70
+ return NextResponse.json({ run: limitedRun }, {
71
+ headers: {
72
+ "Cache-Control": "no-cache",
73
+ ETag: etag,
74
+ },
75
+ });
76
+ } catch (error) {
77
+ console.error("Failed to read run:", error);
78
+ const normalized = normalizeError(error);
79
+ return NextResponse.json(
80
+ { error: normalized.message, code: normalized.code },
81
+ { status: normalized.status }
82
+ );
83
+ }
84
+ }
@@ -0,0 +1,44 @@
1
+ import { NextResponse } from "next/server";
2
+ import { findRunDir } from "@/lib/path-resolver";
3
+ import { parseTaskDetail } from "@/lib/parser";
4
+ import { normalizeError } from "@/lib/error-handler";
5
+
6
+ export const dynamic = "force-dynamic";
7
+
8
+ function isValidId(id: string): boolean {
9
+ return /^[a-zA-Z0-9_\-]+$/.test(id);
10
+ }
11
+
12
+ export async function GET(
13
+ _request: Request,
14
+ { params }: { params: { runId: string; effectId: string } }
15
+ ) {
16
+ try {
17
+ const { runId, effectId } = params;
18
+ if (!isValidId(runId) || !isValidId(effectId)) {
19
+ return NextResponse.json({ error: "Invalid ID" }, { status: 400 });
20
+ }
21
+
22
+ const found = await findRunDir(runId);
23
+ if (!found) {
24
+ return NextResponse.json({ error: "Run not found" }, { status: 404 });
25
+ }
26
+
27
+ const task = await parseTaskDetail(found.runDir, effectId);
28
+
29
+ if (!task) {
30
+ return NextResponse.json({ error: "Task not found" }, { status: 404 });
31
+ }
32
+
33
+ return NextResponse.json({ task }, {
34
+ headers: { "Cache-Control": "no-cache, no-store" },
35
+ });
36
+ } catch (error) {
37
+ console.error("Failed to read task:", error);
38
+ const normalized = normalizeError(error);
39
+ return NextResponse.json(
40
+ { error: normalized.message, code: normalized.code },
41
+ { status: normalized.status }
42
+ );
43
+ }
44
+ }
@@ -0,0 +1,48 @@
1
+ import { NextResponse } from "next/server";
2
+ import { ensureInitialized } from "@/lib/server-init";
3
+ import { normalizeError } from "@/lib/error-handler";
4
+ import { RunQueryService, type SortMode } from "@/lib/services/run-query-service";
5
+
6
+ export const dynamic = "force-dynamic";
7
+
8
+ const NO_CACHE_HEADERS = { "Cache-Control": "no-cache, no-store" };
9
+
10
+ const service = new RunQueryService();
11
+
12
+ export async function GET(request: Request) {
13
+ try {
14
+ await ensureInitialized();
15
+
16
+ const { searchParams } = new URL(request.url);
17
+ const mode = searchParams.get("mode");
18
+ const project = searchParams.get("project");
19
+ const limit = parseInt(searchParams.get("limit") || "0");
20
+ const offset = parseInt(searchParams.get("offset") || "0");
21
+ const search = searchParams.get("search") || "";
22
+ const status = searchParams.get("status") || "";
23
+ const sort = (searchParams.get("sort") || "status") as SortMode;
24
+
25
+ // Mode: projects - return lightweight project summaries
26
+ if (mode === "projects") {
27
+ const data = await service.listProjects();
28
+ return NextResponse.json(data, { headers: NO_CACHE_HEADERS });
29
+ }
30
+
31
+ // Mode: project - return paginated runs for a specific project
32
+ if (project) {
33
+ const data = await service.listProjectRuns({ project, limit, offset, search, status, sort });
34
+ return NextResponse.json(data, { headers: NO_CACHE_HEADERS });
35
+ }
36
+
37
+ // Default: return all runs with totalCount
38
+ const data = await service.listAllRuns({ limit, offset, search, status, sort });
39
+ return NextResponse.json(data, { headers: NO_CACHE_HEADERS });
40
+ } catch (error) {
41
+ console.error("Failed to read runs:", error);
42
+ const normalized = normalizeError(error);
43
+ return NextResponse.json(
44
+ { error: normalized.message, code: normalized.code },
45
+ { status: normalized.status }
46
+ );
47
+ }
48
+ }
@@ -0,0 +1,136 @@
1
+ import path from "path";
2
+ import { ensureInitialized, serverEvents, type BatchedRunChangedEvent } from "@/lib/server-init";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ // Extract runId from a runDir path (last segment of the directory)
7
+ function extractRunId(runDir: string): string {
8
+ return path.basename(runDir);
9
+ }
10
+
11
+ export async function GET() {
12
+ try {
13
+ // Ensure watcher and cache are initialized
14
+ await ensureInitialized();
15
+
16
+ // Track cleanup via closure so cancel() can access it
17
+ let cleanup: (() => void) | null = null;
18
+
19
+ const stream = new ReadableStream({
20
+ start(controller) {
21
+ const encoder = new TextEncoder();
22
+
23
+ // Send initial connection message
24
+ controller.enqueue(
25
+ encoder.encode(
26
+ `data: ${JSON.stringify({ type: "connected", timestamp: new Date().toISOString() })}\n\n`
27
+ )
28
+ );
29
+
30
+ // Listen for run-changed events (batched by leading-edge debounce).
31
+ // Each event contains runIds[] and runDirs[] for targeted client refresh.
32
+ const runChangedListener = (event: BatchedRunChangedEvent) => {
33
+ try {
34
+ const message = {
35
+ type: "update",
36
+ runIds: event.runIds,
37
+ // Keep singular runId for backward compatibility (first in batch)
38
+ runId: event.runIds[0],
39
+ timestamp: new Date().toISOString(),
40
+ };
41
+ controller.enqueue(
42
+ encoder.encode(`data: ${JSON.stringify(message)}\n\n`)
43
+ );
44
+ } catch (err) {
45
+ console.error("Failed to send run-changed event:", err);
46
+ }
47
+ };
48
+
49
+ // Listen for new-run events
50
+ const newRunListener = (event: {
51
+ type: string;
52
+ runDir: string;
53
+ error?: Error;
54
+ }) => {
55
+ try {
56
+ const runId = extractRunId(event.runDir);
57
+ const message = {
58
+ type: "new-run",
59
+ runId,
60
+ runDir: event.runDir,
61
+ timestamp: new Date().toISOString(),
62
+ };
63
+ controller.enqueue(
64
+ encoder.encode(`data: ${JSON.stringify(message)}\n\n`)
65
+ );
66
+ } catch (err) {
67
+ console.error("Failed to send new-run event:", err);
68
+ }
69
+ };
70
+
71
+ // Listen for watcher-error events (deduplicated in server-init)
72
+ // These are logged server-side but NOT forwarded as SSE data events
73
+ // to prevent transient filesystem errors from triggering client-side
74
+ // status flashes. The client will self-heal via normal polling.
75
+ const errorListener = (event: {
76
+ type: string;
77
+ runDir: string;
78
+ error?: Error;
79
+ }) => {
80
+ // Log server-side only; do not push to client SSE stream
81
+ console.warn(
82
+ "Watcher error (suppressed from SSE):",
83
+ event.error?.message ?? "unknown",
84
+ event.runDir
85
+ );
86
+ };
87
+
88
+ serverEvents.on("run-changed", runChangedListener);
89
+ serverEvents.on("new-run", newRunListener);
90
+ serverEvents.on("watcher-error", errorListener);
91
+
92
+ // Keep-alive ping every 15 seconds
93
+ const pingInterval = setInterval(() => {
94
+ try {
95
+ controller.enqueue(encoder.encode(": ping\n\n"));
96
+ } catch (err) {
97
+ console.error("Failed to send ping:", err);
98
+ clearInterval(pingInterval);
99
+ }
100
+ }, 15000);
101
+
102
+ // Store cleanup via closure (accessible from cancel)
103
+ cleanup = () => {
104
+ clearInterval(pingInterval);
105
+ serverEvents.off("run-changed", runChangedListener);
106
+ serverEvents.off("new-run", newRunListener);
107
+ serverEvents.off("watcher-error", errorListener);
108
+ };
109
+ },
110
+ cancel() {
111
+ if (cleanup) {
112
+ cleanup();
113
+ cleanup = null;
114
+ }
115
+ },
116
+ });
117
+
118
+ return new Response(stream, {
119
+ headers: {
120
+ "Content-Type": "text/event-stream",
121
+ "Cache-Control": "no-cache, no-store",
122
+ Connection: "keep-alive",
123
+ },
124
+ });
125
+ } catch (error) {
126
+ console.error("Failed to initialize SSE stream:", error);
127
+ return new Response(
128
+ JSON.stringify({ error: "Failed to initialize stream" }),
129
+ {
130
+ status: 500,
131
+ headers: { "Content-Type": "application/json" },
132
+ }
133
+ );
134
+ }
135
+ }
136
+
@@ -0,0 +1 @@
1
+ export async function GET() { return new Response("test", { status: 200 }); }
@@ -0,0 +1,57 @@
1
+ import { NextResponse } from "next/server";
2
+ import { execSync } from "child_process";
3
+ import { readFileSync } from "fs";
4
+ import { resolve } from "path";
5
+
6
+ export const dynamic = "force-dynamic";
7
+
8
+ function detectVersion(command: string): string {
9
+ try {
10
+ const raw = execSync(command, { encoding: "utf-8", timeout: 3000 }).trim();
11
+ const match = raw.match(/(\d+\.\d+\.\d+)/);
12
+ return match ? match[1] : raw || "N/A";
13
+ } catch {
14
+ return "N/A";
15
+ }
16
+ }
17
+
18
+ function getAppVersion(): string {
19
+ try {
20
+ const pkgPath = resolve(__dirname, "..", "..", "..", "..", "package.json");
21
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
22
+ return pkg.version || process.env.NEXT_PUBLIC_APP_VERSION || "unknown";
23
+ } catch {
24
+ return process.env.NEXT_PUBLIC_APP_VERSION || "unknown";
25
+ }
26
+ }
27
+
28
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes for normal polling
29
+ const IDLE_THRESHOLD_MS = 60 * 1000; // 1 minute gap = user was away
30
+
31
+ let cached: { app: string; babysitter: string } | null = null;
32
+ let cachedAt = 0;
33
+ let lastRequestAt = 0;
34
+
35
+ function isCacheStale(): boolean {
36
+ const now = Date.now();
37
+ const idleGap = now - lastRequestAt;
38
+ const cacheAge = now - cachedAt;
39
+
40
+ // User returned after being away — likely upgraded something
41
+ if (lastRequestAt > 0 && idleGap > IDLE_THRESHOLD_MS) return true;
42
+
43
+ // Normal TTL expiry
44
+ return cacheAge > CACHE_TTL_MS;
45
+ }
46
+
47
+ export async function GET() {
48
+ if (!cached || isCacheStale()) {
49
+ cached = {
50
+ app: getAppVersion(),
51
+ babysitter: detectVersion("babysitter --version"),
52
+ };
53
+ cachedAt = Date.now();
54
+ }
55
+ lastRequestAt = Date.now();
56
+ return NextResponse.json(cached);
57
+ }