blob: 11e388771475ea083a06ce4afcf8f47781b050c6 [file] [log] [blame]
Sean McCulloughd9f13372025-04-21 15:08:49 -07001import { AgentMessage } from "./types";
Earl Lee2e463fb2025-04-17 11:22:22 -07002import { formatNumber } from "./utils";
3
4/**
5 * Event types for data manager
6 */
Sean McCullough71941bd2025-04-18 13:31:48 -07007export type DataManagerEventType = "dataChanged" | "connectionStatusChanged";
Earl Lee2e463fb2025-04-17 11:22:22 -07008
9/**
10 * Connection status types
11 */
Sean McCullough71941bd2025-04-18 13:31:48 -070012export type ConnectionStatus = "connected" | "disconnected" | "disabled";
Earl Lee2e463fb2025-04-17 11:22:22 -070013
14/**
15 * State interface
16 */
17export interface TimelineState {
18 hostname?: string;
19 working_dir?: string;
20 initial_commit?: string;
21 message_count?: number;
22 title?: string;
23 total_usage?: {
24 input_tokens: number;
25 output_tokens: number;
26 cache_read_input_tokens: number;
27 cache_creation_input_tokens: number;
28 total_cost_usd: number;
29 };
Philip Zeyliger99a9a022025-04-27 15:15:25 +000030 outstanding_llm_calls?: number;
31 outstanding_tool_calls?: string[];
Earl Lee2e463fb2025-04-17 11:22:22 -070032}
33
34/**
35 * DataManager - Class to manage timeline data, fetching, and polling
36 */
37export class DataManager {
38 // State variables
39 private lastMessageCount: number = 0;
40 private nextFetchIndex: number = 0;
41 private currentFetchStartIndex: number = 0;
42 private currentPollController: AbortController | null = null;
43 private isFetchingMessages: boolean = false;
44 private isPollingEnabled: boolean = true;
45 private isFirstLoad: boolean = true;
46 private connectionStatus: ConnectionStatus = "disabled";
Sean McCulloughd9f13372025-04-21 15:08:49 -070047 private messages: AgentMessage[] = [];
Earl Lee2e463fb2025-04-17 11:22:22 -070048 private timelineState: TimelineState | null = null;
Sean McCullough71941bd2025-04-18 13:31:48 -070049
Earl Lee2e463fb2025-04-17 11:22:22 -070050 // Event listeners
Sean McCullough71941bd2025-04-18 13:31:48 -070051 private eventListeners: Map<
52 DataManagerEventType,
53 Array<(...args: any[]) => void>
54 > = new Map();
Earl Lee2e463fb2025-04-17 11:22:22 -070055
56 constructor() {
57 // Initialize empty arrays for each event type
Sean McCullough71941bd2025-04-18 13:31:48 -070058 this.eventListeners.set("dataChanged", []);
59 this.eventListeners.set("connectionStatusChanged", []);
Earl Lee2e463fb2025-04-17 11:22:22 -070060 }
61
62 /**
63 * Initialize the data manager and fetch initial data
64 */
65 public async initialize(): Promise<void> {
66 try {
67 // Initial data fetch
68 await this.fetchData();
69 // Start polling for updates only if initial fetch succeeds
70 this.startPolling();
71 } catch (error) {
72 console.error("Initial data fetch failed, will retry via polling", error);
73 // Still start polling to recover
74 this.startPolling();
75 }
76 }
77
78 /**
79 * Get all messages
80 */
Sean McCulloughd9f13372025-04-21 15:08:49 -070081 public getMessages(): AgentMessage[] {
Earl Lee2e463fb2025-04-17 11:22:22 -070082 return this.messages;
83 }
84
85 /**
86 * Get the current state
87 */
88 public getState(): TimelineState | null {
89 return this.timelineState;
90 }
91
92 /**
93 * Get the connection status
94 */
95 public getConnectionStatus(): ConnectionStatus {
96 return this.connectionStatus;
97 }
98
99 /**
100 * Get the isFirstLoad flag
101 */
102 public getIsFirstLoad(): boolean {
103 return this.isFirstLoad;
104 }
105
106 /**
107 * Get the currentFetchStartIndex
108 */
109 public getCurrentFetchStartIndex(): number {
110 return this.currentFetchStartIndex;
111 }
112
113 /**
114 * Add an event listener
115 */
Sean McCullough71941bd2025-04-18 13:31:48 -0700116 public addEventListener(
117 event: DataManagerEventType,
118 callback: (...args: any[]) => void,
119 ): void {
Earl Lee2e463fb2025-04-17 11:22:22 -0700120 const listeners = this.eventListeners.get(event) || [];
121 listeners.push(callback);
122 this.eventListeners.set(event, listeners);
123 }
124
125 /**
126 * Remove an event listener
127 */
Sean McCullough71941bd2025-04-18 13:31:48 -0700128 public removeEventListener(
129 event: DataManagerEventType,
130 callback: (...args: any[]) => void,
131 ): void {
Earl Lee2e463fb2025-04-17 11:22:22 -0700132 const listeners = this.eventListeners.get(event) || [];
133 const index = listeners.indexOf(callback);
134 if (index !== -1) {
135 listeners.splice(index, 1);
136 this.eventListeners.set(event, listeners);
137 }
138 }
139
140 /**
141 * Emit an event
142 */
143 private emitEvent(event: DataManagerEventType, ...args: any[]): void {
144 const listeners = this.eventListeners.get(event) || [];
Sean McCullough71941bd2025-04-18 13:31:48 -0700145 listeners.forEach((callback) => callback(...args));
Earl Lee2e463fb2025-04-17 11:22:22 -0700146 }
147
148 /**
149 * Set polling enabled/disabled state
150 */
151 public setPollingEnabled(enabled: boolean): void {
152 this.isPollingEnabled = enabled;
Sean McCullough71941bd2025-04-18 13:31:48 -0700153
Earl Lee2e463fb2025-04-17 11:22:22 -0700154 if (enabled) {
155 this.startPolling();
156 } else {
157 this.stopPolling();
158 }
159 }
160
161 /**
162 * Start polling for updates
163 */
164 public startPolling(): void {
165 this.stopPolling(); // Stop any existing polling
Sean McCullough71941bd2025-04-18 13:31:48 -0700166
Earl Lee2e463fb2025-04-17 11:22:22 -0700167 // Start long polling
168 this.longPoll();
169 }
170
171 /**
172 * Stop polling for updates
173 */
174 public stopPolling(): void {
175 // Abort any ongoing long poll request
176 if (this.currentPollController) {
177 this.currentPollController.abort();
178 this.currentPollController = null;
179 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700180
Earl Lee2e463fb2025-04-17 11:22:22 -0700181 // If polling is disabled by user, set connection status to disabled
182 if (!this.isPollingEnabled) {
183 this.updateConnectionStatus("disabled");
184 }
185 }
186
187 /**
188 * Update the connection status
189 */
190 private updateConnectionStatus(status: ConnectionStatus): void {
191 if (this.connectionStatus !== status) {
192 this.connectionStatus = status;
Sean McCullough71941bd2025-04-18 13:31:48 -0700193 this.emitEvent("connectionStatusChanged", status);
Earl Lee2e463fb2025-04-17 11:22:22 -0700194 }
195 }
196
197 /**
198 * Long poll for updates
199 */
200 private async longPoll(): Promise<void> {
201 // Abort any existing poll request
202 if (this.currentPollController) {
203 this.currentPollController.abort();
204 this.currentPollController = null;
205 }
206
207 // If polling is disabled, don't start a new poll
208 if (!this.isPollingEnabled) {
209 return;
210 }
211
212 let timeoutId: number | undefined;
213
214 try {
215 // Create a new abort controller for this request
216 this.currentPollController = new AbortController();
217 const signal = this.currentPollController.signal;
218
219 // Get the URL with the current message count
220 const pollUrl = `state?poll=true&seen=${this.lastMessageCount}`;
221
222 // Make the long poll request
223 // Use explicit timeout to handle stalled connections (120s)
224 const controller = new AbortController();
225 timeoutId = window.setTimeout(() => controller.abort(), 120000);
226
227 interface CustomFetchOptions extends RequestInit {
228 [Symbol.toStringTag]?: unknown;
229 }
230
231 const fetchOptions: CustomFetchOptions = {
232 signal: controller.signal,
233 // Use the original signal to allow manual cancellation too
234 get [Symbol.toStringTag]() {
235 if (signal.aborted) controller.abort();
236 return "";
237 },
238 };
239
240 try {
241 const response = await fetch(pollUrl, fetchOptions);
242 // Clear the timeout since we got a response
243 clearTimeout(timeoutId);
244
245 // Parse the JSON response
246 const _data = await response.json();
247
248 // If we got here, data has changed, so fetch the latest data
249 await this.fetchData();
250
251 // Start a new long poll (if polling is still enabled)
252 if (this.isPollingEnabled) {
253 this.longPoll();
254 }
255 } catch (error) {
256 // Handle fetch errors inside the inner try block
257 clearTimeout(timeoutId);
258 throw error; // Re-throw to be caught by the outer catch block
259 }
260 } catch (error: unknown) {
261 // Clean up timeout if we're handling an error
262 if (timeoutId) clearTimeout(timeoutId);
263
264 // Don't log or treat manual cancellations as errors
265 const isErrorWithName = (
266 err: unknown,
267 ): err is { name: string; message?: string } =>
268 typeof err === "object" && err !== null && "name" in err;
269
270 if (
271 isErrorWithName(error) &&
272 error.name === "AbortError" &&
273 this.currentPollController?.signal.aborted
274 ) {
275 console.log("Polling cancelled by user");
276 return;
277 }
278
279 // Handle different types of errors with specific messages
280 let errorMessage = "Not connected";
281
282 if (isErrorWithName(error)) {
283 if (error.name === "AbortError") {
284 // This was our timeout abort
285 errorMessage = "Connection timeout - not connected";
286 console.error("Long polling timeout");
287 } else if (error.name === "SyntaxError") {
288 // JSON parsing error
289 errorMessage = "Invalid response from server - not connected";
290 console.error("JSON parsing error:", error);
291 } else if (
292 error.name === "TypeError" &&
293 error.message?.includes("NetworkError")
294 ) {
295 // Network connectivity issues
296 errorMessage = "Network connection lost - not connected";
297 console.error("Network error during polling:", error);
298 } else {
299 // Generic error
300 console.error("Long polling error:", error);
301 }
302 }
303
304 // Disable polling on error
305 this.isPollingEnabled = false;
306
307 // Update connection status to disconnected
308 this.updateConnectionStatus("disconnected");
309
310 // Emit an event that we're disconnected with the error message
Sean McCullough71941bd2025-04-18 13:31:48 -0700311 this.emitEvent(
312 "connectionStatusChanged",
313 this.connectionStatus,
314 errorMessage,
315 );
Earl Lee2e463fb2025-04-17 11:22:22 -0700316 }
317 }
318
319 /**
320 * Fetch timeline data
321 */
Sean McCullough71941bd2025-04-18 13:31:48 -0700322 public async fetchData(): Promise<void> {
Earl Lee2e463fb2025-04-17 11:22:22 -0700323 // If we're already fetching messages, don't start another fetch
324 if (this.isFetchingMessages) {
325 console.log("Already fetching messages, skipping request");
326 return;
327 }
328
329 this.isFetchingMessages = true;
330
331 try {
332 // Fetch state first
333 const stateResponse = await fetch("state");
334 const state = await stateResponse.json();
335 this.timelineState = state;
336
337 // Check if new messages are available
338 if (
339 state.message_count === this.lastMessageCount &&
340 this.lastMessageCount > 0
341 ) {
342 // No new messages, early return
343 this.isFetchingMessages = false;
Sean McCullough71941bd2025-04-18 13:31:48 -0700344 this.emitEvent("dataChanged", { state, newMessages: [] });
Earl Lee2e463fb2025-04-17 11:22:22 -0700345 return;
346 }
347
348 // Fetch messages with a start parameter
349 this.currentFetchStartIndex = this.nextFetchIndex;
350 const messagesResponse = await fetch(
351 `messages?start=${this.nextFetchIndex}`,
352 );
Sean McCullough71941bd2025-04-18 13:31:48 -0700353 const newMessages = (await messagesResponse.json()) || [];
Earl Lee2e463fb2025-04-17 11:22:22 -0700354
355 // Store messages in our array
356 if (this.nextFetchIndex === 0) {
357 // If this is the first fetch, replace the entire array
358 this.messages = [...newMessages];
359 } else {
360 // Otherwise append the new messages
361 this.messages = [...this.messages, ...newMessages];
362 }
363
364 // Update connection status to connected
365 this.updateConnectionStatus("connected");
366
367 // Update the last message index for next fetch
368 if (newMessages && newMessages.length > 0) {
369 this.nextFetchIndex += newMessages.length;
370 }
371
372 // Update the message count
373 this.lastMessageCount = state?.message_count ?? 0;
374
375 // Mark that we've completed first load
376 if (this.isFirstLoad) {
377 this.isFirstLoad = false;
378 }
379
380 // Emit an event that data has changed
Sean McCullough71941bd2025-04-18 13:31:48 -0700381 this.emitEvent("dataChanged", {
382 state,
383 newMessages,
384 isFirstFetch: this.nextFetchIndex === newMessages.length,
385 });
Earl Lee2e463fb2025-04-17 11:22:22 -0700386 } catch (error) {
387 console.error("Error fetching data:", error);
388
389 // Update connection status to disconnected
390 this.updateConnectionStatus("disconnected");
391
392 // Emit an event that we're disconnected
Sean McCullough71941bd2025-04-18 13:31:48 -0700393 this.emitEvent(
394 "connectionStatusChanged",
395 this.connectionStatus,
396 "Not connected",
397 );
Earl Lee2e463fb2025-04-17 11:22:22 -0700398 } finally {
399 this.isFetchingMessages = false;
400 }
401 }
402}