1- import type { DatabaseSync } from "node:sqlite" ;
2- import type { SessionRow , SessionWithAgents } from "../../shared/agent-db-contract.js" ;
1+ import type { DatabaseSync , SQLInputValue } from "node:sqlite" ;
2+ import type {
3+ KanbanPages ,
4+ SessionPage ,
5+ SessionPageRequest ,
6+ SessionRow ,
7+ SessionWithAgents ,
8+ } from "../../shared/agent-db-contract.js" ;
39
410// Terminal session statuses (vendor + canonical AgentSession vocabulary). A
511// session not in this set is treated as active. Writes are owned by
612// `lifecycle.ts`; this store is read-only.
713const TERMINAL_STATUSES = "('completed', 'abandoned', 'error')" ;
814const TERMINAL_STATUS_SET = new Set ( [ "completed" , "abandoned" , "error" ] ) ;
15+ const MAX_SESSION_PAGE_LIMIT = 100 ;
16+ const DEFAULT_SESSION_PAGE_LIMIT = 25 ;
17+ // Correlated subqueries — efficient for bounded queries (LIMIT/single-row).
18+ const SESSION_DETAIL_SELECT = `
19+ SELECT
20+ s.*,
21+ (SELECT COUNT(*) FROM agents a WHERE a.session_id = s.id) as agent_count,
22+ (SELECT COUNT(*) FROM events e WHERE e.session_id = s.id) as event_count,
23+ (
24+ SELECT COALESCE(SUM(COALESCE(t.input_tokens, 0) + COALESCE(t.output_tokens, 0)), 0)
25+ FROM token_usage t
26+ WHERE t.session_id = s.id
27+ ) as total_tokens
28+ FROM sessions s
29+ ` ;
30+ // CTE-based join — efficient for unbounded multi-row queries (active/historical).
931const SESSION_DETAILS_CTES = `
1032 WITH agent_counts AS (
1133 SELECT session_id, COUNT(*) as agent_count
@@ -32,6 +54,10 @@ export function createSessionStore(db: DatabaseSync) {
3254 const getActiveStmt = db . prepare (
3355 `SELECT * FROM sessions WHERE status NOT IN ${ TERMINAL_STATUSES } ORDER BY started_at DESC` ,
3456 ) ;
57+ const getDetailsByIdStmt = db . prepare ( `
58+ ${ SESSION_DETAIL_SELECT }
59+ WHERE s.id = ?
60+ ` ) ;
3561
3662 const getActiveWithDetailsStmt = db . prepare ( `
3763 ${ SESSION_DETAILS_CTES }
@@ -99,6 +125,70 @@ export function createSessionStore(db: DatabaseSync) {
99125 } ) ;
100126 }
101127
128+ // Statement cache for dynamically-built page queries. Avoids re-preparing
129+ // on every IPC call while keeping the WHERE clause flexible.
130+ const stmtCache = new Map < string , ReturnType < typeof db . prepare > > ( ) ;
131+ function getOrPrepare ( key : string , sql : string ) {
132+ let stmt = stmtCache . get ( key ) ;
133+ if ( ! stmt ) {
134+ stmt = db . prepare ( sql ) ;
135+ stmtCache . set ( key , stmt ) ;
136+ }
137+ return stmt ;
138+ }
139+
140+ function coercePageRequest ( request : SessionPageRequest | undefined ) : {
141+ limit : number ;
142+ offset : number ;
143+ status : string | null ;
144+ q : string | null ;
145+ } {
146+ const requestedLimit = request ?. limit ;
147+ const limit = typeof requestedLimit === "number" && Number . isInteger ( requestedLimit )
148+ ? Math . min ( Math . max ( requestedLimit , 1 ) , MAX_SESSION_PAGE_LIMIT )
149+ : DEFAULT_SESSION_PAGE_LIMIT ;
150+ const requestedOffset = request ?. offset ;
151+ const offset = typeof requestedOffset === "number" && Number . isInteger ( requestedOffset )
152+ ? Math . max ( requestedOffset , 0 )
153+ : 0 ;
154+ const status =
155+ typeof request ?. status === "string" && request . status . length > 0
156+ ? request . status
157+ : null ;
158+ const q =
159+ typeof request ?. q === "string" && request . q . trim ( ) . length > 0
160+ ? request . q . trim ( )
161+ : null ;
162+ return { limit, offset, status, q } ;
163+ }
164+
165+ function pageWhereClause ( status : string | null , q : string | null ) : {
166+ whereSql : string ;
167+ params : SQLInputValue [ ] ;
168+ } {
169+ const where : string [ ] = [ ] ;
170+ const params : SQLInputValue [ ] = [ ] ;
171+ if ( status === "waiting" ) {
172+ where . push ( "s.status NOT IN ('completed', 'abandoned', 'error') AND s.awaiting_input_since IS NOT NULL" ) ;
173+ } else if ( status === "running" ) {
174+ where . push ( "s.status NOT IN ('completed', 'abandoned', 'error') AND s.awaiting_input_since IS NULL" ) ;
175+ } else if ( status && status !== "all" ) {
176+ where . push ( "s.status = ?" ) ;
177+ params . push ( status ) ;
178+ }
179+ if ( q ) {
180+ // Escape SQLite LIKE wildcards so user search for "%" or "_" does literal matching
181+ const escaped = q . replace ( / [ % _ ] / g, ( ch ) => `\\${ ch } ` ) ;
182+ const like = `%${ escaped } %` ;
183+ where . push ( "(s.id LIKE ? ESCAPE '\\' OR s.name LIKE ? ESCAPE '\\' OR s.cwd LIKE ? ESCAPE '\\' OR s.model LIKE ? ESCAPE '\\')" ) ;
184+ params . push ( like , like , like , like ) ;
185+ }
186+ return {
187+ whereSql : where . length > 0 ? `WHERE ${ where . join ( " AND " ) } ` : "" ,
188+ params,
189+ } ;
190+ }
191+
102192 return {
103193 getById ( id : string ) : SessionRow | undefined {
104194 return toRow ( getByIdStmt . get ( id ) as Record < string , unknown > | undefined ) ;
@@ -112,6 +202,11 @@ export function createSessionStore(db: DatabaseSync) {
112202 return rowsToList ( getActiveStmt . all ( ) as Record < string , unknown > [ ] ) ;
113203 } ,
114204
205+ getDetailsById ( id : string ) : SessionWithAgents | undefined {
206+ const row = getDetailsByIdStmt . get ( id ) as Record < string , unknown > | undefined ;
207+ return row ? detailRowsToList ( [ row ] ) [ 0 ] : undefined ;
208+ } ,
209+
115210 getActiveWithDetails ( ) : SessionWithAgents [ ] {
116211 return detailRowsToList (
117212 getActiveWithDetailsStmt . all ( ) as Record < string , unknown > [ ] ,
@@ -135,6 +230,33 @@ export function createSessionStore(db: DatabaseSync) {
135230 ] ;
136231 } ,
137232
233+ getPage ( request ?: SessionPageRequest ) : SessionPage {
234+ const { limit, offset, status, q } = coercePageRequest ( request ) ;
235+ const { whereSql, params } = pageWhereClause ( status , q ) ;
236+ const countStmt = getOrPrepare ( `page-count:${ whereSql } ` ,
237+ `SELECT COUNT(*) as count FROM sessions s ${ whereSql } ` ) ;
238+ const selectStmt = getOrPrepare ( `page-select:${ whereSql } ` ,
239+ `${ SESSION_DETAIL_SELECT } ${ whereSql } ORDER BY s.started_at DESC, s.id DESC LIMIT ? OFFSET ?` ) ;
240+
241+ const totalRow = countStmt . get ( ...params ) as { count : number } ;
242+ const rows = selectStmt . all ( ...params , limit , offset ) as Record < string , unknown > [ ] ;
243+
244+ return {
245+ sessions : detailRowsToList ( rows ) ,
246+ total : totalRow . count ,
247+ limit,
248+ offset,
249+ } ;
250+ } ,
251+
252+ getKanbanPages ( statuses : string [ ] , limit : number ) : KanbanPages {
253+ const result : KanbanPages = { } ;
254+ for ( const status of statuses ) {
255+ result [ status ] = this . getPage ( { limit, status } ) ;
256+ }
257+ return result ;
258+ } ,
259+
138260 invalidateHistoricalDetails ( ) : void {
139261 historicalDetailsCache = null ;
140262 } ,
0 commit comments