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

@@ -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<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 {
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', {

View File

@@ -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<Record<string, unknown>> = [];
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 {
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');
});