Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions server/modules/database/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
created_at = COALESCE(sessions.created_at, excluded.created_at),
updated_at = COALESCE(excluded.updated_at, sessions.updated_at)
`);
Expand All @@ -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 => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -464,6 +494,7 @@ export const runMigrations = (db: Database) => {

migrateLegacyWorkspaceTableIntoProjects(db);
rebuildSessionsTableWithProjectSchema(db);
addSessionCustomNameSource(db);
migrateLegacySessionNames(db);
addProviderSessionIdMapping(db);
addSessionModelColumn(db);
Expand Down
78 changes: 68 additions & 10 deletions server/modules/database/repositories/sessions.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}$/;

Expand Down Expand Up @@ -103,14 +104,23 @@ 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,
updatedAtValue,
normalizedProjectPath,
jsonlPath ?? null,
customName ?? null,
customName ?? null,
existing.session_id
);

Expand All @@ -121,21 +131,29 @@ 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,
updated_at = excluded.updated_at,
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,
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions server/modules/database/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions server/modules/database/tests/sessions-provider-mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
95 changes: 94 additions & 1 deletion server/modules/database/tests/sessions.db.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>): Promise<void> {
async function withIsolatedDatabase(
runTest: () => void | Promise<void>,
prepareDatabase?: (databasePath: string) => void,
): Promise<void> {
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 {
Expand All @@ -30,6 +36,93 @@ async function withIsolatedDatabase(runTest: () => void | Promise<void>): 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');
Expand Down
3 changes: 1 addition & 2 deletions server/modules/providers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down Expand Up @@ -377,4 +377,3 @@ alongside the implementation.
user/project skill folders.
- Assuming one provider's MCP config file format works for the others.


Loading