ocwatch 0.4.0 → 0.5.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.
@@ -1,25 +1,57 @@
1
1
  import { createHash } from "node:crypto";
2
- import type { PollResponse, SessionMetadata, MessageMeta, PlanProgress, ActivitySession, PartMeta } from "../../shared/types";
3
- import { listAllSessions, checkStorageExists } from "../storage/sessionParser";
4
- import { listMessages } from "../storage/messageParser";
2
+ import type {
3
+ PollResponse,
4
+ SessionSummary,
5
+ SessionDetail,
6
+ SessionMetadata,
7
+ MessageMeta,
8
+ PlanProgress,
9
+ ActivitySession,
10
+ PartMeta,
11
+ TodoItem,
12
+ } from "../../shared/types";
5
13
  import { parseBoulder, calculatePlanProgress } from "../storage/boulderParser";
6
- import { getPartsForSession, getSessionActivityState, isPendingToolCall, generateActivityMessage, deriveActivityType } from "../storage/partParser";
7
- import { getSessionStatusInfo } from "../utils/sessionStatus";
14
+ import {
15
+ checkDbExists,
16
+ queryMaxTimestamp,
17
+ queryMessages,
18
+ queryParts,
19
+ querySessions,
20
+ queryTodos,
21
+ } from "../storage";
22
+ import {
23
+ getSessionActivityState,
24
+ generateActivityMessage,
25
+ deriveActivityType,
26
+ getSessionStatusInfo,
27
+ isAssistantFinished,
28
+ } from "../logic";
8
29
  import { resolveProjectDirectory } from "../utils/projectResolver";
9
- import { isAssistantFinished, getSessionHierarchy } from "./sessionService";
30
+ import { getSessionHierarchy } from "./sessionService";
10
31
  import { aggregateSessionStats } from "./statsService";
11
- import { TWENTY_FOUR_HOURS_MS, MAX_SESSIONS_LIMIT, MAX_MESSAGES_LIMIT, POLL_CACHE_TTL_MS } from "../../shared/constants";
32
+ import {
33
+ toSessionMetadata,
34
+ toMessageMeta,
35
+ toPartMeta,
36
+ getLatestAssistantMessage,
37
+ getMostRecentPendingPart,
38
+ } from "./parsing";
39
+ import {
40
+ TWENTY_FOUR_HOURS_MS,
41
+ MAX_SESSIONS_LIMIT,
42
+ MAX_MESSAGES_LIMIT,
43
+ POLL_CACHE_TTL_MS,
44
+ } from "../../shared/constants";
45
+ /** Max sessions to scan when building incremental state (internal upper bound) */
46
+ const SESSION_SCAN_LIMIT = 50_000;
47
+ /** Max messages per session for incremental poll cache (high to avoid missing updates) */
48
+ const MESSAGE_SCAN_LIMIT = 10_000;
12
49
 
13
- function getLatestAssistantMessage(messages: MessageMeta[]): MessageMeta | undefined {
14
- return messages
15
- .filter((message) => message.role === "assistant")
16
- .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0];
17
- }
18
-
19
- function getMostRecentPendingPart(parts: PartMeta[]): PartMeta | undefined {
20
- return parts
21
- .filter((part) => isPendingToolCall(part))
22
- .sort((a, b) => (b.startedAt?.getTime() || 0) - (a.startedAt?.getTime() || 0))[0];
50
+ interface IncrementalPollState {
51
+ sessionsById: Map<string, SessionMetadata>;
52
+ messagesBySessionId: Map<string, MessageMeta[]>;
53
+ partsBySessionId: Map<string, PartMeta[]>;
54
+ lastTimestamp: number;
23
55
  }
24
56
 
25
57
  function sessionPriority(session: SessionMetadata): number {
@@ -31,27 +63,25 @@ function sessionPriority(session: SessionMetadata): number {
31
63
  }
32
64
 
33
65
  export function generateETag(data: PollResponse): string {
34
- const dataForHash = {
35
- sessions: data.sessions,
36
- activeSession: data.activeSession,
37
- planProgress: data.planProgress,
38
- messages: data.messages,
39
- activitySessions: data.activitySessions,
40
- };
41
- const hash = createHash("sha256")
42
- .update(JSON.stringify(dataForHash))
43
- .digest("hex");
44
- return `"${hash.substring(0, 16)}"`;
66
+ const dataForHash = {
67
+ sessions: data.sessions,
68
+ activeSessionId: data.activeSessionId,
69
+ planProgress: data.planProgress,
70
+ };
71
+ const hash = createHash("sha256").update(JSON.stringify(dataForHash)).digest("hex");
72
+ return `"${hash.substring(0, 16)}"`;
45
73
  }
46
74
 
47
75
  type PollCacheEntry = { data: PollResponse; etag: string; timestamp: number };
48
76
 
49
77
  const pollCacheMap = new Map<string, PollCacheEntry>();
50
78
  const pollInProgressMap = new Map<string, Promise<PollResponse>>();
79
+ const incrementalPollStateMap = new Map<string, IncrementalPollState>();
80
+ const MAX_INCREMENTAL_STATE_ENTRIES = 10;
51
81
  let pollCacheEpoch = 0;
52
82
 
53
83
  function cacheKey(projectId?: string): string {
54
- return projectId ?? '';
84
+ return projectId ?? "";
55
85
  }
56
86
 
57
87
  export function getPollCache(projectId?: string): PollCacheEntry | null {
@@ -93,76 +123,179 @@ export function getPollCacheTTL() {
93
123
  return POLL_CACHE_TTL_MS;
94
124
  }
95
125
 
96
- export async function fetchPollData(sessionId?: string, projectId?: string): Promise<PollResponse> {
97
- const storageExists = await checkStorageExists();
98
- if (!storageExists) {
126
+ function getIncrementalState(projectId?: string): IncrementalPollState {
127
+ const key = cacheKey(projectId);
128
+ const existing = incrementalPollStateMap.get(key);
129
+ if (existing) {
130
+ return existing;
131
+ }
132
+
133
+ // Evict oldest entry if at capacity
134
+ if (incrementalPollStateMap.size >= MAX_INCREMENTAL_STATE_ENTRIES) {
135
+ let oldestKey: string | undefined;
136
+ let oldestTimestamp = Infinity;
137
+ for (const [k, v] of incrementalPollStateMap) {
138
+ if (v.lastTimestamp < oldestTimestamp) {
139
+ oldestTimestamp = v.lastTimestamp;
140
+ oldestKey = k;
141
+ }
142
+ }
143
+ if (oldestKey !== undefined) {
144
+ incrementalPollStateMap.delete(oldestKey);
145
+ }
146
+ }
147
+
148
+ const created: IncrementalPollState = {
149
+ sessionsById: new Map(),
150
+ messagesBySessionId: new Map(),
151
+ partsBySessionId: new Map(),
152
+ lastTimestamp: 0,
153
+ };
154
+ incrementalPollStateMap.set(key, created);
155
+ return created;
156
+ }
157
+
158
+ function sortMessagesDescending(messages: MessageMeta[]): MessageMeta[] {
159
+ return [...messages].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
160
+ }
161
+
162
+ function loadMessagesForSession(state: IncrementalPollState, sessionId: string, forceRefresh = false): MessageMeta[] {
163
+ if (!forceRefresh) {
164
+ const cached = state.messagesBySessionId.get(sessionId);
165
+ if (cached) {
166
+ return cached;
167
+ }
168
+ }
169
+
170
+ const messages = queryMessages(sessionId, MESSAGE_SCAN_LIMIT).map(toMessageMeta);
171
+ const sorted = sortMessagesDescending(messages);
172
+ state.messagesBySessionId.set(sessionId, sorted);
173
+ return sorted;
174
+ }
175
+
176
+ function loadPartsForSession(state: IncrementalPollState, sessionId: string, forceRefresh = false): PartMeta[] {
177
+ if (!forceRefresh) {
178
+ const cached = state.partsBySessionId.get(sessionId);
179
+ if (cached) {
180
+ return cached;
181
+ }
182
+ }
183
+
184
+ const parts = queryParts(sessionId).map(toPartMeta);
185
+ state.partsBySessionId.set(sessionId, parts);
186
+ return parts;
187
+ }
188
+
189
+ function updateIncrementalState(state: IncrementalPollState, projectId?: string): void {
190
+ const currentMaxTimestamp = queryMaxTimestamp();
191
+ const hasBaseline = state.sessionsById.size > 0;
192
+ const hasIncrementalWindow = hasBaseline && currentMaxTimestamp > state.lastTimestamp;
193
+
194
+ let refreshAllSessions = !hasBaseline;
195
+ const changedSessionIds = new Set<string>();
196
+
197
+ if (hasIncrementalWindow) {
198
+ const changedSessions = querySessions(projectId, state.lastTimestamp, SESSION_SCAN_LIMIT);
199
+ if (changedSessions.length === 0) {
200
+ refreshAllSessions = true;
201
+ } else {
202
+ for (const row of changedSessions) {
203
+ const session = toSessionMetadata(row);
204
+ state.sessionsById.set(session.id, session);
205
+ changedSessionIds.add(session.id);
206
+ }
207
+ }
208
+ }
209
+
210
+ if (refreshAllSessions) {
211
+ state.sessionsById.clear();
212
+ const allRows = querySessions(projectId, undefined, SESSION_SCAN_LIMIT);
213
+ for (const row of allRows) {
214
+ const session = toSessionMetadata(row);
215
+ state.sessionsById.set(session.id, session);
216
+ changedSessionIds.add(session.id);
217
+ }
218
+
219
+ const activeSessionIds = new Set(state.sessionsById.keys());
220
+ for (const sessionKey of state.messagesBySessionId.keys()) {
221
+ if (!activeSessionIds.has(sessionKey)) {
222
+ state.messagesBySessionId.delete(sessionKey);
223
+ }
224
+ }
225
+ for (const sessionKey of state.partsBySessionId.keys()) {
226
+ if (!activeSessionIds.has(sessionKey)) {
227
+ state.partsBySessionId.delete(sessionKey);
228
+ }
229
+ }
230
+ }
231
+
232
+ if (changedSessionIds.size > 0) {
233
+ for (const sessionId of changedSessionIds) {
234
+ loadMessagesForSession(state, sessionId, true);
235
+ loadPartsForSession(state, sessionId, true);
236
+ }
237
+ }
238
+
239
+ state.lastTimestamp = currentMaxTimestamp;
240
+ }
241
+
242
+ export async function fetchPollData(projectId?: string): Promise<PollResponse> {
243
+ const dbExists = checkDbExists();
244
+ if (!dbExists) {
245
+ incrementalPollStateMap.delete(cacheKey(projectId));
99
246
  return {
100
247
  sessions: [],
101
- activeSession: null,
248
+ activeSessionId: null,
102
249
  planProgress: null,
103
- messages: [],
104
- activitySessions: [],
105
250
  lastUpdate: Date.now(),
106
251
  };
107
252
  }
108
253
 
109
- const allSessions = await listAllSessions();
110
- let scopedSessions = allSessions;
254
+ const state = getIncrementalState(projectId);
255
+ updateIncrementalState(state, projectId);
256
+
257
+ const scopedSessions = Array.from(state.sessionsById.values());
111
258
  let selectedProjectDirectory: string | null = null;
112
259
 
113
260
  if (projectId) {
114
- selectedProjectDirectory = await resolveProjectDirectory(projectId, allSessions);
115
- scopedSessions = allSessions.filter((session) => session.projectID === projectId);
261
+ selectedProjectDirectory = await resolveProjectDirectory(projectId, scopedSessions);
116
262
  }
117
263
 
118
264
  const now = Date.now();
119
265
  const twentyFourHoursAgo = now - TWENTY_FOUR_HOURS_MS;
120
266
 
121
- const recentSessions = scopedSessions.filter(
122
- (s) => s.updatedAt.getTime() >= twentyFourHoursAgo
123
- );
124
-
125
- const sortedSessions = recentSessions.sort(
126
- (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
127
- );
128
-
267
+ const recentSessions = scopedSessions.filter((session) => session.updatedAt.getTime() >= twentyFourHoursAgo);
268
+ const sortedSessions = recentSessions.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
129
269
  const limitedSessions = sortedSessions.slice(0, MAX_SESSIONS_LIMIT);
130
- const rootSessions = limitedSessions.filter(s => !s.parentID);
270
+ const rootSessions = limitedSessions.filter((session) => !session.parentID);
131
271
 
132
- const messagesCache = new Map<string, MessageMeta[]>();
133
- const partsCache = new Map<string, PartMeta[]>();
134
-
135
- async function getCachedMessages(id: string): Promise<MessageMeta[]> {
136
- if (!messagesCache.has(id)) {
137
- messagesCache.set(id, await listMessages(id));
138
- }
139
- return messagesCache.get(id)!;
272
+ function getCachedMessages(id: string): MessageMeta[] {
273
+ const cached = state.messagesBySessionId.get(id);
274
+ if (cached) return cached;
275
+ return loadMessagesForSession(state, id);
140
276
  }
141
277
 
142
- async function getCachedParts(id: string): Promise<PartMeta[]> {
143
- if (!partsCache.has(id)) {
144
- partsCache.set(id, await getPartsForSession(id));
145
- }
146
- return partsCache.get(id)!;
278
+ function getCachedParts(id: string): PartMeta[] {
279
+ return loadPartsForSession(state, id);
147
280
  }
148
281
 
149
282
  const sessionsWithAgent = await Promise.all(
150
283
  rootSessions.map(async (session) => {
151
- const messages = await getCachedMessages(session.id);
284
+ const messages = getCachedMessages(session.id);
152
285
  const latestAssistantMsg = getLatestAssistantMessage(messages);
153
- const parts = await getCachedParts(session.id);
286
+ const parts = getCachedParts(session.id);
154
287
  const activityState = getSessionActivityState(parts);
155
288
  const lastAssistantFinished = isAssistantFinished(messages);
156
-
289
+
157
290
  const statusInfo = getSessionStatusInfo(
158
291
  messages,
159
292
  activityState.hasPendingToolCall,
160
293
  activityState.lastToolCompletedAt || undefined,
161
294
  undefined,
162
- lastAssistantFinished
295
+ lastAssistantFinished,
163
296
  );
164
297
  const status = statusInfo.status;
165
-
298
+
166
299
  const pendingPart = getMostRecentPendingPart(parts);
167
300
  const currentAction = generateActivityMessage(
168
301
  activityState,
@@ -170,11 +303,17 @@ export async function fetchPollData(sessionId?: string, projectId?: string): Pro
170
303
  false,
171
304
  status,
172
305
  pendingPart,
173
- statusInfo.waitingReason
306
+ statusInfo.waitingReason,
307
+ );
308
+
309
+ const activityType = deriveActivityType(
310
+ activityState,
311
+ lastAssistantFinished,
312
+ false,
313
+ status,
314
+ statusInfo.waitingReason,
174
315
  );
175
-
176
- const activityType = deriveActivityType(activityState, lastAssistantFinished, false, status, statusInfo.waitingReason);
177
-
316
+
178
317
  return {
179
318
  ...session,
180
319
  agent: latestAssistantMsg?.agent || null,
@@ -184,22 +323,19 @@ export async function fetchPollData(sessionId?: string, projectId?: string): Pro
184
323
  activityType,
185
324
  currentAction,
186
325
  };
187
- })
326
+ }),
188
327
  );
189
328
 
190
- let activeSession: SessionMetadata | null = null;
191
- const activeEnriched = [...sessionsWithAgent].sort((a, b) => {
192
- const priorityDiff = sessionPriority(b) - sessionPriority(a);
193
- if (priorityDiff !== 0) {
194
- return priorityDiff;
195
- }
196
- return b.updatedAt.getTime() - a.updatedAt.getTime();
197
- })[0];
198
- if (activeEnriched && sessionPriority(activeEnriched) === 0) {
199
- activeSession = null;
200
- } else if (activeEnriched) {
201
- activeSession = rootSessions.find(s => s.id === activeEnriched.id) ?? null;
202
- }
329
+ const activeEnriched = [...sessionsWithAgent].sort((a, b) => {
330
+ const priorityDiff = sessionPriority(b) - sessionPriority(a);
331
+ if (priorityDiff !== 0) {
332
+ return priorityDiff;
333
+ }
334
+ return b.updatedAt.getTime() - a.updatedAt.getTime();
335
+ })[0];
336
+
337
+ const activeSessionId =
338
+ activeEnriched && sessionPriority(activeEnriched) > 0 ? activeEnriched.id : null;
203
339
 
204
340
  let planProgress: PlanProgress | null = null;
205
341
  let planName: string | undefined;
@@ -212,43 +348,85 @@ export async function fetchPollData(sessionId?: string, projectId?: string): Pro
212
348
  planName = boulder.planName || undefined;
213
349
  }
214
350
  } catch (err) {
215
- console.debug('Failed to parse boulder.json:', err instanceof Error ? err.message : err);
351
+ console.debug("Failed to parse boulder.json:", err instanceof Error ? err.message : err);
216
352
  }
217
353
  }
218
354
 
219
- let messages: MessageMeta[] = [];
220
- let activitySessions: ActivitySession[] = [];
221
- let sessionStats = undefined;
222
- const scopedSessionIds = new Set(scopedSessions.map((session) => session.id));
223
- const requestedSessionId =
224
- sessionId && (!projectId || scopedSessionIds.has(sessionId))
225
- ? sessionId
226
- : undefined;
227
- const targetSessionId = requestedSessionId || activeSession?.id;
228
- if (targetSessionId) {
229
- const fetchedMessages = await getCachedMessages(targetSessionId);
230
- const sortedMessages = fetchedMessages.sort(
231
- (a, b) => b.createdAt.getTime() - a.createdAt.getTime()
232
- );
233
- messages = sortedMessages.slice(0, MAX_MESSAGES_LIMIT);
234
-
235
- activitySessions = await getSessionHierarchy(targetSessionId, scopedSessions);
236
-
237
- for (const session of activitySessions) {
238
- await getCachedMessages(session.id);
239
- }
240
-
241
- sessionStats = aggregateSessionStats(activitySessions, messagesCache);
242
- }
355
+ const sessions: SessionSummary[] = sessionsWithAgent.map((s) => ({
356
+ id: s.id,
357
+ projectID: s.projectID,
358
+ title: s.title,
359
+ status: s.status,
360
+ activityType: s.activityType,
361
+ currentAction: s.currentAction,
362
+ agent: s.agent,
363
+ modelID: s.modelID,
364
+ providerID: s.providerID,
365
+ updatedAt: s.updatedAt,
366
+ createdAt: s.createdAt,
367
+ }));
243
368
 
244
369
  return {
245
- sessions: sessionsWithAgent,
246
- activeSession,
370
+ sessions,
371
+ activeSessionId,
247
372
  planProgress,
248
373
  planName,
249
- messages,
250
- activitySessions,
251
- sessionStats,
252
374
  lastUpdate: now,
253
375
  };
254
376
  }
377
+
378
+ export async function fetchSessionDetail(sessionId: string): Promise<SessionDetail> {
379
+ // Get all sessions for hierarchy context
380
+ const allSessions = querySessions(undefined, undefined, SESSION_SCAN_LIMIT).map(toSessionMetadata);
381
+
382
+ // Get messages for this session
383
+ const messages = queryMessages(sessionId, MAX_MESSAGES_LIMIT).map(toMessageMeta);
384
+
385
+ // Get activity tree (hierarchy)
386
+ const activity: ActivitySession[] = await getSessionHierarchy(sessionId, allSessions);
387
+
388
+ // Build messages cache for stats
389
+ const messagesCache = new Map<string, MessageMeta[]>();
390
+ messagesCache.set(sessionId, messages);
391
+ for (const activitySession of activity) {
392
+ if (!messagesCache.has(activitySession.id) && !activitySession.id.includes("-phase-")) {
393
+ const childMessages = queryMessages(activitySession.id, MAX_MESSAGES_LIMIT).map(toMessageMeta);
394
+ messagesCache.set(activitySession.id, childMessages);
395
+ }
396
+ }
397
+
398
+ // Get todos
399
+ const todos: TodoItem[] = queryTodos(sessionId).map((row) => ({
400
+ content: row.content,
401
+ status: row.status,
402
+ priority: row.priority,
403
+ position: row.position,
404
+ }));
405
+
406
+ // Compute stats
407
+ const stats = aggregateSessionStats(activity, messagesCache);
408
+
409
+ // Find the session metadata
410
+ const sessionMeta = allSessions.find((s) => s.id === sessionId);
411
+
412
+ // Find the root activity entry for derived status (direct match or last phase)
413
+ const rootActivity =
414
+ activity.find((a) => a.id === sessionId) ??
415
+ activity.filter((a) => a.id.startsWith(`${sessionId}-phase-`)).pop();
416
+
417
+ const session: SessionSummary = {
418
+ id: sessionId,
419
+ projectID: sessionMeta?.projectID ?? "",
420
+ title: sessionMeta?.title ?? sessionId,
421
+ status: rootActivity?.status,
422
+ activityType: rootActivity?.activityType,
423
+ currentAction: rootActivity?.currentAction,
424
+ agent: rootActivity?.agent ?? null,
425
+ modelID: rootActivity?.modelID ?? null,
426
+ providerID: rootActivity?.providerID ?? null,
427
+ updatedAt: sessionMeta?.updatedAt ?? new Date(),
428
+ createdAt: sessionMeta?.createdAt ?? new Date(),
429
+ };
430
+
431
+ return { session, messages, activity, todos, stats };
432
+ }