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.
This commit is contained in:
Haileyesus
2026-06-12 20:52:18 +03:00
parent 123ae31020
commit 3bbb42c233
5 changed files with 205 additions and 24 deletions

View File

@@ -70,3 +70,15 @@ test('createSession reactivates archived rows when the session becomes active ag
assert.equal(restoredSession?.isArchived, 0); 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/);
});
});

View File

@@ -17,10 +17,19 @@ type SessionRow = {
const SESSION_ROW_COLUMNS = const SESSION_ROW_COLUMNS =
'session_id, provider, provider_session_id, project_path, jsonl_path, custom_name, isArchived, created_at, updated_at'; '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 { function normalizeTimestamp(value?: string): string | null {
if (!value) return 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())) { if (Number.isNaN(parsed.getTime())) {
return null; return null;
} }
@@ -28,6 +37,22 @@ function normalizeTimestamp(value?: string): string | null {
return parsed.toISOString(); return parsed.toISOString();
} }
function normalizeSessionRow<T extends SessionRow | null | undefined>(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 { function normalizeProjectPathForProvider(provider: string, projectPath: string): string {
void provider; void provider;
return normalizeProjectPath(projectPath); return normalizeProjectPath(projectPath);
@@ -207,7 +232,7 @@ export const sessionsDb = {
) )
.get(sessionId) as SessionRow | undefined; .get(sessionId) as SessionRow | undefined;
return row ?? null; return normalizeSessionRow(row) ?? null;
}, },
/** /**
@@ -229,18 +254,20 @@ export const sessionsDb = {
) )
.get(providerSessionId) as SessionRow | undefined; .get(providerSessionId) as SessionRow | undefined;
return row ?? null; return normalizeSessionRow(row) ?? null;
}, },
getAllSessions(): SessionRow[] { getAllSessions(): SessionRow[] {
const db = getConnection(); const db = getConnection();
return db const rows = db
.prepare( .prepare(
`SELECT ${SESSION_ROW_COLUMNS} `SELECT ${SESSION_ROW_COLUMNS}
FROM sessions FROM sessions
WHERE isArchived = 0` WHERE isArchived = 0`
) )
.all() as SessionRow[]; .all() as SessionRow[];
return normalizeSessionRows(rows);
}, },
/** /**
@@ -249,7 +276,7 @@ export const sessionsDb = {
*/ */
getArchivedSessions(): SessionRow[] { getArchivedSessions(): SessionRow[] {
const db = getConnection(); const db = getConnection();
return db const rows = db
.prepare( .prepare(
`SELECT ${SESSION_ROW_COLUMNS} `SELECT ${SESSION_ROW_COLUMNS}
FROM sessions FROM sessions
@@ -257,12 +284,14 @@ export const sessionsDb = {
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC` ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC`
) )
.all() as SessionRow[]; .all() as SessionRow[];
return normalizeSessionRows(rows);
}, },
getSessionsByProjectPath(projectPath: string): SessionRow[] { getSessionsByProjectPath(projectPath: string): SessionRow[] {
const db = getConnection(); const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath); const normalizedProjectPath = normalizeProjectPath(projectPath);
return db const rows = db
.prepare( .prepare(
`SELECT ${SESSION_ROW_COLUMNS} `SELECT ${SESSION_ROW_COLUMNS}
FROM sessions FROM sessions
@@ -270,6 +299,8 @@ export const sessionsDb = {
AND isArchived = 0` AND isArchived = 0`
) )
.all(normalizedProjectPath) as SessionRow[]; .all(normalizedProjectPath) as SessionRow[];
return normalizeSessionRows(rows);
}, },
/** /**
@@ -279,19 +310,21 @@ export const sessionsDb = {
getSessionsByProjectPathIncludingArchived(projectPath: string): SessionRow[] { getSessionsByProjectPathIncludingArchived(projectPath: string): SessionRow[] {
const db = getConnection(); const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath); const normalizedProjectPath = normalizeProjectPath(projectPath);
return db const rows = db
.prepare( .prepare(
`SELECT ${SESSION_ROW_COLUMNS} `SELECT ${SESSION_ROW_COLUMNS}
FROM sessions FROM sessions
WHERE project_path = ?` WHERE project_path = ?`
) )
.all(normalizedProjectPath) as SessionRow[]; .all(normalizedProjectPath) as SessionRow[];
return normalizeSessionRows(rows);
}, },
getSessionsByProjectPathPage(projectPath: string, limit: number, offset: number): SessionRow[] { getSessionsByProjectPathPage(projectPath: string, limit: number, offset: number): SessionRow[] {
const db = getConnection(); const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath); const normalizedProjectPath = normalizeProjectPath(projectPath);
return db const rows = db
.prepare( .prepare(
`SELECT ${SESSION_ROW_COLUMNS} `SELECT ${SESSION_ROW_COLUMNS}
FROM sessions FROM sessions
@@ -301,6 +334,8 @@ export const sessionsDb = {
LIMIT ? OFFSET ?` LIMIT ? OFFSET ?`
) )
.all(normalizedProjectPath, limit, offset) as SessionRow[]; .all(normalizedProjectPath, limit, offset) as SessionRow[];
return normalizeSessionRows(rows);
}, },
countSessionsByProjectPath(projectPath: string): number { countSessionsByProjectPath(projectPath: string): number {

View File

@@ -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 { 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 { import type {
LLMProvider, LLMProvider,
NormalizedMessage, NormalizedMessage,
@@ -58,6 +62,48 @@ const MAX_BUFFERED_EVENTS_PER_RUN = 5000;
*/ */
const runs = new Map<string, ChatRun>(); const runs = new Map<string, ChatRun>();
async function broadcastCanonicalSessionUpsert(appSessionId: string): Promise<void> {
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 { function evictRunLater(appSessionId: string): void {
const timer = setTimeout(() => { const timer = setTimeout(() => {
const run = runs.get(appSessionId); const run = runs.get(appSessionId);
@@ -132,6 +178,14 @@ function recordProviderSessionId(run: ChatRun, providerSessionId: string): void
try { try {
sessionsDb.assignProviderSessionId(run.appSessionId, providerSessionId); 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) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
console.error('[ChatRunRegistry] Failed to persist provider session id mapping', { console.error('[ChatRunRegistry] Failed to persist provider session id mapping', {

View File

@@ -6,7 +6,7 @@ import test from 'node:test';
import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js'; import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js';
import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.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 * 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 { class FakeConnection {
readyState = 1; // WS_OPEN_STATE readyState = 1; // WS_OPEN_STATE
frames: NormalizedMessage[] = []; frames: Array<Record<string, unknown>> = [];
send(data: string): void { send(data: string): void {
this.frames.push(JSON.parse(data) as NormalizedMessage); this.frames.push(JSON.parse(data) as Record<string, unknown>);
} }
} }
@@ -33,6 +33,7 @@ async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promis
try { try {
await runTest(); await runTest();
} finally { } finally {
connectedClients.clear();
chatRunRegistry.clearAll(); chatRunRegistry.clearAll();
closeConnection(); closeConnection();
if (previousDatabasePath === undefined) { if (previousDatabasePath === undefined) {
@@ -72,6 +73,7 @@ test('session_created is swallowed and persisted as the provider-id mapping', as
await withIsolatedDatabase(() => { await withIsolatedDatabase(() => {
sessionsDb.createAppSession('app-run-2', 'cursor', '/workspace/demo'); sessionsDb.createAppSession('app-run-2', 'cursor', '/workspace/demo');
const connection = new FakeConnection(); const connection = new FakeConnection();
connectedClients.add(connection as never);
const run = chatRunRegistry.startRun({ const run = chatRunRegistry.startRun({
appSessionId: 'app-run-2', appSessionId: 'app-run-2',
provider: 'cursor', provider: 'cursor',
@@ -88,9 +90,12 @@ test('session_created is swallowed and persisted as the provider-id mapping', as
newSessionId: 'cursor-native-7', newSessionId: 'cursor-native-7',
}); });
// Never forwarded to the client... // The provider-native event itself is never forwarded...
assert.equal(connection.frames.length, 0); const sessionUpserts = connection.frames.filter((frame) => frame.kind === 'session_upserted');
// ...but recorded in the registry and persisted in the database. 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(run.providerSessionId, 'cursor-native-7');
assert.equal(sessionsDb.getSessionById('app-run-2')?.provider_session_id, 'cursor-native-7'); assert.equal(sessionsDb.getSessionById('app-run-2')?.provider_session_id, 'cursor-native-7');
}); });

View File

@@ -29,6 +29,7 @@ type UseProjectsStateArgs = {
*/ */
type SessionUpsertedEvent = ServerEvent & { type SessionUpsertedEvent = ServerEvent & {
sessionId: string; sessionId: string;
providerSessionId?: string | null;
provider: LLMProvider; provider: LLMProvider;
session: ProjectSession; session: ProjectSession;
project: { project: {
@@ -212,6 +213,26 @@ const mergeProjectSessionPage = (
return mergedProject; return mergedProject;
}; };
const getSessionAliasIds = (event: SessionUpsertedEvent): Set<string> => {
const ids = new Set<string>();
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. * Resolves which provider bucket on a `Project` holds sessions for a provider.
* The legacy payload keeps Claude sessions in `sessions` and the other * 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 upsertSessionIntoProject = (project: Project, event: SessionUpsertedEvent): Project => {
const bucketKey = providerBucketKey(event.provider); const bucketKey = providerBucketKey(event.provider);
const bucket = project[bucketKey] ?? []; 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 nextBucket: ProjectSession[];
let inserted = false;
if (existingIndex >= 0) { if (existingIndex >= 0) {
const existing = bucket[existingIndex]; let changed = false;
const updated = { ...existing, ...event.session }; nextBucket = [];
if (serialize(existing) === serialize(updated)) {
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; return project;
} }
nextBucket = [...bucket];
nextBucket[existingIndex] = updated;
} else { } else {
nextBucket = [event.session, ...bucket]; nextBucket = [normalizedSession, ...bucket];
inserted = true;
} }
const next: Project = { ...project, [bucketKey]: nextBucket }; const next: Project = { ...project, [bucketKey]: nextBucket };
if (existingIndex < 0) { if (inserted) {
const total = Number(project.sessionMeta?.total ?? 0) + 1; const total = Number(project.sessionMeta?.total ?? 0) + 1;
next.sessionMeta = { next.sessionMeta = {
...project.sessionMeta, ...project.sessionMeta,
@@ -629,10 +674,40 @@ export function useProjectsState({
const updated = upsertSessionIntoProject(previousProject, upsert); const updated = upsertSessionIntoProject(previousProject, upsert);
return updated === previousProject ? previousProject : updated; 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); return subscribe(handleEvent);
}, [subscribe]); }, [navigate, sessionId, subscribe]);
useEffect(() => { useEffect(() => {
return () => { return () => {