Files
claudecodeui/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts
2026-05-13 17:43:10 +03:00

158 lines
4.9 KiB
TypeScript

import fsSync from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import { sessionsDb } from '@/modules/database/index.js';
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
import {
getOpenCodeDatabasePath,
normalizeProviderTimestamp,
normalizeSessionName,
readJsonRecord,
readOptionalString,
} from '@/shared/utils.js';
type OpenCodeSessionRow = {
id: string;
directory: string | null;
title: string | null;
time_created: number | null;
time_updated: number | null;
worktree: string | null;
};
type SynchronizeRowsResult = {
processed: number;
firstSessionId: string | null;
};
/**
* Session indexer for OpenCode's SQLite-backed session store.
*/
export class OpenCodeSessionSynchronizer implements IProviderSessionSynchronizer {
private readonly provider = 'opencode' as const;
/**
* Scans OpenCode's shared opencode.db and upserts active sessions into DB.
*/
async synchronize(since?: Date): Promise<number> {
const result = this.synchronizeRows(since);
return result.processed;
}
/**
* Handles watcher changes for opencode.db.
*/
async synchronizeFile(filePath: string): Promise<string | null> {
if (path.basename(filePath) !== 'opencode.db') {
return null;
}
const result = this.synchronizeRows(undefined, 1);
return result.firstSessionId;
}
private synchronizeRows(since?: Date, limit?: number): SynchronizeRowsResult {
const dbPath = getOpenCodeDatabasePath();
if (!fsSync.existsSync(dbPath)) {
return { processed: 0, firstSessionId: null };
}
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
try {
const sinceMillis = since?.getTime() ?? null;
const limitClause = limit ? 'LIMIT ?' : '';
const params = limit ? [sinceMillis, sinceMillis, limit] : [sinceMillis, sinceMillis];
const rows = db.prepare(`
SELECT
s.id AS id,
s.directory AS directory,
s.title AS title,
s.time_created AS time_created,
s.time_updated AS time_updated,
p.worktree AS worktree
FROM session s
LEFT JOIN project p ON p.id = s.project_id
WHERE s.time_archived IS NULL
AND (? IS NULL OR COALESCE(s.time_updated, s.time_created, 0) >= ?)
ORDER BY COALESCE(s.time_updated, s.time_created, 0) DESC, s.id DESC
${limitClause}
`).all(...params) as OpenCodeSessionRow[];
let processed = 0;
let firstSessionId: string | null = null;
for (const row of rows) {
const indexedSessionId = this.upsertSession(db, row);
if (!indexedSessionId) {
continue;
}
if (!firstSessionId) {
firstSessionId = indexedSessionId;
}
processed += 1;
}
return { processed, firstSessionId };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn('[OpenCodeProvider] Failed to synchronize sessions:', message);
return { processed: 0, firstSessionId: null };
} finally {
db.close();
}
}
private upsertSession(db: Database.Database, row: OpenCodeSessionRow): string | null {
const sessionId = readOptionalString(row.id);
const projectPath = readOptionalString(row.directory) ?? readOptionalString(row.worktree);
if (!sessionId || !projectPath) {
return null;
}
const fallbackTitle = 'Untitled OpenCode Session';
const existingSession = sessionsDb.getSessionById(sessionId);
const existingName = existingSession?.custom_name;
const nextName = existingName && existingName !== fallbackTitle
? existingName
: readOptionalString(row.title) ?? this.readFirstUserText(db, sessionId);
// OpenCode stores every session in one shared sqlite database, so jsonl_path
// must stay null to avoid deleting opencode.db when one app session is removed.
sessionsDb.createSession(
sessionId,
this.provider,
projectPath,
normalizeSessionName(nextName, fallbackTitle),
normalizeProviderTimestamp(row.time_created),
normalizeProviderTimestamp(row.time_updated ?? row.time_created),
null,
);
return sessionId;
}
private readFirstUserText(db: Database.Database, sessionId: string): string | undefined {
try {
const row = db.prepare(`
SELECT p.data AS data
FROM message m
INNER JOIN part p
ON p.session_id = m.session_id
AND p.message_id = m.id
WHERE m.session_id = ?
AND json_extract(m.data, '$.role') = 'user'
AND json_extract(p.data, '$.type') = 'text'
ORDER BY COALESCE(m.time_created, 0), COALESCE(p.time_created, 0)
LIMIT 1
`).get(sessionId) as { data: string | null } | undefined;
const data = readJsonRecord(row?.data);
return readOptionalString(data?.text);
} catch {
return undefined;
}
}
}