diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e49a6dc3..a6462fd1 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "desktop", - "version": "0.15.115", + "version": "0.15.116", "description": "ClosedLoop Desktop", "author": "ClosedLoop AI ", "private": true, diff --git a/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts b/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts index 3a2ccb4e..2a6bf1d1 100644 --- a/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts +++ b/apps/desktop/src/main/agent-dashboard-design-system-runtime.ts @@ -6,6 +6,7 @@ import { loadMeteredUsageRows, type MeteredUsageRow, } from "./reconciliation-worker.js"; +import type { SessionPageRequest } from "../shared/agent-db-contract.js"; import { detectBillingMode } from "./billing-mode-detector.js"; import { openAgentDatabase, type AgentDatabase } from "./database/index.js"; import { coerceDbId } from "./database/ipc-validation.js"; @@ -14,7 +15,10 @@ import { isAgentMonitorHooksEnabled } from "./agent-monitor-hooks.js"; const DESIGN_SYSTEM_DB_IPC_CHANNELS = [ "desktop:db:get-sessions", + "desktop:db:get-sessions-page", + "desktop:db:get-kanban-pages", "desktop:db:get-session", + "desktop:db:get-session-details", "desktop:db:get-agents", "desktop:db:get-events", "desktop:db:get-dashboard-summary", @@ -153,12 +157,28 @@ export function createAgentDashboardDesignSystemRuntime( function registerDesignSystemDbIpcHandlers(agentDatabase: AgentDatabase): void { ipcMain.handle("desktop:db:get-sessions", () => agentDatabase.sessions.getAll()); + ipcMain.handle("desktop:db:get-sessions-page", (_event, request: unknown) => + agentDatabase.sessions.getPage(coerceSessionPageRequest(request)), + ); + + ipcMain.handle("desktop:db:get-kanban-pages", (_event, statuses: unknown, limit: unknown) => { + const safeStatuses = Array.isArray(statuses) ? statuses.filter((s): s is string => typeof s === "string") : []; + const safeLimit = typeof limit === "number" && Number.isInteger(limit) ? Math.min(Math.max(limit, 1), 100) : 25; + return agentDatabase.sessions.getKanbanPages(safeStatuses, safeLimit); + }); + ipcMain.handle("desktop:db:get-session", (_event, id: unknown) => { const sessionId = coerceDbId(id); if (sessionId === null) return undefined; return agentDatabase.sessions.getById(sessionId); }); + ipcMain.handle("desktop:db:get-session-details", (_event, id: unknown) => { + const sessionId = coerceDbId(id); + if (sessionId === null) return undefined; + return agentDatabase.sessions.getDetailsById(sessionId); + }); + ipcMain.handle("desktop:db:get-agents", (_event, sessionId: unknown) => { const id = coerceDbId(sessionId); if (id === null) return []; @@ -224,3 +244,16 @@ function unregisterDesignSystemDbIpcHandlers(): void { ipcMain.removeHandler(channel); } } + +function coerceSessionPageRequest(value: unknown): SessionPageRequest | undefined { + if (typeof value !== "object" || value === null) { + return undefined; + } + const raw = value as Record; + return { + limit: typeof raw.limit === "number" ? raw.limit : undefined, + offset: typeof raw.offset === "number" ? raw.offset : undefined, + status: typeof raw.status === "string" ? raw.status : undefined, + q: typeof raw.q === "string" ? raw.q : undefined, + }; +} diff --git a/apps/desktop/src/main/database/schema.ts b/apps/desktop/src/main/database/schema.ts index 9f46323e..b4b0e3d6 100644 --- a/apps/desktop/src/main/database/schema.ts +++ b/apps/desktop/src/main/database/schema.ts @@ -1,4 +1,4 @@ -export const CURRENT_SCHEMA_VERSION = 6; +export const CURRENT_SCHEMA_VERSION = 7; /** * Each migration runs against the DB when user_version < CURRENT_SCHEMA_VERSION. @@ -132,5 +132,19 @@ ALTER TABLE sessions ADD COLUMN organization_id TEXT; CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id) WHERE user_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_sessions_organization_id ON sessions(organization_id) WHERE organization_id IS NOT NULL; +`, + + // Version 6 -> 7: keep the in-process dashboard waiting-session page bounded + // to the sessions index instead of scanning large historical databases. + ` +CREATE INDEX IF NOT EXISTS idx_sessions_waiting_started_at +ON sessions(started_at DESC) +WHERE awaiting_input_since IS NOT NULL + AND status NOT IN ('completed', 'abandoned', 'error'); + +CREATE INDEX IF NOT EXISTS idx_sessions_running_started_at +ON sessions(started_at DESC) +WHERE awaiting_input_since IS NULL + AND status NOT IN ('completed', 'abandoned', 'error'); `, ]; diff --git a/apps/desktop/src/main/database/sessions.ts b/apps/desktop/src/main/database/sessions.ts index cb08c194..556dff47 100644 --- a/apps/desktop/src/main/database/sessions.ts +++ b/apps/desktop/src/main/database/sessions.ts @@ -1,11 +1,33 @@ -import type { DatabaseSync } from "node:sqlite"; -import type { SessionRow, SessionWithAgents } from "../../shared/agent-db-contract.js"; +import type { DatabaseSync, SQLInputValue } from "node:sqlite"; +import type { + KanbanPages, + SessionPage, + SessionPageRequest, + SessionRow, + SessionWithAgents, +} from "../../shared/agent-db-contract.js"; // Terminal session statuses (vendor + canonical AgentSession vocabulary). A // session not in this set is treated as active. Writes are owned by // `lifecycle.ts`; this store is read-only. const TERMINAL_STATUSES = "('completed', 'abandoned', 'error')"; const TERMINAL_STATUS_SET = new Set(["completed", "abandoned", "error"]); +const MAX_SESSION_PAGE_LIMIT = 100; +const DEFAULT_SESSION_PAGE_LIMIT = 25; +// Correlated subqueries — efficient for bounded queries (LIMIT/single-row). +const SESSION_DETAIL_SELECT = ` + SELECT + s.*, + (SELECT COUNT(*) FROM agents a WHERE a.session_id = s.id) as agent_count, + (SELECT COUNT(*) FROM events e WHERE e.session_id = s.id) as event_count, + ( + SELECT COALESCE(SUM(COALESCE(t.input_tokens, 0) + COALESCE(t.output_tokens, 0)), 0) + FROM token_usage t + WHERE t.session_id = s.id + ) as total_tokens + FROM sessions s +`; +// CTE-based join — efficient for unbounded multi-row queries (active/historical). const SESSION_DETAILS_CTES = ` WITH agent_counts AS ( SELECT session_id, COUNT(*) as agent_count @@ -32,6 +54,10 @@ export function createSessionStore(db: DatabaseSync) { const getActiveStmt = db.prepare( `SELECT * FROM sessions WHERE status NOT IN ${TERMINAL_STATUSES} ORDER BY started_at DESC`, ); + const getDetailsByIdStmt = db.prepare(` + ${SESSION_DETAIL_SELECT} + WHERE s.id = ? + `); const getActiveWithDetailsStmt = db.prepare(` ${SESSION_DETAILS_CTES} @@ -99,6 +125,70 @@ export function createSessionStore(db: DatabaseSync) { }); } + // Statement cache for dynamically-built page queries. Avoids re-preparing + // on every IPC call while keeping the WHERE clause flexible. + const stmtCache = new Map>(); + function getOrPrepare(key: string, sql: string) { + let stmt = stmtCache.get(key); + if (!stmt) { + stmt = db.prepare(sql); + stmtCache.set(key, stmt); + } + return stmt; + } + + function coercePageRequest(request: SessionPageRequest | undefined): { + limit: number; + offset: number; + status: string | null; + q: string | null; + } { + const requestedLimit = request?.limit; + const limit = typeof requestedLimit === "number" && Number.isInteger(requestedLimit) + ? Math.min(Math.max(requestedLimit, 1), MAX_SESSION_PAGE_LIMIT) + : DEFAULT_SESSION_PAGE_LIMIT; + const requestedOffset = request?.offset; + const offset = typeof requestedOffset === "number" && Number.isInteger(requestedOffset) + ? Math.max(requestedOffset, 0) + : 0; + const status = + typeof request?.status === "string" && request.status.length > 0 + ? request.status + : null; + const q = + typeof request?.q === "string" && request.q.trim().length > 0 + ? request.q.trim() + : null; + return { limit, offset, status, q }; + } + + function pageWhereClause(status: string | null, q: string | null): { + whereSql: string; + params: SQLInputValue[]; + } { + const where: string[] = []; + const params: SQLInputValue[] = []; + if (status === "waiting") { + where.push("s.status NOT IN ('completed', 'abandoned', 'error') AND s.awaiting_input_since IS NOT NULL"); + } else if (status === "running") { + where.push("s.status NOT IN ('completed', 'abandoned', 'error') AND s.awaiting_input_since IS NULL"); + } else if (status && status !== "all") { + where.push("s.status = ?"); + params.push(status); + } + if (q) { + // Escape SQLite LIKE wildcards so user search for "%" or "_" does literal matching + const escaped = q.replace(/[%_]/g, (ch) => `\\${ch}`); + const like = `%${escaped}%`; + where.push("(s.id LIKE ? ESCAPE '\\' OR s.name LIKE ? ESCAPE '\\' OR s.cwd LIKE ? ESCAPE '\\' OR s.model LIKE ? ESCAPE '\\')"); + params.push(like, like, like, like); + } + return { + whereSql: where.length > 0 ? `WHERE ${where.join(" AND ")}` : "", + params, + }; + } + return { getById(id: string): SessionRow | undefined { return toRow(getByIdStmt.get(id) as Record | undefined); @@ -112,6 +202,11 @@ export function createSessionStore(db: DatabaseSync) { return rowsToList(getActiveStmt.all() as Record[]); }, + getDetailsById(id: string): SessionWithAgents | undefined { + const row = getDetailsByIdStmt.get(id) as Record | undefined; + return row ? detailRowsToList([row])[0] : undefined; + }, + getActiveWithDetails(): SessionWithAgents[] { return detailRowsToList( getActiveWithDetailsStmt.all() as Record[], @@ -135,6 +230,33 @@ export function createSessionStore(db: DatabaseSync) { ]; }, + getPage(request?: SessionPageRequest): SessionPage { + const { limit, offset, status, q } = coercePageRequest(request); + const { whereSql, params } = pageWhereClause(status, q); + const countStmt = getOrPrepare(`page-count:${whereSql}`, + `SELECT COUNT(*) as count FROM sessions s ${whereSql}`); + const selectStmt = getOrPrepare(`page-select:${whereSql}`, + `${SESSION_DETAIL_SELECT} ${whereSql} ORDER BY s.started_at DESC, s.id DESC LIMIT ? OFFSET ?`); + + const totalRow = countStmt.get(...params) as { count: number }; + const rows = selectStmt.all(...params, limit, offset) as Record[]; + + return { + sessions: detailRowsToList(rows), + total: totalRow.count, + limit, + offset, + }; + }, + + getKanbanPages(statuses: string[], limit: number): KanbanPages { + const result: KanbanPages = {}; + for (const status of statuses) { + result[status] = this.getPage({ limit, status }); + } + return result; + }, + invalidateHistoricalDetails(): void { historicalDetailsCache = null; }, diff --git a/apps/desktop/src/main/preload-design-system.ts b/apps/desktop/src/main/preload-design-system.ts index ba412fbc..280d7098 100644 --- a/apps/desktop/src/main/preload-design-system.ts +++ b/apps/desktop/src/main/preload-design-system.ts @@ -7,6 +7,9 @@ import type { EventCountByType, EventRow, EventWithSession, + KanbanPages, + SessionPage, + SessionPageRequest, SessionRow, SessionWithAgents, TokenAnalytics, @@ -18,10 +21,13 @@ const designSystemDashboardApi = { db: { getSessions: () => ipcRenderer.invoke("desktop:db:get-sessions") as Promise, getSession: (id: string) => ipcRenderer.invoke("desktop:db:get-session", id) as Promise, + getSessionDetails: (id: string) => ipcRenderer.invoke("desktop:db:get-session-details", id) as Promise, getAgents: (sessionId: string) => ipcRenderer.invoke("desktop:db:get-agents", sessionId) as Promise, getEvents: (sessionId: string, agentId?: string) => ipcRenderer.invoke("desktop:db:get-events", sessionId, agentId) as Promise, getDashboardSummary: () => ipcRenderer.invoke("desktop:db:get-dashboard-summary") as Promise, getSessionsWithDetails: () => ipcRenderer.invoke("desktop:db:get-sessions-with-details") as Promise, + getSessionsPage: (request?: SessionPageRequest) => ipcRenderer.invoke("desktop:db:get-sessions-page", request) as Promise, + getKanbanPages: (statuses: string[], limit: number) => ipcRenderer.invoke("desktop:db:get-kanban-pages", statuses, limit) as Promise, getEventFeed: () => ipcRenderer.invoke("desktop:db:get-event-feed") as Promise, getEventsWithSession: (sessionId: string) => ipcRenderer.invoke("desktop:db:get-events-with-session", sessionId) as Promise, getEventCountByType: () => ipcRenderer.invoke("desktop:db:get-event-count-by-type") as Promise, diff --git a/apps/desktop/src/renderer/components/kanban/KanbanView.tsx b/apps/desktop/src/renderer/components/kanban/KanbanView.tsx index 81f532b4..77d040cb 100644 --- a/apps/desktop/src/renderer/components/kanban/KanbanView.tsx +++ b/apps/desktop/src/renderer/components/kanban/KanbanView.tsx @@ -1,8 +1,7 @@ import { useState } from "react"; import { KanbanBoardLayout, KanbanColumn, KanbanCardFrame } from "@closedloop-ai/design-system/components/ui/layout/kanban-board"; -import { Badge } from "@closedloop-ai/design-system/components/ui/badge"; import { useQueryCache } from "../../hooks/useQueryCache"; -import type { SessionWithAgents } from "../../../shared/agent-db-contract"; +import type { KanbanPages, SessionWithAgents } from "../../../shared/agent-db-contract"; function PlayIcon() { return ; } function ClockIcon() { return ; } @@ -11,21 +10,24 @@ function XIcon() { return function StopIcon() { return ; } const COLUMNS = [ - { key: "running", label: "Running", icon: PlayIcon(), color: "text-blue-400" }, - { key: "waiting", label: "Waiting", icon: ClockIcon(), color: "text-yellow-400" }, - { key: "completed", label: "Completed", icon: CheckIcon(), color: "text-green-400" }, - { key: "failed", label: "Failed", icon: XIcon(), color: "text-red-400" }, - { key: "stopped", label: "Stopped", icon: StopIcon(), color: "text-zinc-400" }, + { key: "running", label: "Running", status: "running", icon: PlayIcon(), color: "text-blue-400" }, + { key: "waiting", label: "Waiting", status: "waiting", icon: ClockIcon(), color: "text-yellow-400" }, + { key: "completed", label: "Completed", status: "completed", icon: CheckIcon(), color: "text-green-400" }, + { key: "failed", label: "Failed", status: "error", icon: XIcon(), color: "text-red-400" }, + { key: "stopped", label: "Stopped", status: "abandoned", icon: StopIcon(), color: "text-zinc-400" }, ]; +const KANBAN_COLUMN_LIMIT = 25; + +const KANBAN_STATUSES = COLUMNS.map((c) => c.status); export function KanbanView() { - const { data: sessions, loading } = useQueryCache( - "db:sessions-details", - () => window.desktopApi.db.getSessionsWithDetails() as Promise, + const { data: pages, loading } = useQueryCache( + "db:kanban-session-pages", + () => window.desktopApi.db.getKanbanPages(KANBAN_STATUSES, KANBAN_COLUMN_LIMIT), ); const [selectedId, setSelectedId] = useState(null); - if (loading || !sessions) { + if (loading || !pages) { return (

Loading...

@@ -33,34 +35,26 @@ export function KanbanView() { ); } - const grouped: Record = {}; - for (const col of COLUMNS) { - grouped[col.key] = sessions.filter((s) => s.status === col.key); - } - - const uncategorized = sessions.filter( - (s) => !COLUMNS.some((c) => c.key === s.status), - ); - return (

My Tasks

- Sessions grouped by status + Recent sessions grouped by status

{COLUMNS.map((col) => { - const items = grouped[col.key] ?? []; + const page = pages[col.key]; + const items: SessionWithAgents[] = page?.sessions ?? []; return ( @@ -96,24 +90,14 @@ export function KanbanView() { ))} + {(page?.total ?? 0) > items.length ? ( +
+ Showing latest {items.length} of {page?.total} +
+ ) : null}
); })} - - {uncategorized.length > 0 && ( - - {uncategorized.map((session) => ( - -

{session.name ?? "Unnamed"}

- {session.status} -
- ))} -
- )}
diff --git a/apps/desktop/src/renderer/components/sessions/SessionDetailView.tsx b/apps/desktop/src/renderer/components/sessions/SessionDetailView.tsx index 8c815687..8345105a 100644 --- a/apps/desktop/src/renderer/components/sessions/SessionDetailView.tsx +++ b/apps/desktop/src/renderer/components/sessions/SessionDetailView.tsx @@ -65,11 +65,9 @@ function AgentNode({ node, depth }: { node: AgentHierarchyNode; depth: number }) } export function SessionDetailView({ sessionId, onBack }: { sessionId: string; onBack: () => void }) { - // Reuses the shared db:sessions-details cache (also feeding the list/kanban), - // so opening a detail view issues no extra round-trip for header data. - const { data: sessions } = useQueryCache( - "db:sessions-details", - () => window.desktopApi.db.getSessionsWithDetails(), + const { data: session } = useQueryCache( + `db:session-details:${sessionId}`, + () => window.desktopApi.db.getSessionDetails(sessionId), ); const { data: hierarchy } = useQueryCache( `db:agent-hierarchy:${sessionId}`, @@ -80,7 +78,6 @@ export function SessionDetailView({ sessionId, onBack }: { sessionId: string; on () => window.desktopApi.db.getEvents(sessionId), ); - const session = (sessions ?? []).find((s) => s.id === sessionId); const timeline = (events ?? []).slice(-EVENT_TIMELINE_CAP).reverse(); const truncated = (events?.length ?? 0) > EVENT_TIMELINE_CAP; diff --git a/apps/desktop/src/renderer/components/sessions/SessionsView.tsx b/apps/desktop/src/renderer/components/sessions/SessionsView.tsx index a7b87eb1..a83826f7 100644 --- a/apps/desktop/src/renderer/components/sessions/SessionsView.tsx +++ b/apps/desktop/src/renderer/components/sessions/SessionsView.tsx @@ -1,11 +1,11 @@ -import { useState, useMemo } from "react"; +import { useEffect, useState } from "react"; import { Button } from "@closedloop-ai/design-system/components/ui/button"; import { MetricCard } from "@closedloop-ai/design-system/components/ui/primitives/metric-card"; import { SessionTable } from "@closedloop-ai/design-system/components/ui/composites/session-table"; import { MonitorDot, Activity, Bot, Coins } from "lucide-react"; import { useQueryCache } from "../../hooks/useQueryCache"; import type { SessionRow } from "@closedloop-ai/design-system/components/ui/types"; -import type { SessionWithAgents } from "../../../shared/agent-db-contract"; +import type { DashboardSummary, SessionPage, SessionWithAgents } from "../../../shared/agent-db-contract"; const OVERVIEW_CARD_CLASS_NAME = "min-h-0 gap-0 rounded-xl border-border/70 bg-card shadow-sm [&>div:first-child]:px-5 [&>div:first-child]:pt-4 [&>div:first-child]:pb-2 [&_[data-slot='card-description']]:text-[10px] [&_[data-slot='card-title']]:text-[1.75rem] [&>div:last-child]:px-5 [&>div:last-child]:pb-4 [&>div:last-child]:text-xs"; @@ -28,45 +28,46 @@ function adaptSession(raw: SessionWithAgents): SessionRow { } const STATUS_OPTIONS = ["all", "active", "waiting", "completed", "abandoned", "error"] as const; -const TERMINAL_STATUSES = ["completed", "abandoned", "error"]; +const PAGE_SIZE = 25; interface SessionsViewProps { showOverview?: boolean; } export function SessionsView({ showOverview = true }: SessionsViewProps) { - const { data: sessions, loading } = useQueryCache( - "db:sessions-details", - () => window.desktopApi.db.getSessionsWithDetails(), - ); const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); + const [page, setPage] = useState(0); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedSearch(search), 300); + return () => clearTimeout(timer); + }, [search]); - const allSessions = sessions ?? []; + const { data: summary } = useQueryCache( + "db:summary", + () => window.desktopApi.db.getDashboardSummary(), + ); + const { data: sessionPage, loading } = useQueryCache( + `db:sessions-page:${page}:${statusFilter}:${debouncedSearch}`, + () => window.desktopApi.db.getSessionsPage({ + limit: PAGE_SIZE, + offset: page * PAGE_SIZE, + status: statusFilter === "all" ? undefined : statusFilter, + q: debouncedSearch || undefined, + }), + ); - const filtered = useMemo(() => { - let result = allSessions; - if (statusFilter !== "all") { - result = result.filter((s) => s.status === statusFilter); - } - if (search) { - const q = search.toLowerCase(); - result = result.filter( - (s) => - (s.name ?? "").toLowerCase().includes(q) || - s.id.toLowerCase().includes(q) || - (s.cwd ?? "").toLowerCase().includes(q) || - (s.model ?? "").toLowerCase().includes(q), - ); - } - return result; - }, [allSessions, statusFilter, search]); + useEffect(() => { + setPage(0); + }, [debouncedSearch, statusFilter]); - const totalAgents = allSessions.reduce((a, s) => a + s.agentCount, 0); - const totalTokens = allSessions.reduce((a, s) => a + s.totalTokens, 0); - const activeSessions = allSessions.filter( - (s) => !TERMINAL_STATUSES.includes(s.status), - ).length; + const sessions = sessionPage?.sessions ?? []; + const total = sessionPage?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const from = total === 0 ? 0 : page * PAGE_SIZE + 1; + const to = Math.min((page + 1) * PAGE_SIZE, total); if (loading) { return ( @@ -83,15 +84,15 @@ export function SessionsView({ showOverview = true }: SessionsViewProps) {

Sessions

- All agent sessions ({allSessions.length} total) + All agent sessions ({summary?.totalSessions ?? total} total)

- - - - + + + +
) : null} @@ -105,7 +106,7 @@ export function SessionsView({ showOverview = true }: SessionsViewProps) {

- {filtered.length.toLocaleString()} shown + {from.toLocaleString()}-{to.toLocaleString()} of {total.toLocaleString()} shown
@@ -135,7 +136,7 @@ export function SessionsView({ showOverview = true }: SessionsViewProps) {
`#tab=dashboard&sessionId=${encodeURIComponent(row.id)}`} emptyState={
@@ -146,6 +147,31 @@ export function SessionsView({ showOverview = true }: SessionsViewProps) { } />
+ {totalPages > 1 ? ( +
+ + Page {page + 1} of {totalPages} + +
+ + +
+
+ ) : null}
diff --git a/apps/desktop/src/renderer/hooks/useQueryCache.ts b/apps/desktop/src/renderer/hooks/useQueryCache.ts index 5849e197..a19dd5a1 100644 --- a/apps/desktop/src/renderer/hooks/useQueryCache.ts +++ b/apps/desktop/src/renderer/hooks/useQueryCache.ts @@ -44,6 +44,10 @@ export function useQueryCache( return; } + // No fresh cache hit; set loading so the consumer can render a loading + // state instead of displaying stale data from a previous key. + if (mounted) setLoading(true); + fetcherRef.current() .then((result) => { cache.set(key, { data: result, fetchedAt: Date.now() }); @@ -54,7 +58,10 @@ export function useQueryCache( } }) .catch(() => { - if (mounted) setError(true); + if (mounted) { + setLoading(false); + setError(true); + } }); }; diff --git a/apps/desktop/src/renderer/types/desktop-api.d.ts b/apps/desktop/src/renderer/types/desktop-api.d.ts index c0a5468b..92e157af 100644 --- a/apps/desktop/src/renderer/types/desktop-api.d.ts +++ b/apps/desktop/src/renderer/types/desktop-api.d.ts @@ -4,6 +4,9 @@ import type { EventRow, EventWithSession, EventCountByType, + KanbanPages, + SessionPage, + SessionPageRequest, SessionWithAgents, DashboardSummary, TokenAnalytics, @@ -104,10 +107,13 @@ export interface DesktopApi { db: { getSessions: () => Promise; getSession: (id: string) => Promise; + getSessionDetails: (id: string) => Promise; getAgents: (sessionId: string) => Promise; getEvents: (sessionId: string, agentId?: string) => Promise; getDashboardSummary: () => Promise; getSessionsWithDetails: () => Promise; + getSessionsPage: (request?: SessionPageRequest) => Promise; + getKanbanPages: (statuses: string[], limit: number) => Promise; getEventFeed: () => Promise; getEventsWithSession: (sessionId: string) => Promise; getEventCountByType: () => Promise; diff --git a/apps/desktop/src/shared/agent-db-contract.ts b/apps/desktop/src/shared/agent-db-contract.ts index 3461e7e7..bc7f7d59 100644 --- a/apps/desktop/src/shared/agent-db-contract.ts +++ b/apps/desktop/src/shared/agent-db-contract.ts @@ -81,6 +81,22 @@ export interface SessionWithAgents extends SessionRow { estimatedCostUsd?: number; } +export interface SessionPageRequest { + limit?: number; + offset?: number; + status?: string; + q?: string; +} + +export interface SessionPage { + sessions: SessionWithAgents[]; + total: number; + limit: number; + offset: number; +} + +export type KanbanPages = Record; + export interface EventWithSession extends EventRow { sessionName: string | null; } diff --git a/apps/desktop/test/agent-session-pagination.test.ts b/apps/desktop/test/agent-session-pagination.test.ts new file mode 100644 index 00000000..ecf6fe59 --- /dev/null +++ b/apps/desktop/test/agent-session-pagination.test.ts @@ -0,0 +1,269 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import { openAgentDatabase } from "../src/main/database/index.js"; + +function openTempDb() { + const dir = mkdtempSync(path.join(tmpdir(), "agent-session-pagination-")); + const db = openAgentDatabase(path.join(dir, "agent-dashboard.sqlite")); + return { + db, + cleanup() { + db.close(); + rmSync(dir, { recursive: true, force: true }); + }, + }; +} + +function insertSession( + db: ReturnType, + id: string, + overrides: { + name?: string; + status?: string; + cwd?: string; + model?: string; + startedAt?: string; + awaitingInputSince?: string | null; + } = {}, +) { + db.connection.prepare(` + INSERT INTO sessions ( + id, + name, + status, + cwd, + model, + started_at, + updated_at, + awaiting_input_since, + harness + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + overrides.name ?? `Session ${id}`, + overrides.status ?? "completed", + overrides.cwd ?? `/work/${id}`, + overrides.model ?? "gpt-5", + overrides.startedAt ?? `2024-03-09T16:${id.padStart(2, "0")}:00.000Z`, + overrides.startedAt ?? `2024-03-09T16:${id.padStart(2, "0")}:00.000Z`, + overrides.awaitingInputSince ?? null, + "codex", + ); +} + +function insertAgent(db: ReturnType, id: string, sessionId: string) { + db.connection.prepare(` + INSERT INTO agents (id, session_id, name, type, status, started_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run(id, sessionId, `Agent ${id}`, "main", "completed", "2024-03-09T16:00:00.000Z"); +} + +function insertEvent(db: ReturnType, id: string, sessionId: string) { + db.connection.prepare(` + INSERT INTO events (id, session_id, event_type, created_at) + VALUES (?, ?, ?, ?) + `).run(id, sessionId, "Stop", "2024-03-09T16:00:00.000Z"); +} + +function insertTokenUsage(db: ReturnType, sessionId: string, input: number, output: number) { + db.connection.prepare(` + INSERT INTO token_usage ( + session_id, + model, + input_tokens, + output_tokens, + raw_input, + raw_output + ) + VALUES (?, ?, ?, ?, ?, ?) + `).run(sessionId, "gpt-5", input, output, input, output); +} + +test("sessions.getPage returns bounded session details and total count", () => { + const { db, cleanup } = openTempDb(); + try { + for (let i = 1; i <= 5; i += 1) { + insertSession(db, `s${i}`, { + startedAt: `2024-03-09T16:0${i}:00.000Z`, + }); + } + insertAgent(db, "a4-main", "s4"); + insertAgent(db, "a4-sub", "s4"); + insertEvent(db, "e4-1", "s4"); + insertEvent(db, "e4-2", "s4"); + insertEvent(db, "e4-3", "s4"); + insertTokenUsage(db, "s4", 100, 25); + + const page = db.sessions.getPage({ limit: 2, offset: 1 }); + + assert.equal(page.total, 5); + assert.equal(page.limit, 2); + assert.equal(page.offset, 1); + assert.deepEqual(page.sessions.map((session) => session.id), ["s4", "s3"]); + assert.equal(page.sessions[0].agentCount, 2); + assert.equal(page.sessions[0].eventCount, 3); + assert.equal(page.sessions[0].totalTokens, 125); + + const details = db.sessions.getDetailsById("s4"); + assert.ok(details); + assert.equal(details.agentCount, 2); + assert.equal(details.eventCount, 3); + assert.equal(details.totalTokens, 125); + } finally { + cleanup(); + } +}); + +test("sessions.getPage clamps runaway limits", () => { + const { db, cleanup } = openTempDb(); + try { + for (let i = 1; i <= 105; i += 1) { + insertSession(db, `s${i}`, { + startedAt: `2024-03-09T16:${String(i).padStart(3, "0")}:00.000Z`, + }); + } + + const page = db.sessions.getPage({ limit: 1000, offset: -10 }); + + assert.equal(page.total, 105); + assert.equal(page.limit, 100); + assert.equal(page.offset, 0); + assert.equal(page.sessions.length, 100); + } finally { + cleanup(); + } +}); + +test("sessions.getPage supports renderer status and search filters", () => { + const { db, cleanup } = openTempDb(); + try { + insertSession(db, "active-1", { + status: "active", + name: "Renderer Shell", + cwd: "/repo/closedloop-electron", + startedAt: "2024-03-09T16:03:00.000Z", + }); + insertSession(db, "waiting-1", { + status: "active", + name: "Needs Input", + cwd: "/repo/design-system", + startedAt: "2024-03-09T16:02:00.000Z", + awaitingInputSince: "2024-03-09T16:02:30.000Z", + }); + insertSession(db, "completed-1", { + status: "completed", + name: "Historical Session", + model: "claude-sonnet-4-6", + startedAt: "2024-03-09T16:01:00.000Z", + }); + + const waiting = db.sessions.getPage({ status: "waiting" }); + assert.deepEqual(waiting.sessions.map((session) => session.id), ["waiting-1"]); + assert.equal(waiting.total, 1); + + const running = db.sessions.getPage({ status: "running" }); + assert.deepEqual(running.sessions.map((session) => session.id), ["active-1"]); + assert.equal(running.total, 1); + + const completed = db.sessions.getPage({ status: "completed" }); + assert.deepEqual(completed.sessions.map((session) => session.id), ["completed-1"]); + + const search = db.sessions.getPage({ q: "closedloop" }); + assert.deepEqual(search.sessions.map((session) => session.id), ["active-1"]); + } finally { + cleanup(); + } +}); + +test("sessions.getPage escapes LIKE wildcards in search queries", () => { + const { db, cleanup } = openTempDb(); + try { + insertSession(db, "s-percent", { + name: "100% done", + startedAt: "2024-03-09T16:03:00.000Z", + }); + insertSession(db, "s-underscore", { + name: "task_runner", + startedAt: "2024-03-09T16:02:00.000Z", + }); + insertSession(db, "s-normal", { + name: "normal session", + startedAt: "2024-03-09T16:01:00.000Z", + }); + + // "%" should match only the session with a literal percent, not all sessions + const percentSearch = db.sessions.getPage({ q: "%" }); + assert.deepEqual(percentSearch.sessions.map((s) => s.id), ["s-percent"]); + assert.equal(percentSearch.total, 1); + + // "_" should match only the session with a literal underscore, not single-char wildcards + const underscoreSearch = db.sessions.getPage({ q: "_" }); + assert.deepEqual(underscoreSearch.sessions.map((s) => s.id), ["s-underscore"]); + assert.equal(underscoreSearch.total, 1); + } finally { + cleanup(); + } +}); + +test("sessions.getPage paginates deterministically with tied timestamps", () => { + const { db, cleanup } = openTempDb(); + try { + // All sessions share the same started_at — tiebreaker is s.id DESC + const sharedTime = "2024-03-09T16:00:00.000Z"; + for (const id of ["aaa", "bbb", "ccc", "ddd", "eee"]) { + insertSession(db, id, { startedAt: sharedTime }); + } + + const page1 = db.sessions.getPage({ limit: 2, offset: 0 }); + const page2 = db.sessions.getPage({ limit: 2, offset: 2 }); + const page3 = db.sessions.getPage({ limit: 2, offset: 4 }); + + const allPaged = [ + ...page1.sessions.map((s) => s.id), + ...page2.sessions.map((s) => s.id), + ...page3.sessions.map((s) => s.id), + ]; + + // Should have 5 unique IDs with no duplicates or skips + assert.equal(new Set(allPaged).size, 5, "no duplicates across pages"); + assert.equal(allPaged.length, 5, "no skipped sessions"); + + // ORDER BY id DESC means: eee, ddd, ccc, bbb, aaa + assert.deepEqual(allPaged, ["eee", "ddd", "ccc", "bbb", "aaa"]); + } finally { + cleanup(); + } +}); + +test("sessions.getKanbanPages returns all status pages in a single call", () => { + const { db, cleanup } = openTempDb(); + try { + insertSession(db, "run-1", { + status: "active", + startedAt: "2024-03-09T16:03:00.000Z", + }); + insertSession(db, "wait-1", { + status: "active", + startedAt: "2024-03-09T16:02:00.000Z", + awaitingInputSince: "2024-03-09T16:02:30.000Z", + }); + insertSession(db, "done-1", { + status: "completed", + startedAt: "2024-03-09T16:01:00.000Z", + }); + + const pages = db.sessions.getKanbanPages(["running", "waiting", "completed"], 25); + + assert.deepEqual(Object.keys(pages).sort(), ["completed", "running", "waiting"]); + assert.deepEqual(pages.running.sessions.map((s) => s.id), ["run-1"]); + assert.deepEqual(pages.waiting.sessions.map((s) => s.id), ["wait-1"]); + assert.deepEqual(pages.completed.sessions.map((s) => s.id), ["done-1"]); + } finally { + cleanup(); + } +}); diff --git a/apps/desktop/test/session-identity-columns.test.ts b/apps/desktop/test/session-identity-columns.test.ts index fac4de95..0848b5d6 100644 --- a/apps/desktop/test/session-identity-columns.test.ts +++ b/apps/desktop/test/session-identity-columns.test.ts @@ -31,7 +31,7 @@ function cleanup(db: ReturnType, dir: string) { test("schema v6 migration adds user_id and organization_id columns to sessions", () => { const { db, dir } = makeTmpDb(); try { - assert.equal(CURRENT_SCHEMA_VERSION, 6, "schema version bumped to 6"); + assert.ok(CURRENT_SCHEMA_VERSION >= 6, "schema includes the v6 identity-column migration"); // Verify columns exist by inserting a row with user_id/organization_id db.connection.exec(` @@ -270,7 +270,7 @@ test("v5 → v6 upgrade preserves existing session data", () => { const db = openAgentDatabase(dbPath); try { const version = (db.connection.prepare("PRAGMA user_version").get() as { user_version: number }).user_version; - assert.equal(version, 6, "schema upgraded to v6"); + assert.equal(version, CURRENT_SCHEMA_VERSION, "schema upgraded to the current version"); // Verify pre-existing session still has all its data const session = db.sessions.getById("pre-upgrade");