From 3bbb42c23324c3cbb5587f2bcab09b1dc23086a8 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:52:18 +0300 Subject: [PATCH] fix(sessions): canonicalize sidebar ids and timestamps The sidebar could keep a provider-native id after backend remapping. That left a duplicate non-working session visible until refresh. Fresh sessions could also appear hours old. SQLite CURRENT_TIMESTAMP is UTC without a timezone suffix. Browser parsing then treated those values like local time. Broadcast a canonical session_upserted event when the provider id is mapped. Collapse provider-id aliases onto the stable app session id in the client. Normalize session-row timestamps to ISO UTC when reading from the repository. --- .../sessions.db.integration.test.ts | 12 +++ .../database/repositories/sessions.db.ts | 51 ++++++++-- .../services/chat-run-registry.service.ts | 56 ++++++++++- .../websocket/tests/chat-run-registry.test.ts | 17 ++-- src/hooks/useProjectsState.ts | 93 +++++++++++++++++-- 5 files changed, 205 insertions(+), 24 deletions(-) diff --git a/server/modules/database/repositories/sessions.db.integration.test.ts b/server/modules/database/repositories/sessions.db.integration.test.ts index d14ec5ae..ecc11c99 100644 --- a/server/modules/database/repositories/sessions.db.integration.test.ts +++ b/server/modules/database/repositories/sessions.db.integration.test.ts @@ -70,3 +70,15 @@ test('createSession reactivates archived rows when the session becomes active ag assert.equal(restoredSession?.isArchived, 0); }); }); + +test('repository reads normalize SQLite UTC timestamps to ISO strings', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('session-timezone', 'claude', '/workspace/demo-project'); + + const row = sessionsDb.getSessionById('session-timezone'); + assert.ok(row?.created_at.endsWith('Z')); + assert.ok(row?.updated_at.endsWith('Z')); + assert.match(row?.created_at ?? '', /^\d{4}-\d{2}-\d{2}T/); + assert.match(row?.updated_at ?? '', /^\d{4}-\d{2}-\d{2}T/); + }); +}); diff --git a/server/modules/database/repositories/sessions.db.ts b/server/modules/database/repositories/sessions.db.ts index a1aa26b8..698c3e16 100644 --- a/server/modules/database/repositories/sessions.db.ts +++ b/server/modules/database/repositories/sessions.db.ts @@ -17,10 +17,19 @@ type SessionRow = { const SESSION_ROW_COLUMNS = 'session_id, provider, provider_session_id, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at'; +const SQLITE_UTC_TIMESTAMP_REGEX = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/; + function normalizeTimestamp(value?: string): string | null { if (!value) return null; - const parsed = new Date(value); + // SQLite CURRENT_TIMESTAMP is stored as UTC without a timezone suffix. + // Normalize it here so every session reader returns canonical ISO strings + // and the sidebar never interprets fresh rows as local-time "hours old". + const normalizedValue = SQLITE_UTC_TIMESTAMP_REGEX.test(value) + ? `${value.replace(' ', 'T')}Z` + : value; + + const parsed = new Date(normalizedValue); if (Number.isNaN(parsed.getTime())) { return null; } @@ -28,6 +37,22 @@ function normalizeTimestamp(value?: string): string | null { return parsed.toISOString(); } +function normalizeSessionRow(row: T): T { + if (!row) { + return row; + } + + return { + ...row, + created_at: normalizeTimestamp(row.created_at) ?? row.created_at, + updated_at: normalizeTimestamp(row.updated_at) ?? row.updated_at, + }; +} + +function normalizeSessionRows(rows: SessionRow[]): SessionRow[] { + return rows.map((row) => normalizeSessionRow(row) as SessionRow); +} + function normalizeProjectPathForProvider(provider: string, projectPath: string): string { void provider; return normalizeProjectPath(projectPath); @@ -207,7 +232,7 @@ export const sessionsDb = { ) .get(sessionId) as SessionRow | undefined; - return row ?? null; + return normalizeSessionRow(row) ?? null; }, /** @@ -229,18 +254,20 @@ export const sessionsDb = { ) .get(providerSessionId) as SessionRow | undefined; - return row ?? null; + return normalizeSessionRow(row) ?? null; }, getAllSessions(): SessionRow[] { const db = getConnection(); - return db + const rows = db .prepare( `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE isArchived = 0` ) .all() as SessionRow[]; + + return normalizeSessionRows(rows); }, /** @@ -249,7 +276,7 @@ export const sessionsDb = { */ getArchivedSessions(): SessionRow[] { const db = getConnection(); - return db + const rows = db .prepare( `SELECT ${SESSION_ROW_COLUMNS} FROM sessions @@ -257,12 +284,14 @@ export const sessionsDb = { ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC` ) .all() as SessionRow[]; + + return normalizeSessionRows(rows); }, getSessionsByProjectPath(projectPath: string): SessionRow[] { const db = getConnection(); const normalizedProjectPath = normalizeProjectPath(projectPath); - return db + const rows = db .prepare( `SELECT ${SESSION_ROW_COLUMNS} FROM sessions @@ -270,6 +299,8 @@ export const sessionsDb = { AND isArchived = 0` ) .all(normalizedProjectPath) as SessionRow[]; + + return normalizeSessionRows(rows); }, /** @@ -279,19 +310,21 @@ export const sessionsDb = { getSessionsByProjectPathIncludingArchived(projectPath: string): SessionRow[] { const db = getConnection(); const normalizedProjectPath = normalizeProjectPath(projectPath); - return db + const rows = db .prepare( `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE project_path = ?` ) .all(normalizedProjectPath) as SessionRow[]; + + return normalizeSessionRows(rows); }, getSessionsByProjectPathPage(projectPath: string, limit: number, offset: number): SessionRow[] { const db = getConnection(); const normalizedProjectPath = normalizeProjectPath(projectPath); - return db + const rows = db .prepare( `SELECT ${SESSION_ROW_COLUMNS} FROM sessions @@ -301,6 +334,8 @@ export const sessionsDb = { LIMIT ? OFFSET ?` ) .all(normalizedProjectPath, limit, offset) as SessionRow[]; + + return normalizeSessionRows(rows); }, countSessionsByProjectPath(projectPath: string): number { diff --git a/server/modules/websocket/services/chat-run-registry.service.ts b/server/modules/websocket/services/chat-run-registry.service.ts index c807f209..a5e51b5f 100644 --- a/server/modules/websocket/services/chat-run-registry.service.ts +++ b/server/modules/websocket/services/chat-run-registry.service.ts @@ -1,5 +1,9 @@ -import { sessionsDb } from '@/modules/database/index.js'; +import path from 'node:path'; + +import { projectsDb, sessionsDb } from '@/modules/database/index.js'; +import { generateDisplayName } from '@/modules/projects/index.js'; import { ChatSessionWriter } from '@/modules/websocket/services/chat-session-writer.service.js'; +import { connectedClients, WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js'; import type { LLMProvider, NormalizedMessage, @@ -58,6 +62,48 @@ const MAX_BUFFERED_EVENTS_PER_RUN = 5000; */ const runs = new Map(); +async function broadcastCanonicalSessionUpsert(appSessionId: string): Promise { + const row = sessionsDb.getSessionById(appSessionId); + if (!row || row.isArchived) { + return; + } + + const projectPath = row.project_path; + const project = projectPath ? projectsDb.getProjectPath(projectPath) : null; + const displayName = project?.custom_project_name?.trim() + ? project.custom_project_name + : await generateDisplayName(path.basename(projectPath ?? '') || (projectPath ?? ''), projectPath); + + const payload = JSON.stringify({ + kind: 'session_upserted', + sessionId: row.session_id, + providerSessionId: row.provider_session_id, + provider: row.provider, + session: { + id: row.session_id, + summary: row.custom_name || '', + messageCount: 0, + lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(), + }, + project: project + ? { + projectId: project.project_id, + path: project.project_path, + fullPath: project.project_path, + displayName, + isStarred: Boolean(project.isStarred), + } + : null, + timestamp: new Date().toISOString(), + }); + + connectedClients.forEach((client) => { + if (client.readyState === WS_OPEN_STATE) { + client.send(payload); + } + }); +} + function evictRunLater(appSessionId: string): void { const timer = setTimeout(() => { const run = runs.get(appSessionId); @@ -132,6 +178,14 @@ function recordProviderSessionId(run: ChatRun, providerSessionId: string): void try { sessionsDb.assignProviderSessionId(run.appSessionId, providerSessionId); + void broadcastCanonicalSessionUpsert(run.appSessionId).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error('[ChatRunRegistry] Failed to broadcast canonical session mapping', { + appSessionId: run.appSessionId, + providerSessionId, + error: message, + }); + }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error('[ChatRunRegistry] Failed to persist provider session id mapping', { diff --git a/server/modules/websocket/tests/chat-run-registry.test.ts b/server/modules/websocket/tests/chat-run-registry.test.ts index bc33b897..cc6250c0 100644 --- a/server/modules/websocket/tests/chat-run-registry.test.ts +++ b/server/modules/websocket/tests/chat-run-registry.test.ts @@ -6,7 +6,7 @@ import test from 'node:test'; import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js'; import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.js'; -import type { NormalizedMessage } from '@/shared/types.js'; +import { connectedClients } from '@/modules/websocket/services/websocket-state.service.js'; /** * Minimal stand-in for a websocket connection: collects every JSON frame the @@ -14,10 +14,10 @@ import type { NormalizedMessage } from '@/shared/types.js'; */ class FakeConnection { readyState = 1; // WS_OPEN_STATE - frames: NormalizedMessage[] = []; + frames: Array> = []; send(data: string): void { - this.frames.push(JSON.parse(data) as NormalizedMessage); + this.frames.push(JSON.parse(data) as Record); } } @@ -33,6 +33,7 @@ async function withIsolatedDatabase(runTest: () => void | Promise): Promis try { await runTest(); } finally { + connectedClients.clear(); chatRunRegistry.clearAll(); closeConnection(); if (previousDatabasePath === undefined) { @@ -72,6 +73,7 @@ test('session_created is swallowed and persisted as the provider-id mapping', as await withIsolatedDatabase(() => { sessionsDb.createAppSession('app-run-2', 'cursor', '/workspace/demo'); const connection = new FakeConnection(); + connectedClients.add(connection as never); const run = chatRunRegistry.startRun({ appSessionId: 'app-run-2', provider: 'cursor', @@ -88,9 +90,12 @@ test('session_created is swallowed and persisted as the provider-id mapping', as newSessionId: 'cursor-native-7', }); - // Never forwarded to the client... - assert.equal(connection.frames.length, 0); - // ...but recorded in the registry and persisted in the database. + // The provider-native event itself is never forwarded... + const sessionUpserts = connection.frames.filter((frame) => frame.kind === 'session_upserted'); + assert.equal(sessionUpserts.length, 1); + assert.equal(sessionUpserts[0]?.sessionId, 'app-run-2'); + assert.equal(sessionUpserts[0]?.providerSessionId, 'cursor-native-7'); + // ...but the canonical mapping is recorded and persisted in the database. assert.equal(run.providerSessionId, 'cursor-native-7'); assert.equal(sessionsDb.getSessionById('app-run-2')?.provider_session_id, 'cursor-native-7'); }); diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts index 6d0dcfc8..a81ab617 100644 --- a/src/hooks/useProjectsState.ts +++ b/src/hooks/useProjectsState.ts @@ -29,6 +29,7 @@ type UseProjectsStateArgs = { */ type SessionUpsertedEvent = ServerEvent & { sessionId: string; + providerSessionId?: string | null; provider: LLMProvider; session: ProjectSession; project: { @@ -212,6 +213,26 @@ const mergeProjectSessionPage = ( return mergedProject; }; +const getSessionAliasIds = (event: SessionUpsertedEvent): Set => { + const ids = new Set(); + const add = (value: unknown) => { + if (typeof value !== 'string') { + return; + } + + const trimmed = value.trim(); + if (trimmed) { + ids.add(trimmed); + } + }; + + add(event.sessionId); + add(event.providerSessionId); + add(event.session?.id); + + return ids; +}; + /** * Resolves which provider bucket on a `Project` holds sessions for a provider. * The legacy payload keeps Claude sessions in `sessions` and the other @@ -237,23 +258,47 @@ const providerBucketKey = ( const upsertSessionIntoProject = (project: Project, event: SessionUpsertedEvent): Project => { const bucketKey = providerBucketKey(event.provider); const bucket = project[bucketKey] ?? []; - const existingIndex = bucket.findIndex((session) => session.id === event.sessionId); + const aliasIds = getSessionAliasIds(event); + const normalizedSession: ProjectSession = { + ...event.session, + id: event.sessionId, + }; + const existingIndex = bucket.findIndex((session) => aliasIds.has(String(session.id))); let nextBucket: ProjectSession[]; + let inserted = false; if (existingIndex >= 0) { - const existing = bucket[existingIndex]; - const updated = { ...existing, ...event.session }; - if (serialize(existing) === serialize(updated)) { + let changed = false; + nextBucket = []; + + for (const [index, session] of bucket.entries()) { + if (index === existingIndex) { + const updated = { ...session, ...normalizedSession }; + if (serialize(session) !== serialize(updated)) { + changed = true; + } + nextBucket.push(updated); + continue; + } + + if (aliasIds.has(String(session.id))) { + changed = true; + continue; + } + + nextBucket.push(session); + } + + if (!changed) { return project; } - nextBucket = [...bucket]; - nextBucket[existingIndex] = updated; } else { - nextBucket = [event.session, ...bucket]; + nextBucket = [normalizedSession, ...bucket]; + inserted = true; } const next: Project = { ...project, [bucketKey]: nextBucket }; - if (existingIndex < 0) { + if (inserted) { const total = Number(project.sessionMeta?.total ?? 0) + 1; next.sessionMeta = { ...project.sessionMeta, @@ -629,10 +674,40 @@ export function useProjectsState({ const updated = upsertSessionIntoProject(previousProject, upsert); return updated === previousProject ? previousProject : updated; }); + + const aliasedSelectedSessionId = + typeof upsert.providerSessionId === 'string' && upsert.providerSessionId !== upsert.sessionId + ? upsert.providerSessionId + : null; + if (!aliasedSelectedSessionId) { + return; + } + + const normalizedSelectedSession: ProjectSession = { + ...upsert.session, + id: upsert.sessionId, + __provider: upsert.provider, + __projectId: upsert.project?.projectId ?? currentSelectedSession?.__projectId, + }; + + setSelectedSession((previousSession) => { + if (previousSession?.id !== aliasedSelectedSessionId) { + return previousSession; + } + + return { + ...previousSession, + ...normalizedSelectedSession, + }; + }); + + if (sessionId === aliasedSelectedSessionId) { + navigate(`/session/${upsert.sessionId}`); + } }; return subscribe(handleEvent); - }, [subscribe]); + }, [navigate, sessionId, subscribe]); useEffect(() => { return () => {