diff --git a/server/modules/database/migrations.ts b/server/modules/database/migrations.ts index 4e86c799c5..2505ff4be4 100644 --- a/server/modules/database/migrations.ts +++ b/server/modules/database/migrations.ts @@ -58,18 +58,28 @@ const migrateLegacySessionNames = (db: Database): void => { if (hasSessionsTable) { console.log('Running migration: Merging session_names into sessions'); db.exec(` - INSERT INTO sessions (session_id, provider, custom_name, created_at, updated_at) + INSERT INTO sessions (session_id, provider, custom_name, custom_name_source, created_at, updated_at) SELECT session_id, COALESCE(provider, 'claude'), custom_name, + CASE WHEN custom_name IS NULL OR trim(custom_name) = '' THEN NULL ELSE 'manual' END, COALESCE(created_at, CURRENT_TIMESTAMP), COALESCE(updated_at, CURRENT_TIMESTAMP) FROM session_names WHERE true ON CONFLICT(session_id) DO UPDATE SET provider = excluded.provider, - custom_name = COALESCE(excluded.custom_name, sessions.custom_name), + custom_name = CASE + WHEN excluded.custom_name IS NOT NULL AND trim(excluded.custom_name) <> '' + THEN excluded.custom_name + ELSE sessions.custom_name + END, + custom_name_source = CASE + WHEN excluded.custom_name IS NOT NULL AND trim(excluded.custom_name) <> '' + THEN 'manual' + ELSE sessions.custom_name_source + END, created_at = COALESCE(sessions.created_at, excluded.created_at), updated_at = COALESCE(excluded.updated_at, sessions.updated_at) `); @@ -79,6 +89,13 @@ const migrateLegacySessionNames = (db: Database): void => { console.log('Running migration: Renaming session_names table to sessions'); db.exec('ALTER TABLE session_names RENAME TO sessions'); + const columnNames = getTableInfo(db, 'sessions').map((column) => column.name); + addColumnToTableIfNotExists(db, 'sessions', columnNames, 'custom_name_source', 'TEXT'); + db.exec(` + UPDATE sessions + SET custom_name_source = 'manual' + WHERE custom_name IS NOT NULL AND trim(custom_name) <> '' + `); }; const migrateLegacyWorkspaceTableIntoProjects = (db: Database): void => { @@ -416,6 +433,19 @@ const addSessionModelColumn = (db: Database): void => { addColumnToTableIfNotExists(db, 'sessions', columnNames, 'model', 'TEXT'); }; +const addSessionCustomNameSource = (db: Database): void => { + const columnNames = getTableInfo(db, 'sessions').map((column) => column.name); + const isLegacySchema = !columnNames.includes('custom_name_source'); + addColumnToTableIfNotExists(db, 'sessions', columnNames, 'custom_name_source', 'TEXT'); + if (isLegacySchema) { + db.exec(` + UPDATE sessions + SET custom_name_source = 'manual' + WHERE custom_name IS NOT NULL AND trim(custom_name) <> '' + `); + } +}; + const ensureProjectsForSessionPaths = (db: Database): void => { if (!tableExists(db, 'sessions')) { return; @@ -464,6 +494,7 @@ export const runMigrations = (db: Database) => { migrateLegacyWorkspaceTableIntoProjects(db); rebuildSessionsTableWithProjectSchema(db); + addSessionCustomNameSource(db); migrateLegacySessionNames(db); addProviderSessionIdMapping(db); addSessionModelColumn(db); diff --git a/server/modules/database/repositories/sessions.db.ts b/server/modules/database/repositories/sessions.db.ts index 3d9a8de732..332d4ee804 100644 --- a/server/modules/database/repositories/sessions.db.ts +++ b/server/modules/database/repositories/sessions.db.ts @@ -9,6 +9,7 @@ type SessionRow = { project_path: string | null; jsonl_path: string | null; custom_name: string | null; + custom_name_source: string | null; /** Model this session runs with; NULL until the app records one for it. */ model: string | null; isArchived: number; @@ -17,7 +18,7 @@ type SessionRow = { }; const SESSION_ROW_COLUMNS = - 'session_id, provider, provider_session_id, project_path, jsonl_path, custom_name, model, isArchived, created_at, updated_at'; + 'session_id, provider, provider_session_id, project_path, jsonl_path, custom_name, custom_name_source, model, isArchived, created_at, updated_at'; const SQLITE_UTC_TIMESTAMP_REGEX = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/; @@ -103,7 +104,15 @@ export const sessionsDb = { project_path = ?, jsonl_path = ?, isArchived = 0, - custom_name = COALESCE(?, custom_name) + custom_name = CASE + WHEN custom_name_source = 'manual' THEN custom_name + ELSE COALESCE(?, custom_name) + END, + custom_name_source = CASE + WHEN custom_name_source = 'manual' THEN custom_name_source + WHEN ? IS NOT NULL THEN 'provider' + ELSE custom_name_source + END WHERE session_id = ?` ).run( provider, @@ -111,6 +120,7 @@ export const sessionsDb = { normalizedProjectPath, jsonlPath ?? null, customName ?? null, + customName ?? null, existing.session_id ); @@ -121,8 +131,8 @@ export const sessionsDb = { // keyed by the provider-native id for both columns. The ON CONFLICT path // covers legacy rows that predate the provider_session_id mapping. db.prepare( - `INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, 0, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP)) + `INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, custom_name_source, project_path, jsonl_path, isArchived, created_at, updated_at) + VALUES (?, ?, ?, ?, CASE WHEN ? IS NULL THEN NULL ELSE 'provider' END, ?, ?, 0, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP)) ON CONFLICT(session_id) DO UPDATE SET provider = excluded.provider, provider_session_id = excluded.provider_session_id, @@ -130,12 +140,20 @@ export const sessionsDb = { project_path = excluded.project_path, jsonl_path = excluded.jsonl_path, isArchived = 0, - custom_name = COALESCE(excluded.custom_name, sessions.custom_name)` + custom_name = CASE + WHEN sessions.custom_name_source = 'manual' THEN sessions.custom_name + ELSE COALESCE(excluded.custom_name, sessions.custom_name) + END, + custom_name_source = CASE + WHEN sessions.custom_name_source = 'manual' THEN sessions.custom_name_source + ELSE COALESCE(excluded.custom_name_source, sessions.custom_name_source) + END` ).run( providerSessionId, provider, providerSessionId, customName ?? null, + customName ?? null, normalizedProjectPath, jsonlPath ?? null, createdAtValue, @@ -160,8 +178,8 @@ export const sessionsDb = { projectsDb.createProjectPath(normalizedProjectPath); db.prepare( - `INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, project_path, jsonl_path, isArchived, created_at, updated_at) - VALUES (?, ?, NULL, NULL, ?, NULL, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)` + `INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, custom_name_source, project_path, jsonl_path, isArchived, created_at, updated_at) + VALUES (?, ?, NULL, NULL, NULL, ?, NULL, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)` ).run(sessionId, provider, normalizedProjectPath); return sessionId; @@ -195,10 +213,28 @@ export const sessionsDb = { `UPDATE sessions SET provider_session_id = ?, jsonl_path = COALESCE(jsonl_path, ?), - custom_name = COALESCE(custom_name, ?), + custom_name = CASE + WHEN custom_name_source = 'manual' THEN custom_name + WHEN ? = 'manual' THEN ? + ELSE COALESCE(custom_name, ?) + END, + custom_name_source = CASE + WHEN custom_name_source = 'manual' THEN custom_name_source + WHEN ? = 'manual' THEN 'manual' + ELSE COALESCE(custom_name_source, ?) + END, updated_at = CURRENT_TIMESTAMP WHERE session_id = ?` - ).run(providerSessionId, duplicate.jsonl_path, duplicate.custom_name, sessionId); + ).run( + providerSessionId, + duplicate.jsonl_path, + duplicate.custom_name_source, + duplicate.custom_name, + duplicate.custom_name, + duplicate.custom_name_source, + duplicate.custom_name_source, + sessionId + ); return; } @@ -233,11 +269,20 @@ export const sessionsDb = { const db = getConnection(); db.prepare( `UPDATE sessions - SET custom_name = ? + SET custom_name = ?, custom_name_source = 'manual' WHERE session_id = ?` ).run(customName, sessionId); }, + updateSessionProviderName(sessionId: string, customName: string): void { + const db = getConnection(); + db.prepare( + `UPDATE sessions + SET custom_name = ?, custom_name_source = 'provider' + WHERE session_id = ? AND COALESCE(custom_name_source, 'provider') <> 'manual'` + ).run(customName, sessionId); + }, + getSessionById(sessionId: string): SessionRow | null { const db = getConnection(); const row = db @@ -325,6 +370,19 @@ export const sessionsDb = { return normalizeSessionRows(rows); }, + getSessionsByProvider(provider: string): SessionRow[] { + const db = getConnection(); + const rows = db + .prepare( + `SELECT ${SESSION_ROW_COLUMNS} + FROM sessions + WHERE provider = ?` + ) + .all(provider) as SessionRow[]; + + return normalizeSessionRows(rows); + }, + /** * Archived rows are intentionally queried separately so the caller can render * them in a dedicated view without reintroducing them into active session lists. diff --git a/server/modules/database/schema.ts b/server/modules/database/schema.ts index f0f9cebedd..9d58f12380 100644 --- a/server/modules/database/schema.ts +++ b/server/modules/database/schema.ts @@ -107,6 +107,7 @@ CREATE TABLE IF NOT EXISTS sessions ( -- id mid-run, or equals \`session_id\` for sessions discovered on disk. provider_session_id TEXT, custom_name TEXT, + custom_name_source TEXT, project_path TEXT, jsonl_path TEXT, -- Model this session runs with. Written when the user picks a model for the diff --git a/server/modules/database/tests/sessions-provider-mapping.test.ts b/server/modules/database/tests/sessions-provider-mapping.test.ts index 45e4a41137..f50fbf10b0 100644 --- a/server/modules/database/tests/sessions-provider-mapping.test.ts +++ b/server/modules/database/tests/sessions-provider-mapping.test.ts @@ -98,6 +98,21 @@ test('assignProviderSessionId merges a watcher-created duplicate into the app ro }); }); +test('assignProviderSessionId preserves a duplicate manual name over a provider name', async () => { + await withIsolatedDatabase(() => { + sessionsDb.createAppSession('app-id-3', 'codex', '/workspace/demo'); + sessionsDb.updateSessionProviderName('app-id-3', 'Provider title'); + sessionsDb.createSession('provider-manual-race', 'codex', '/workspace/demo', 'Provider duplicate name'); + sessionsDb.updateSessionCustomName('provider-manual-race', 'Manual duplicate name'); + + sessionsDb.assignProviderSessionId('app-id-3', 'provider-manual-race'); + + const row = sessionsDb.getSessionById('app-id-3'); + assert.equal(row?.custom_name, 'Manual duplicate name'); + assert.equal(row?.custom_name_source, 'manual'); + }); +}); + test('legacy provider-keyed rows stay resolvable through both lookups', async () => { await withIsolatedDatabase(() => { sessionsDb.createSession('legacy-1', 'opencode', '/workspace/demo'); diff --git a/server/modules/database/tests/sessions.db.integration.test.ts b/server/modules/database/tests/sessions.db.integration.test.ts index ecc11c9943..9e353bdf17 100644 --- a/server/modules/database/tests/sessions.db.integration.test.ts +++ b/server/modules/database/tests/sessions.db.integration.test.ts @@ -4,17 +4,23 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import test from 'node:test'; +import Database from 'better-sqlite3'; + import { closeConnection } from '@/modules/database/connection.js'; import { initializeDatabase } from '@/modules/database/init-db.js'; import { sessionsDb } from '@/modules/database/repositories/sessions.db.js'; -async function withIsolatedDatabase(runTest: () => void | Promise): Promise { +async function withIsolatedDatabase( + runTest: () => void | Promise, + prepareDatabase?: (databasePath: string) => void, +): Promise { const previousDatabasePath = process.env.DATABASE_PATH; const tempDirectory = await mkdtemp(path.join(tmpdir(), 'sessions-db-')); const databasePath = path.join(tempDirectory, 'auth.db'); closeConnection(); process.env.DATABASE_PATH = databasePath; + prepareDatabase?.(databasePath); await initializeDatabase(); try { @@ -30,6 +36,93 @@ async function withIsolatedDatabase(runTest: () => void | Promise): Promis } } +test('migration preserves legacy session names as manual overrides', async () => { + await withIsolatedDatabase(() => { + const migrated = sessionsDb.getSessionById('legacy-manual'); + assert.equal(migrated?.custom_name_source, 'manual'); + + sessionsDb.updateSessionProviderName('legacy-manual', 'Codex title'); + assert.equal(sessionsDb.getSessionById('legacy-manual')?.custom_name, 'My existing name'); + }, (databasePath) => { + const db = new Database(databasePath); + db.exec(` + CREATE TABLE sessions ( + session_id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + custom_name TEXT, + project_path TEXT, + jsonl_path TEXT, + isArchived BOOLEAN DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + INSERT INTO sessions (session_id, provider, custom_name) + VALUES ('legacy-manual', 'codex', 'My existing name'); + `); + db.close(); + }); +}); + +test('session_names migration preserves custom names as manual overrides', async () => { + await withIsolatedDatabase(() => { + const migrated = sessionsDb.getSessionById('legacy-session-name'); + assert.equal(migrated?.custom_name_source, 'manual'); + + sessionsDb.updateSessionProviderName('legacy-session-name', 'Provider title'); + assert.equal(sessionsDb.getSessionById('legacy-session-name')?.custom_name, 'Legacy custom name'); + }, (databasePath) => { + const db = new Database(databasePath); + db.exec(` + CREATE TABLE session_names ( + session_id TEXT PRIMARY KEY, + provider TEXT, + custom_name TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + INSERT INTO session_names (session_id, provider, custom_name) + VALUES ('legacy-session-name', 'codex', 'Legacy custom name'); + `); + db.close(); + }); +}); + +test('session_names migration treats blank names as absent during conflict merge', async () => { + await withIsolatedDatabase(() => { + const migrated = sessionsDb.getSessionById('existing-provider-name'); + assert.equal(migrated?.custom_name, 'Existing provider name'); + assert.equal(migrated?.custom_name_source, 'provider'); + }, (databasePath) => { + const db = new Database(databasePath); + db.exec(` + CREATE TABLE sessions ( + session_id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + provider_session_id TEXT, + custom_name TEXT, + custom_name_source TEXT, + project_path TEXT, + jsonl_path TEXT, + isArchived BOOLEAN DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + INSERT INTO sessions (session_id, provider, provider_session_id, custom_name, custom_name_source) + VALUES ('existing-provider-name', 'codex', 'codex-existing-provider-name', 'Existing provider name', 'provider'); + CREATE TABLE session_names ( + session_id TEXT PRIMARY KEY, + provider TEXT, + custom_name TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + INSERT INTO session_names (session_id, provider, custom_name) + VALUES ('existing-provider-name', 'codex', ' '); + `); + db.close(); + }); +}); + test('session archive queries hide archived rows from active project views', async () => { await withIsolatedDatabase(() => { sessionsDb.createSession('session-active', 'claude', '/workspace/demo-project', 'Active Session'); diff --git a/server/modules/providers/README.md b/server/modules/providers/README.md index 643bb2bb94..a772448a51 100644 --- a/server/modules/providers/README.md +++ b/server/modules/providers/README.md @@ -205,7 +205,7 @@ Current session sync roots are: | Provider | Scan Roots | Metadata Helpers / Notes | | --- | --- | --- | | Claude | `~/.claude/projects/**/*.jsonl` | Uses `~/.claude/history.jsonl` for name lookup and the trailing `ai-title`, `last-prompt`, or `custom-title` entries for title recovery. | -| Codex | `~/.codex/sessions/**/*.jsonl` | Uses `~/.codex/session_index.jsonl` for title lookup and the last `task_complete` message for a fallback title. | +| Codex | `~/.codex/sessions/**/*.jsonl` | Preserves CloudCLI manual names first, then prefers the latest `~/.codex/session_index.jsonl` name over the title from the latest `~/.codex/state_*.sqlite`; app-created sessions use the first user message and other sessions use the last `task_complete` message as fallback. | | Cursor | `~/.cursor/projects/**/*.jsonl` | Uses sibling `worker.log` to recover `workspacePath`, then derives the session title from the first user prompt. | | OpenCode | `~/.local/share/opencode/opencode.db` | Reads active sessions/messages/parts from OpenCode's shared SQLite database and stores `jsonl_path` as `null` so deleting one app session cannot remove the shared DB. | @@ -377,4 +377,3 @@ alongside the implementation. user/project skill folders. - Assuming one provider's MCP config file format works for the others. - diff --git a/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts b/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts index 5e1afed28d..b9e5046019 100644 --- a/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts +++ b/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts @@ -1,16 +1,22 @@ +import { createReadStream } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { readFile } from 'node:fs/promises'; +import { readFile, readdir, stat } from 'node:fs/promises'; +import readline from 'node:readline'; + +import Database from 'better-sqlite3'; import { sessionsDb } from '@/modules/database/index.js'; import { - buildLookupMap, extractFirstValidJsonlData, findFilesRecursivelyCreatedAfter, normalizeSessionName, readFileTimestamps, } from '@/shared/utils.js'; -import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js'; +import type { + IProviderSessionSynchronizer, + SessionSynchronizeOptions, +} from '@/shared/interfaces.js'; type ParsedSession = { sessionId: string; @@ -24,12 +30,27 @@ type ParsedSession = { export class CodexSessionSynchronizer implements IProviderSessionSynchronizer { private readonly provider = 'codex' as const; private readonly codexHome = path.join(os.homedir(), '.codex'); + private indexedNameCache: { mtimeMs: number; names: Map } | null = null; + private synchronizationQueue: Promise = Promise.resolve(); /** * Scans ~/.codex/sessions and upserts discovered sessions into DB. */ - async synchronize(since?: Date): Promise { - const nameMap = await buildLookupMap(path.join(this.codexHome, 'session_index.jsonl'), 'id', 'thread_name'); + async synchronize( + since?: Date, + options: SessionSynchronizeOptions = {}, + ): Promise { + return this.enqueueSynchronization(() => this.synchronizeInternal(since, options)); + } + + private async synchronizeInternal( + since?: Date, + options: SessionSynchronizeOptions = {}, + ): Promise { + const nameMap = options.initializing + ? await this.buildSessionNameMap() + : await this.readIndexedNameMap(); + this.updateProviderSessionNames(nameMap); const files = await findFilesRecursivelyCreatedAfter( path.join(this.codexHome, 'sessions'), '.jsonl', @@ -48,7 +69,7 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer { if (existingSession) { // If session name is untitled and we now have a name, update it if (existingSession.custom_name === 'Untitled Codex Session' && parsed.sessionName && parsed.sessionName !== 'Untitled Codex Session') { - sessionsDb.updateSessionCustomName(existingSession.session_id, parsed.sessionName); + sessionsDb.updateSessionProviderName(existingSession.session_id, parsed.sessionName); } } @@ -72,11 +93,15 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer { * Parses and upserts one Codex session JSONL file. */ async synchronizeFile(filePath: string): Promise { + return this.enqueueSynchronization(() => this.synchronizeFileInternal(filePath)); + } + + private async synchronizeFileInternal(filePath: string): Promise { if (!filePath.endsWith('.jsonl')) { return null; } - const nameMap = await buildLookupMap(path.join(this.codexHome, 'session_index.jsonl'), 'id', 'thread_name'); + const nameMap = await this.readIndexedNameMap(); const parsed = await this.processSessionFile(filePath, nameMap); if (!parsed) { return null; @@ -94,6 +119,127 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer { ); } + private enqueueSynchronization(operation: () => Promise): Promise { + const previous = this.synchronizationQueue; + let release!: () => void; + this.synchronizationQueue = new Promise((resolve) => { + release = resolve; + }); + + return previous.then(operation).finally(release); + } + + private async buildSessionNameMap(): Promise> { + const nameMap = await this.readStateTitleMap(); + const indexedNames = await this.readIndexedNameMap(); + for (const [sessionId, name] of indexedNames) { + if (name.trim()) { + nameMap.set(sessionId, name); + } + } + return nameMap; + } + + private async readIndexedNameMap(): Promise> { + const indexPath = path.join(this.codexHome, 'session_index.jsonl'); + const mtimeMs = await this.readIndexedNameMtime(); + if (mtimeMs === null) { + return new Map(); + } + + if (this.indexedNameCache?.mtimeMs === mtimeMs) { + return this.indexedNameCache.names; + } + + const names = await this.loadIndexedNameMap(indexPath); + this.indexedNameCache = { mtimeMs, names }; + return names; + } + + private async readIndexedNameMtime(): Promise { + try { + return (await stat(path.join(this.codexHome, 'session_index.jsonl'))).mtimeMs; + } catch { + return null; + } + } + + private async loadIndexedNameMap(indexPath: string): Promise> { + const names = new Map(); + try { + const lines = readline.createInterface({ + input: createReadStream(indexPath), + crlfDelay: Infinity, + }); + for await (const line of lines) { + try { + const entry = JSON.parse(line) as Record; + if (typeof entry.id === 'string' && typeof entry.thread_name === 'string') { + names.set(entry.id, entry.thread_name); + } + } catch { + // A malformed entry must not hide newer names later in the append-only index. + } + } + } catch { + // The index is optional; state titles and transcript fallbacks remain available. + } + return names; + } + + private updateProviderSessionNames(nameMap: Map): void { + const sessions = sessionsDb.getSessionsByProvider(this.provider); + const sessionsByLookupId = new Map(); + for (const session of sessions) { + if (session.provider_session_id) { + sessionsByLookupId.set(session.provider_session_id, session); + } + } + for (const session of sessions) { + if (!sessionsByLookupId.has(session.session_id)) { + sessionsByLookupId.set(session.session_id, session); + } + } + + for (const [providerSessionId, name] of nameMap) { + const existingSession = sessionsByLookupId.get(providerSessionId); + if (!existingSession) { + continue; + } + + const normalizedName = normalizeSessionName(name, 'Untitled Codex Session'); + if (normalizedName !== existingSession.custom_name) { + sessionsDb.updateSessionProviderName(existingSession.session_id, normalizedName); + } + } + } + + private async readStateTitleMap(): Promise> { + try { + const stateFile = (await readdir(this.codexHome)) + .map((fileName) => ({ fileName, match: /^state_(\d+)\.sqlite$/.exec(fileName) })) + .filter((entry): entry is { fileName: string; match: RegExpExecArray } => entry.match !== null) + .sort((a, b) => Number(b.match[1]) - Number(a.match[1]))[0]?.fileName; + if (!stateFile) { + return new Map(); + } + + const db = new Database(path.join(this.codexHome, stateFile), { + readonly: true, + fileMustExist: true, + }); + try { + const rows = db.prepare('SELECT id, title FROM threads WHERE trim(title) <> \'\'') + .all() as Array<{ id: string; title: string }>; + return new Map(rows.map((row) => [row.id, row.title])); + } finally { + db.close(); + } + } catch { + return new Map(); + } + } + /** * Extracts session metadata from one Codex JSONL session file. */ @@ -126,6 +272,21 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer { // ids must be resolved through the provider-id mapping first. const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId) ?? sessionsDb.getSessionById(parsed.sessionId); + if (existingSession?.custom_name_source === 'manual' && existingSession.custom_name?.trim()) { + return { + ...parsed, + sessionName: normalizeSessionName(existingSession.custom_name, 'Untitled Codex Session'), + }; + } + + const indexedSessionName = nameMap.get(parsed.sessionId); + if (indexedSessionName?.trim()) { + return { + ...parsed, + sessionName: normalizeSessionName(indexedSessionName, 'Untitled Codex Session'), + }; + } + const existingSessionName = existingSession?.custom_name; if (existingSessionName && existingSessionName !== 'Untitled Codex Session') { return { @@ -135,11 +296,8 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer { } // Sessions started by sending a message from cloudcli carry a distinct - // app-allocated session_id mapped to the provider id. For these we title the - // conversation from the first user message the user typed, instead of the - // generic "Untitled Codex Session" placeholder. Sessions discovered purely - // by indexing (session_id === provider_session_id) keep the existing - // thread_name/last-agent-message setup below. + // app-allocated session_id mapped to the provider id. When Codex has not + // assigned a thread name yet, use the first user message as the fallback. const isAppCreated = existingSession != null && existingSession.provider_session_id != null && @@ -148,9 +306,6 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer { let sessionName = isAppCreated ? await this.extractFirstUserMessageFromStart(filePath) : undefined; - if (!sessionName) { - sessionName = nameMap.get(parsed.sessionId); - } if (!sessionName) { sessionName = await this.extractLastAgentMessageFromEnd(filePath); } diff --git a/server/modules/providers/services/session-synchronizer.service.ts b/server/modules/providers/services/session-synchronizer.service.ts index 5bb2b8645f..7f27cac181 100644 --- a/server/modules/providers/services/session-synchronizer.service.ts +++ b/server/modules/providers/services/session-synchronizer.service.ts @@ -1,5 +1,6 @@ import { scanStateDb } from '@/modules/database/index.js'; import { providerRegistry } from '@/modules/providers/provider.registry.js'; +import type { SessionSynchronizeOptions } from '@/shared/interfaces.js'; import type { LLMProvider } from '@/shared/types.js'; type SessionSynchronizeResult = { @@ -14,7 +15,7 @@ export const sessionSynchronizerService = { /** * Runs all provider synchronizers and updates scan_state.last_scanned_at. */ - async synchronizeSessions(): Promise { + async synchronizeSessions(options: SessionSynchronizeOptions = {}): Promise { const lastScanAt = scanStateDb.getLastScannedAt(); const scanBoundary = new Date(); const processedByProvider: Record = { @@ -28,7 +29,7 @@ export const sessionSynchronizerService = { const results = await Promise.allSettled( providerRegistry.listProviders().map(async (provider) => ({ provider: provider.id, - processed: await provider.sessionSynchronizer.synchronize(lastScanAt ?? undefined), + processed: await provider.sessionSynchronizer.synchronize(lastScanAt ?? undefined, options), })) ); diff --git a/server/modules/providers/services/sessions-watcher.service.ts b/server/modules/providers/services/sessions-watcher.service.ts index 052afaeb91..b76b4ae543 100644 --- a/server/modules/providers/services/sessions-watcher.service.ts +++ b/server/modules/providers/services/sessions-watcher.service.ts @@ -259,7 +259,7 @@ async function onUpdate( export async function initializeSessionsWatcher(): Promise { console.log('Setting up session watchers'); - const initialSync = await sessionSynchronizerService.synchronizeSessions(); + const initialSync = await sessionSynchronizerService.synchronizeSessions({ initializing: true }); console.log('Initial session synchronization complete', { processedByProvider: initialSync.processedByProvider, failures: initialSync.failures, diff --git a/server/modules/providers/tests/codex-sessions.test.ts b/server/modules/providers/tests/codex-sessions.test.ts index da67420ba0..97cc33cbb2 100644 --- a/server/modules/providers/tests/codex-sessions.test.ts +++ b/server/modules/providers/tests/codex-sessions.test.ts @@ -1,9 +1,11 @@ import assert from 'node:assert/strict'; -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; +import Database from 'better-sqlite3'; + import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js'; import { CodexSessionSynchronizer } from '@/modules/providers/list/codex/codex-session-synchronizer.provider.js'; import { CodexSessionsProvider } from '@/modules/providers/list/codex/codex-sessions.provider.js'; @@ -64,10 +66,16 @@ const writeCodexTranscript = async ( return filePath; }; -test('Codex synchronizer titles app-created sessions from the first user message', { concurrency: false }, async () => { +test('Codex synchronizer titles app-created sessions from the first user message when the indexed title is blank', { concurrency: false }, async () => { const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-session-sync-app-')); const workspacePath = path.join(tempRoot, 'workspace'); await mkdir(workspacePath, { recursive: true }); + await mkdir(path.join(tempRoot, '.codex'), { recursive: true }); + await writeFile( + path.join(tempRoot, '.codex', 'session_index.jsonl'), + `${JSON.stringify({ id: 'codex-app-1', thread_name: ' ' })}\n`, + 'utf8' + ); const restoreHomeDir = patchHomeDir(tempRoot); try { @@ -89,6 +97,323 @@ test('Codex synchronizer titles app-created sessions from the first user message } }); +test('Codex synchronizer replaces an app-created fallback with the indexed thread name', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-session-sync-title-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await mkdir(workspacePath, { recursive: true }); + await mkdir(path.join(tempRoot, '.codex'), { recursive: true }); + await writeFile( + path.join(tempRoot, '.codex', 'session_index.jsonl'), + `${JSON.stringify({ id: 'codex-app-titled', thread_name: ' ' })}\n`, + 'utf8' + ); + const restoreHomeDir = patchHomeDir(tempRoot); + + try { + const transcriptPath = await writeCodexTranscript( + tempRoot, + 'codex-app-titled', + workspacePath, + 'First prompt fallback', + ); + await withIsolatedDatabase(async () => { + sessionsDb.createAppSession('app-titled', 'codex', workspacePath); + sessionsDb.assignProviderSessionId('app-titled', 'codex-app-titled'); + + const synchronizer = new CodexSessionSynchronizer(); + await synchronizer.synchronize(); + assert.equal(sessionsDb.getSessionById('app-titled')?.custom_name, 'First prompt fallback'); + + await writeFile( + path.join(tempRoot, '.codex', 'session_index.jsonl'), + `${JSON.stringify({ id: 'codex-app-titled', thread_name: 'Old title' })}\n${JSON.stringify({ id: 'codex-app-titled', thread_name: 'Fix login redirect' })}\n`, + 'utf8' + ); + await synchronizer.synchronizeFile(transcriptPath); + + assert.equal(sessionsDb.getSessionById('app-titled')?.custom_name, 'Fix login redirect'); + }); + } finally { + restoreHomeDir(); + await rm(tempRoot, { recursive: true, force: true }); + } +}); + +test('Codex file synchronization does not persist a stale index title after a newer sync', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-session-sync-index-race-')); + const workspacePath = path.join(tempRoot, 'workspace'); + const indexPath = path.join(tempRoot, '.codex', 'session_index.jsonl'); + await mkdir(workspacePath, { recursive: true }); + await mkdir(path.dirname(indexPath), { recursive: true }); + await writeFile(indexPath, '{}\n', 'utf8'); + const restoreHomeDir = patchHomeDir(tempRoot); + + try { + const transcriptPath = await writeCodexTranscript( + tempRoot, + 'codex-index-race', + workspacePath, + 'First prompt fallback', + ); + await withIsolatedDatabase(async () => { + let loadCount = 0; + let markFirstLoadStarted!: () => void; + let releaseFirstLoad!: () => void; + const firstLoadStarted = new Promise((resolve) => { + markFirstLoadStarted = resolve; + }); + const firstLoadRelease = new Promise((resolve) => { + releaseFirstLoad = resolve; + }); + + const synchronizer = new CodexSessionSynchronizer(); + (synchronizer as any).loadIndexedNameMap = async () => { + loadCount += 1; + if (loadCount === 1) { + markFirstLoadStarted(); + await firstLoadRelease; + return new Map([['codex-index-race', 'Old indexed title']]); + } + return new Map([['codex-index-race', 'New indexed title']]); + }; + + const firstSync = synchronizer.synchronizeFile(transcriptPath); + await firstLoadStarted; + + await writeFile(indexPath, '{"updated":true}\n', 'utf8'); + const nextMtime = new Date(Date.now() + 60_000); + await utimes(indexPath, nextMtime, nextMtime); + const secondSync = synchronizer.synchronizeFile(transcriptPath); + + releaseFirstLoad(); + await Promise.all([firstSync, secondSync]); + + assert.equal(loadCount, 2); + assert.equal( + sessionsDb.getSessionById('codex-index-race')?.custom_name, + 'New indexed title', + ); + }); + } finally { + restoreHomeDir(); + await rm(tempRoot, { recursive: true, force: true }); + } +}); + +test('Codex full synchronization serializes with a watcher update', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-session-sync-full-race-')); + const workspacePath = path.join(tempRoot, 'workspace'); + const indexPath = path.join(tempRoot, '.codex', 'session_index.jsonl'); + await mkdir(workspacePath, { recursive: true }); + await mkdir(path.dirname(indexPath), { recursive: true }); + await writeFile( + indexPath, + `${JSON.stringify({ id: 'codex-full-race', thread_name: 'Old indexed title' })}\n`, + 'utf8', + ); + const restoreHomeDir = patchHomeDir(tempRoot); + + try { + const transcriptPath = await writeCodexTranscript( + tempRoot, + 'codex-full-race', + workspacePath, + 'First prompt fallback', + ); + await withIsolatedDatabase(async () => { + let markOldSessionParsed!: () => void; + let releaseFullSync!: () => void; + const oldSessionParsed = new Promise((resolve) => { + markOldSessionParsed = resolve; + }); + const fullSyncRelease = new Promise((resolve) => { + releaseFullSync = resolve; + }); + + const synchronizer = new CodexSessionSynchronizer(); + const originalProcessSessionFile = (synchronizer as any).processSessionFile; + (synchronizer as any).processSessionFile = async (filePath: string, nameMap: Map) => { + const parsed = await originalProcessSessionFile.call(synchronizer, filePath, nameMap); + if (nameMap.get('codex-full-race') === 'Old indexed title') { + markOldSessionParsed(); + await fullSyncRelease; + } + return parsed; + }; + + const fullSync = synchronizer.synchronize(); + await oldSessionParsed; + + await writeFile( + indexPath, + `${JSON.stringify({ id: 'codex-full-race', thread_name: 'New indexed title' })}\n`, + 'utf8', + ); + const nextMtime = new Date(Date.now() + 60_000); + await utimes(indexPath, nextMtime, nextMtime); + + const watcherSync = synchronizer.synchronizeFile(transcriptPath); + releaseFullSync(); + await Promise.all([fullSync, watcherSync]); + assert.equal( + sessionsDb.getSessionById('codex-full-race')?.custom_name, + 'New indexed title', + ); + }); + } finally { + restoreHomeDir(); + await rm(tempRoot, { recursive: true, force: true }); + } +}); + +test('Codex synchronizer preserves a CloudCLI manual name over a Codex title', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-session-sync-manual-title-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await mkdir(workspacePath, { recursive: true }); + await mkdir(path.join(tempRoot, '.codex'), { recursive: true }); + await writeFile( + path.join(tempRoot, '.codex', 'session_index.jsonl'), + `${JSON.stringify({ id: 'codex-manual-title', thread_name: 'Codex title' })}\n`, + 'utf8' + ); + const restoreHomeDir = patchHomeDir(tempRoot); + + try { + await writeCodexTranscript(tempRoot, 'codex-manual-title', workspacePath, 'First prompt fallback'); + await withIsolatedDatabase(async () => { + sessionsDb.createAppSession('app-manual-title', 'codex', workspacePath); + sessionsDb.assignProviderSessionId('app-manual-title', 'codex-manual-title'); + sessionsDb.updateSessionCustomName('app-manual-title', 'My CloudCLI name'); + + await new CodexSessionSynchronizer().synchronize(); + + assert.equal(sessionsDb.getSessionById('app-manual-title')?.custom_name, 'My CloudCLI name'); + }); + } finally { + restoreHomeDir(); + await rm(tempRoot, { recursive: true, force: true }); + } +}); + +test('Codex synchronizer replaces a fallback with the Codex state database title', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-session-sync-state-title-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await mkdir(workspacePath, { recursive: true }); + const restoreHomeDir = patchHomeDir(tempRoot); + + try { + await writeCodexTranscript(tempRoot, 'codex-state-titled', workspacePath, 'First prompt fallback'); + await withIsolatedDatabase(async () => { + const synchronizer = new CodexSessionSynchronizer(); + await synchronizer.synchronize(); + assert.equal(sessionsDb.getSessionById('codex-state-titled')?.custom_name, 'Untitled Codex Session'); + sessionsDb.updateSessionIsArchived('codex-state-titled', true); + + const stateDb = new Database(path.join(tempRoot, '.codex', 'state_5.sqlite')); + stateDb.exec('CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT NOT NULL)'); + stateDb.prepare('INSERT INTO threads (id, title) VALUES (?, ?)') + .run('codex-state-titled', 'Fix login redirect'); + stateDb.close(); + + await synchronizer.synchronize( + new Date(Date.now() + 60_000), + { initializing: true }, + ); + assert.equal(sessionsDb.getSessionById('codex-state-titled')?.custom_name, 'Fix login redirect'); + assert.equal(sessionsDb.getSessionById('codex-state-titled')?.isArchived, 1); + }); + } finally { + restoreHomeDir(); + await rm(tempRoot, { recursive: true, force: true }); + } +}); + +test('Codex file synchronization does not reload the full state title database', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-session-file-sync-state-title-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await mkdir(workspacePath, { recursive: true }); + const restoreHomeDir = patchHomeDir(tempRoot); + + try { + const transcriptPath = await writeCodexTranscript( + tempRoot, + 'codex-state-file-sync', + workspacePath, + 'First prompt fallback', + ); + await withIsolatedDatabase(async () => { + const stateDb = new Database(path.join(tempRoot, '.codex', 'state_5.sqlite')); + stateDb.exec('CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT NOT NULL)'); + stateDb.prepare('INSERT INTO threads (id, title) VALUES (?, ?)') + .run('codex-state-file-sync', 'Initial state title'); + + const synchronizer = new CodexSessionSynchronizer(); + await synchronizer.synchronize(undefined, { initializing: true }); + assert.equal( + sessionsDb.getSessionById('codex-state-file-sync')?.custom_name, + 'Initial state title', + ); + + stateDb.prepare('UPDATE threads SET title = ? WHERE id = ?') + .run('Changed state title', 'codex-state-file-sync'); + stateDb.close(); + + await synchronizer.synchronizeFile(transcriptPath); + + assert.equal( + sessionsDb.getSessionById('codex-state-file-sync')?.custom_name, + 'Initial state title', + ); + }); + } finally { + restoreHomeDir(); + await rm(tempRoot, { recursive: true, force: true }); + } +}); + +test('Codex full synchronization does not reload state titles after initialization', { concurrency: false }, async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-session-full-sync-state-title-')); + const workspacePath = path.join(tempRoot, 'workspace'); + await mkdir(workspacePath, { recursive: true }); + const restoreHomeDir = patchHomeDir(tempRoot); + + try { + await writeCodexTranscript( + tempRoot, + 'codex-state-full-sync', + workspacePath, + 'First prompt fallback', + ); + await withIsolatedDatabase(async () => { + const stateDb = new Database(path.join(tempRoot, '.codex', 'state_5.sqlite')); + stateDb.exec('CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT NOT NULL)'); + stateDb.prepare('INSERT INTO threads (id, title) VALUES (?, ?)') + .run('codex-state-full-sync', 'Initial state title'); + + const synchronizer = new CodexSessionSynchronizer(); + await synchronizer.synchronize(undefined, { initializing: true }); + assert.equal( + sessionsDb.getSessionById('codex-state-full-sync')?.custom_name, + 'Initial state title', + ); + + stateDb.prepare('UPDATE threads SET title = ? WHERE id = ?') + .run('Changed state title', 'codex-state-full-sync'); + stateDb.close(); + + await synchronizer.synchronize(); + + assert.equal( + sessionsDb.getSessionById('codex-state-full-sync')?.custom_name, + 'Initial state title', + ); + }); + } finally { + restoreHomeDir(); + await rm(tempRoot, { recursive: true, force: true }); + } +}); + test('Codex synchronizer skips sub-agent rollout files', { concurrency: false }, async () => { const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-session-sync-subagent-')); const workspacePath = path.join(tempRoot, 'workspace'); diff --git a/server/modules/providers/tests/provider-token-usage.service.test.ts b/server/modules/providers/tests/provider-token-usage.service.test.ts index 636da1ef85..813d579cf6 100644 --- a/server/modules/providers/tests/provider-token-usage.service.test.ts +++ b/server/modules/providers/tests/provider-token-usage.service.test.ts @@ -17,6 +17,7 @@ function createSessionRow(overrides: Record = {}) { project_path: null, jsonl_path: null, custom_name: null, + custom_name_source: null, model: null, isArchived: 0, created_at: '2026-01-01T00:00:00.000Z', diff --git a/server/shared/interfaces.ts b/server/shared/interfaces.ts index f220402baf..b91d2ae936 100644 --- a/server/shared/interfaces.ts +++ b/server/shared/interfaces.ts @@ -168,11 +168,15 @@ export interface IProviderSessions { * interface for both full rescans and single-file incremental sync triggered * by filesystem watcher events. */ +export type SessionSynchronizeOptions = { + initializing?: boolean; +}; + export interface IProviderSessionSynchronizer { /** * Scans provider session artifacts and upserts discovered sessions into DB. */ - synchronize(since?: Date): Promise; + synchronize(since?: Date, options?: SessionSynchronizeOptions): Promise; /** * Parses and upserts one provider artifact file without running a full scan.