ocwatch 0.4.0 → 0.6.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 (41) hide show
  1. package/README.md +22 -3
  2. package/package.json +4 -4
  3. package/src/client/dist/assets/GraphView-BZV40eAE.css +1 -0
  4. package/src/client/dist/assets/GraphView-KWCCGYb2.js +9 -0
  5. package/src/client/dist/assets/graph-Cw_XSlvx.js +7 -0
  6. package/src/client/dist/assets/index-CbgYG3pJ.js +23 -0
  7. package/src/client/dist/assets/index-CgDCc8Mm.css +1 -0
  8. package/src/client/dist/assets/motion-CGUGF2CN.js +9 -0
  9. package/src/client/dist/index.html +4 -2
  10. package/src/server/__tests__/helpers/testDb.ts +220 -0
  11. package/src/server/index.ts +27 -27
  12. package/src/server/logic/activityLogic.ts +260 -0
  13. package/src/server/logic/index.ts +2 -0
  14. package/src/server/logic/sessionLogic.ts +107 -0
  15. package/src/server/routes/parts.ts +9 -7
  16. package/src/server/routes/poll.ts +32 -46
  17. package/src/server/routes/projects.ts +10 -27
  18. package/src/server/routes/sessions.ts +159 -68
  19. package/src/server/routes/sse.ts +10 -4
  20. package/src/server/services/parsing.ts +211 -0
  21. package/src/server/services/pollService.ts +400 -116
  22. package/src/server/services/recentSessions.ts +14 -0
  23. package/src/server/services/sessionContext.ts +97 -0
  24. package/src/server/services/sessionService.ts +97 -193
  25. package/src/server/services/sessionTree.ts +92 -0
  26. package/src/server/storage/db.ts +63 -0
  27. package/src/server/storage/index.ts +28 -0
  28. package/src/server/storage/queries.ts +528 -0
  29. package/src/server/utils/projectResolver.ts +9 -3
  30. package/src/server/utils/sessionStatus.ts +5 -89
  31. package/src/server/validation.ts +2 -4
  32. package/src/server/watcher.ts +225 -82
  33. package/src/shared/constants.ts +8 -3
  34. package/src/shared/index.ts +3 -0
  35. package/src/shared/types/index.ts +48 -53
  36. package/src/shared/utils/activityUtils.ts +3 -2
  37. package/src/client/dist/assets/index-BIu7r5_5.css +0 -1
  38. package/src/client/dist/assets/index-BYMVif3u.js +0 -50
  39. package/src/server/storage/messageParser.ts +0 -169
  40. package/src/server/storage/partParser.ts +0 -532
  41. package/src/server/storage/sessionParser.ts +0 -180
@@ -1,25 +1,61 @@
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
+ SessionActivityResponse,
13
+ } from "../../shared/types";
5
14
  import { parseBoulder, calculatePlanProgress } from "../storage/boulderParser";
6
- import { getPartsForSession, getSessionActivityState, isPendingToolCall, generateActivityMessage, deriveActivityType } from "../storage/partParser";
7
- import { getSessionStatusInfo } from "../utils/sessionStatus";
15
+ import {
16
+ checkDbExists,
17
+ queryMaxTimestamp,
18
+ queryMessages,
19
+ queryParts,
20
+ queryMessagesForSessions,
21
+ queryPartsForSessions,
22
+ querySessions,
23
+ querySessionSubtree,
24
+ querySessionSubtreeRevision,
25
+ queryTodos,
26
+ } from "../storage";
27
+ import {
28
+ getSessionActivityState,
29
+ generateActivityMessage,
30
+ deriveActivityType,
31
+ getSessionStatusInfo,
32
+ isAssistantFinished,
33
+ } from "../logic";
8
34
  import { resolveProjectDirectory } from "../utils/projectResolver";
9
- import { isAssistantFinished, getSessionHierarchy } from "./sessionService";
35
+ import { createSessionContext } from "./sessionContext";
36
+ import { getSessionHierarchy } from "./sessionService";
10
37
  import { aggregateSessionStats } from "./statsService";
11
- import { TWENTY_FOUR_HOURS_MS, MAX_SESSIONS_LIMIT, MAX_MESSAGES_LIMIT, POLL_CACHE_TTL_MS } from "../../shared/constants";
38
+ import {
39
+ toSessionMetadata,
40
+ toMessageMeta,
41
+ toPartMeta,
42
+ getLatestAssistantMessage,
43
+ getMostRecentPendingPart,
44
+ } from "./parsing";
45
+ import { selectRecentRootSessions } from "./recentSessions";
46
+ import {
47
+ MAX_SESSIONS_LIMIT,
48
+ MAX_MESSAGES_LIMIT,
49
+ POLL_CACHE_TTL_MS,
50
+ SESSION_SCAN_LIMIT,
51
+ MESSAGE_SCAN_LIMIT,
52
+ } from "../../shared/constants";
12
53
 
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];
54
+ interface IncrementalPollState {
55
+ sessionsById: Map<string, SessionMetadata>;
56
+ messagesBySessionId: Map<string, MessageMeta[]>;
57
+ partsBySessionId: Map<string, PartMeta[]>;
58
+ lastTimestamp: number;
23
59
  }
24
60
 
25
61
  function sessionPriority(session: SessionMetadata): number {
@@ -31,27 +67,46 @@ function sessionPriority(session: SessionMetadata): number {
31
67
  }
32
68
 
33
69
  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)}"`;
70
+ const dataForHash = {
71
+ sessions: data.sessions,
72
+ activeSessionId: data.activeSessionId,
73
+ planProgress: data.planProgress,
74
+ };
75
+ const hash = createHash("sha256").update(JSON.stringify(dataForHash)).digest("hex");
76
+ return `"${hash.substring(0, 16)}"`;
77
+ }
78
+
79
+ export function generateSessionActivityETag(data: SessionActivityResponse): string {
80
+ const hash = createHash("sha256")
81
+ .update(
82
+ JSON.stringify({
83
+ revision: data.revision,
84
+ session: data.session,
85
+ activity: data.activity,
86
+ stats: data.stats,
87
+ }),
88
+ )
89
+ .digest("hex");
90
+ return `"${hash.substring(0, 16)}"`;
45
91
  }
46
92
 
47
93
  type PollCacheEntry = { data: PollResponse; etag: string; timestamp: number };
94
+ type SessionActivityCacheEntry = { data: SessionActivityResponse; etag: string };
48
95
 
49
96
  const pollCacheMap = new Map<string, PollCacheEntry>();
50
97
  const pollInProgressMap = new Map<string, Promise<PollResponse>>();
98
+ const sessionActivityCacheMap = new Map<string, SessionActivityCacheEntry>();
99
+ const sessionActivityInProgressMap = new Map<string, Promise<SessionActivityResponse>>();
100
+ const incrementalPollStateMap = new Map<string, IncrementalPollState>();
101
+ const MAX_INCREMENTAL_STATE_ENTRIES = 10;
51
102
  let pollCacheEpoch = 0;
52
103
 
53
104
  function cacheKey(projectId?: string): string {
54
- return projectId ?? '';
105
+ return projectId ?? "";
106
+ }
107
+
108
+ function sessionActivityCacheKey(sessionId: string): string {
109
+ return sessionId;
55
110
  }
56
111
 
57
112
  export function getPollCache(projectId?: string): PollCacheEntry | null {
@@ -74,6 +129,7 @@ export function getPollCacheEpoch() {
74
129
  export function invalidatePollCache() {
75
130
  pollCacheEpoch += 1;
76
131
  pollCacheMap.clear();
132
+ sessionActivityCacheMap.clear();
77
133
  }
78
134
 
79
135
  export function getPollInProgress(projectId?: string): Promise<PollResponse> | null {
@@ -89,80 +145,238 @@ export function setPollInProgress(promise: Promise<PollResponse> | null, project
89
145
  }
90
146
  }
91
147
 
148
+ export function getSessionActivityCache(sessionId: string): SessionActivityCacheEntry | null {
149
+ return sessionActivityCacheMap.get(sessionActivityCacheKey(sessionId)) ?? null;
150
+ }
151
+
152
+ export function setSessionActivityCache(sessionId: string, cache: SessionActivityCacheEntry | null) {
153
+ const key = sessionActivityCacheKey(sessionId);
154
+ if (cache) {
155
+ sessionActivityCacheMap.set(key, cache);
156
+ } else {
157
+ sessionActivityCacheMap.delete(key);
158
+ }
159
+ }
160
+
161
+ export function getSessionActivityInProgress(sessionId: string): Promise<SessionActivityResponse> | null {
162
+ return sessionActivityInProgressMap.get(sessionActivityCacheKey(sessionId)) ?? null;
163
+ }
164
+
165
+ export function setSessionActivityInProgress(sessionId: string, promise: Promise<SessionActivityResponse> | null) {
166
+ const key = sessionActivityCacheKey(sessionId);
167
+ if (promise) {
168
+ sessionActivityInProgressMap.set(key, promise);
169
+ } else {
170
+ sessionActivityInProgressMap.delete(key);
171
+ }
172
+ }
173
+
92
174
  export function getPollCacheTTL() {
93
175
  return POLL_CACHE_TTL_MS;
94
176
  }
95
177
 
96
- export async function fetchPollData(sessionId?: string, projectId?: string): Promise<PollResponse> {
97
- const storageExists = await checkStorageExists();
98
- if (!storageExists) {
178
+ function getIncrementalState(projectId?: string): IncrementalPollState {
179
+ const key = cacheKey(projectId);
180
+ const existing = incrementalPollStateMap.get(key);
181
+ if (existing) {
182
+ return existing;
183
+ }
184
+
185
+ // Evict oldest entry if at capacity
186
+ if (incrementalPollStateMap.size >= MAX_INCREMENTAL_STATE_ENTRIES) {
187
+ let oldestKey: string | undefined;
188
+ let oldestTimestamp = Infinity;
189
+ for (const [k, v] of incrementalPollStateMap) {
190
+ if (v.lastTimestamp < oldestTimestamp) {
191
+ oldestTimestamp = v.lastTimestamp;
192
+ oldestKey = k;
193
+ }
194
+ }
195
+ if (oldestKey !== undefined) {
196
+ incrementalPollStateMap.delete(oldestKey);
197
+ }
198
+ }
199
+
200
+ const created: IncrementalPollState = {
201
+ sessionsById: new Map(),
202
+ messagesBySessionId: new Map(),
203
+ partsBySessionId: new Map(),
204
+ lastTimestamp: 0,
205
+ };
206
+ incrementalPollStateMap.set(key, created);
207
+ return created;
208
+ }
209
+
210
+ function sortMessagesDescending(messages: MessageMeta[]): MessageMeta[] {
211
+ return [...messages].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
212
+ }
213
+
214
+ function groupMessagesBySession(rows: ReturnType<typeof queryMessagesForSessions>): Map<string, MessageMeta[]> {
215
+ const grouped = new Map<string, MessageMeta[]>();
216
+
217
+ for (const row of rows) {
218
+ const parsed = toMessageMeta(row);
219
+ const existing = grouped.get(parsed.sessionID);
220
+ if (existing) {
221
+ existing.push(parsed);
222
+ } else {
223
+ grouped.set(parsed.sessionID, [parsed]);
224
+ }
225
+ }
226
+
227
+ return grouped;
228
+ }
229
+
230
+ function groupPartsBySession(rows: ReturnType<typeof queryPartsForSessions>): Map<string, PartMeta[]> {
231
+ const grouped = new Map<string, PartMeta[]>();
232
+
233
+ for (const row of rows) {
234
+ const parsed = toPartMeta(row);
235
+ const existing = grouped.get(parsed.sessionID);
236
+ if (existing) {
237
+ existing.push(parsed);
238
+ } else {
239
+ grouped.set(parsed.sessionID, [parsed]);
240
+ }
241
+ }
242
+
243
+ return grouped;
244
+ }
245
+
246
+ function loadMessagesForSession(state: IncrementalPollState, sessionId: string, forceRefresh = false): MessageMeta[] {
247
+ if (!forceRefresh) {
248
+ const cached = state.messagesBySessionId.get(sessionId);
249
+ if (cached) {
250
+ return cached;
251
+ }
252
+ }
253
+
254
+ const messages = queryMessages(sessionId, MESSAGE_SCAN_LIMIT).map(toMessageMeta);
255
+ const sorted = sortMessagesDescending(messages);
256
+ state.messagesBySessionId.set(sessionId, sorted);
257
+ return sorted;
258
+ }
259
+
260
+ function loadPartsForSession(state: IncrementalPollState, sessionId: string, forceRefresh = false): PartMeta[] {
261
+ if (!forceRefresh) {
262
+ const cached = state.partsBySessionId.get(sessionId);
263
+ if (cached) {
264
+ return cached;
265
+ }
266
+ }
267
+
268
+ const parts = queryParts(sessionId).map(toPartMeta);
269
+ state.partsBySessionId.set(sessionId, parts);
270
+ return parts;
271
+ }
272
+
273
+ function updateIncrementalState(state: IncrementalPollState, projectId?: string): void {
274
+ const currentMaxTimestamp = queryMaxTimestamp();
275
+ const hasBaseline = state.sessionsById.size > 0;
276
+ const hasIncrementalWindow = hasBaseline && currentMaxTimestamp > state.lastTimestamp;
277
+
278
+ let refreshAllSessions = !hasBaseline;
279
+ const changedSessionIds = new Set<string>();
280
+
281
+ if (hasIncrementalWindow) {
282
+ const changedSessions = querySessions(projectId, state.lastTimestamp, SESSION_SCAN_LIMIT);
283
+ if (changedSessions.length === 0) {
284
+ refreshAllSessions = true;
285
+ } else {
286
+ for (const row of changedSessions) {
287
+ const session = toSessionMetadata(row);
288
+ state.sessionsById.set(session.id, session);
289
+ changedSessionIds.add(session.id);
290
+ }
291
+ }
292
+ }
293
+
294
+ if (refreshAllSessions) {
295
+ state.sessionsById.clear();
296
+ const allRows = querySessions(projectId, undefined, SESSION_SCAN_LIMIT);
297
+ for (const row of allRows) {
298
+ const session = toSessionMetadata(row);
299
+ state.sessionsById.set(session.id, session);
300
+ changedSessionIds.add(session.id);
301
+ }
302
+
303
+ const activeSessionIds = new Set(state.sessionsById.keys());
304
+ for (const sessionKey of state.messagesBySessionId.keys()) {
305
+ if (!activeSessionIds.has(sessionKey)) {
306
+ state.messagesBySessionId.delete(sessionKey);
307
+ }
308
+ }
309
+ for (const sessionKey of state.partsBySessionId.keys()) {
310
+ if (!activeSessionIds.has(sessionKey)) {
311
+ state.partsBySessionId.delete(sessionKey);
312
+ }
313
+ }
314
+ }
315
+
316
+ const wasFirstLoad = !hasBaseline || refreshAllSessions;
317
+ if (!wasFirstLoad && changedSessionIds.size > 0) {
318
+ for (const sessionId of changedSessionIds) {
319
+ loadMessagesForSession(state, sessionId, true);
320
+ loadPartsForSession(state, sessionId, true);
321
+ }
322
+ }
323
+
324
+ state.lastTimestamp = currentMaxTimestamp;
325
+ }
326
+
327
+ export async function fetchPollData(projectId?: string): Promise<PollResponse> {
328
+ const dbExists = checkDbExists();
329
+ if (!dbExists) {
330
+ incrementalPollStateMap.delete(cacheKey(projectId));
99
331
  return {
100
332
  sessions: [],
101
- activeSession: null,
333
+ activeSessionId: null,
102
334
  planProgress: null,
103
- messages: [],
104
- activitySessions: [],
105
335
  lastUpdate: Date.now(),
106
336
  };
107
337
  }
108
338
 
109
- const allSessions = await listAllSessions();
110
- let scopedSessions = allSessions;
339
+ const state = getIncrementalState(projectId);
340
+ updateIncrementalState(state, projectId);
341
+
342
+ const scopedSessions = Array.from(state.sessionsById.values());
111
343
  let selectedProjectDirectory: string | null = null;
112
344
 
113
345
  if (projectId) {
114
- selectedProjectDirectory = await resolveProjectDirectory(projectId, allSessions);
115
- scopedSessions = allSessions.filter((session) => session.projectID === projectId);
346
+ selectedProjectDirectory = await resolveProjectDirectory(projectId, scopedSessions);
116
347
  }
117
348
 
118
349
  const now = Date.now();
119
- const twentyFourHoursAgo = now - TWENTY_FOUR_HOURS_MS;
120
350
 
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
-
129
- const limitedSessions = sortedSessions.slice(0, MAX_SESSIONS_LIMIT);
130
- const rootSessions = limitedSessions.filter(s => !s.parentID);
351
+ const rootSessions = selectRecentRootSessions(scopedSessions, MAX_SESSIONS_LIMIT);
131
352
 
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)!;
353
+ function getCachedMessages(id: string): MessageMeta[] {
354
+ const cached = state.messagesBySessionId.get(id);
355
+ if (cached) return cached;
356
+ return loadMessagesForSession(state, id);
140
357
  }
141
358
 
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)!;
359
+ function getCachedParts(id: string): PartMeta[] {
360
+ return loadPartsForSession(state, id);
147
361
  }
148
362
 
149
363
  const sessionsWithAgent = await Promise.all(
150
364
  rootSessions.map(async (session) => {
151
- const messages = await getCachedMessages(session.id);
365
+ const messages = getCachedMessages(session.id);
152
366
  const latestAssistantMsg = getLatestAssistantMessage(messages);
153
- const parts = await getCachedParts(session.id);
367
+ const parts = getCachedParts(session.id);
154
368
  const activityState = getSessionActivityState(parts);
155
369
  const lastAssistantFinished = isAssistantFinished(messages);
156
-
370
+
157
371
  const statusInfo = getSessionStatusInfo(
158
372
  messages,
159
373
  activityState.hasPendingToolCall,
160
374
  activityState.lastToolCompletedAt || undefined,
161
375
  undefined,
162
- lastAssistantFinished
376
+ lastAssistantFinished,
163
377
  );
164
378
  const status = statusInfo.status;
165
-
379
+
166
380
  const pendingPart = getMostRecentPendingPart(parts);
167
381
  const currentAction = generateActivityMessage(
168
382
  activityState,
@@ -170,11 +384,17 @@ export async function fetchPollData(sessionId?: string, projectId?: string): Pro
170
384
  false,
171
385
  status,
172
386
  pendingPart,
173
- statusInfo.waitingReason
387
+ statusInfo.waitingReason,
388
+ );
389
+
390
+ const activityType = deriveActivityType(
391
+ activityState,
392
+ lastAssistantFinished,
393
+ false,
394
+ status,
395
+ statusInfo.waitingReason,
174
396
  );
175
-
176
- const activityType = deriveActivityType(activityState, lastAssistantFinished, false, status, statusInfo.waitingReason);
177
-
397
+
178
398
  return {
179
399
  ...session,
180
400
  agent: latestAssistantMsg?.agent || null,
@@ -184,22 +404,19 @@ export async function fetchPollData(sessionId?: string, projectId?: string): Pro
184
404
  activityType,
185
405
  currentAction,
186
406
  };
187
- })
407
+ }),
188
408
  );
189
409
 
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
- }
410
+ const activeEnriched = [...sessionsWithAgent].sort((a, b) => {
411
+ const priorityDiff = sessionPriority(b) - sessionPriority(a);
412
+ if (priorityDiff !== 0) {
413
+ return priorityDiff;
414
+ }
415
+ return b.updatedAt.getTime() - a.updatedAt.getTime();
416
+ })[0];
417
+
418
+ const activeSessionId =
419
+ activeEnriched && sessionPriority(activeEnriched) > 0 ? activeEnriched.id : null;
203
420
 
204
421
  let planProgress: PlanProgress | null = null;
205
422
  let planName: string | undefined;
@@ -212,43 +429,110 @@ export async function fetchPollData(sessionId?: string, projectId?: string): Pro
212
429
  planName = boulder.planName || undefined;
213
430
  }
214
431
  } catch (err) {
215
- console.debug('Failed to parse boulder.json:', err instanceof Error ? err.message : err);
432
+ console.warn("Failed to parse boulder.json:", err instanceof Error ? err.message : err);
216
433
  }
217
434
  }
218
435
 
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
- }
436
+ const sessions: SessionSummary[] = sessionsWithAgent.map((s) => ({
437
+ id: s.id,
438
+ projectID: s.projectID,
439
+ title: s.title,
440
+ status: s.status,
441
+ activityType: s.activityType,
442
+ currentAction: s.currentAction,
443
+ agent: s.agent,
444
+ modelID: s.modelID,
445
+ providerID: s.providerID,
446
+ updatedAt: s.updatedAt,
447
+ createdAt: s.createdAt,
448
+ }));
243
449
 
244
450
  return {
245
- sessions: sessionsWithAgent,
246
- activeSession,
451
+ sessions,
452
+ activeSessionId,
247
453
  planProgress,
248
454
  planName,
249
- messages,
250
- activitySessions,
251
- sessionStats,
252
455
  lastUpdate: now,
253
456
  };
254
457
  }
458
+
459
+ async function buildSessionActivityResponse(sessionId: string): Promise<{
460
+ graph: SessionActivityResponse;
461
+ messagesBySession: Map<string, MessageMeta[]>;
462
+ }> {
463
+ const allSessions = querySessionSubtree(sessionId).map(toSessionMetadata);
464
+ const sessionIds = allSessions.map((session) => session.id);
465
+ const messagesBySession = groupMessagesBySession(
466
+ queryMessagesForSessions(sessionIds, MESSAGE_SCAN_LIMIT),
467
+ );
468
+ const partsBySession = groupPartsBySession(queryPartsForSessions(sessionIds));
469
+ const context = createSessionContext(allSessions, {
470
+ messagesBySession,
471
+ partsBySession,
472
+ });
473
+
474
+ for (const session of allSessions) {
475
+ if (!messagesBySession.has(session.id)) {
476
+ messagesBySession.set(session.id, []);
477
+ }
478
+ if (!partsBySession.has(session.id)) {
479
+ partsBySession.set(session.id, []);
480
+ }
481
+ }
482
+
483
+ const activity: ActivitySession[] = await getSessionHierarchy(sessionId, allSessions, context);
484
+ const stats = aggregateSessionStats(activity, messagesBySession);
485
+ const sessionMeta = allSessions.find((session) => session.id === sessionId);
486
+ const rootActivity =
487
+ activity.find((activitySession) => activitySession.id === sessionId) ??
488
+ activity.filter((activitySession) => activitySession.id.startsWith(`${sessionId}-phase-`)).pop();
489
+
490
+ const session: SessionSummary = {
491
+ id: sessionId,
492
+ projectID: sessionMeta?.projectID ?? "",
493
+ title: sessionMeta?.title ?? sessionId,
494
+ status: rootActivity?.status,
495
+ activityType: rootActivity?.activityType,
496
+ currentAction: rootActivity?.currentAction,
497
+ agent: rootActivity?.agent ?? null,
498
+ modelID: rootActivity?.modelID ?? null,
499
+ providerID: rootActivity?.providerID ?? null,
500
+ updatedAt: sessionMeta?.updatedAt ?? new Date(),
501
+ createdAt: sessionMeta?.createdAt ?? new Date(),
502
+ };
503
+
504
+ return {
505
+ graph: {
506
+ session,
507
+ activity,
508
+ stats,
509
+ revision: querySessionSubtreeRevision(sessionId),
510
+ },
511
+ messagesBySession,
512
+ };
513
+ }
514
+
515
+ export async function fetchSessionActivity(sessionId: string): Promise<SessionActivityResponse> {
516
+ return (await buildSessionActivityResponse(sessionId)).graph;
517
+ }
518
+
519
+ export async function fetchSessionDetail(sessionId: string): Promise<SessionDetail> {
520
+ const { graph, messagesBySession } = await buildSessionActivityResponse(sessionId);
521
+
522
+ const todos: TodoItem[] = queryTodos(sessionId).map((row) => ({
523
+ content: row.content,
524
+ status: row.status,
525
+ priority: row.priority,
526
+ position: row.position,
527
+ }));
528
+
529
+ const messages = (messagesBySession.get(sessionId) ?? []).slice(0, MAX_MESSAGES_LIMIT);
530
+
531
+ return {
532
+ session: graph.session,
533
+ messages,
534
+ activity: graph.activity,
535
+ todos,
536
+ stats: graph.stats ?? undefined,
537
+ };
538
+ }
@@ -0,0 +1,14 @@
1
+ interface HasRootSessionFields {
2
+ parentID?: string | null;
3
+ updatedAt: Date;
4
+ }
5
+
6
+ export function selectRecentRootSessions<T extends HasRootSessionFields>(
7
+ sessions: readonly T[],
8
+ limit: number,
9
+ ): T[] {
10
+ return sessions
11
+ .filter((session) => !session.parentID)
12
+ .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
13
+ .slice(0, limit);
14
+ }