From c6c6c71196362a7f350d5255963199938ec40c53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=99=E8=BE=89?= <42717106+OnlyYu1996@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:58:49 +0800 Subject: [PATCH] fix(opencode): use cross-platform data directory (#10362) * fix(opencode): use cross-platform data directory * fix(opencode): honor in-memory database override * fix(opencode): harden database discovery coverage * test(opencode): reproduce Windows session discovery --------- Co-authored-by: OrcaWin Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../session-scanner-opencode-sources.test.ts | 44 +++++++++ .../session-scanner-opencode-sources.ts | 9 +- .../scanner-windows-data-directory.test.ts | 90 +++++++++++++++++++ src/main/opencode-usage/scanner.test.ts | 14 +++ src/main/opencode-usage/scanner.ts | 47 +++++----- .../opencode/opencode-data-directory.test.ts | 46 ++++++++++ src/main/opencode/opencode-data-directory.ts | 17 ++++ 7 files changed, 237 insertions(+), 30 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-opencode-sources.test.ts create mode 100644 src/main/opencode-usage/scanner-windows-data-directory.test.ts create mode 100644 src/main/opencode/opencode-data-directory.test.ts create mode 100644 src/main/opencode/opencode-data-directory.ts diff --git a/src/main/ai-vault/session-scanner-opencode-sources.test.ts b/src/main/ai-vault/session-scanner-opencode-sources.test.ts new file mode 100644 index 00000000000..12ca270871a --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sources.test.ts @@ -0,0 +1,44 @@ +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { opencodeDiscoveries } from './session-scanner-opencode-sources' + +const { discoverOpenCodeSessionsMock, listOpenCodeDatabasesMock } = vi.hoisted(() => ({ + discoverOpenCodeSessionsMock: vi.fn(), + listOpenCodeDatabasesMock: vi.fn() +})) + +vi.mock('./session-scanner-opencode-sqlite-discovery', () => ({ + discoverOpenCodeSessions: discoverOpenCodeSessionsMock +})) + +vi.mock('../opencode-usage/scanner', () => ({ + listOpenCodeDatabases: listOpenCodeDatabasesMock +})) + +describe('opencodeDiscoveries', () => { + afterEach(() => { + vi.unstubAllEnvs() + vi.clearAllMocks() + }) + + it('discovers local storage from the OpenCode XDG data directory', async () => { + vi.stubEnv('XDG_DATA_HOME', '/xdg/data') + vi.stubEnv('OPENCODE_CONFIG_DIR', '/opencode/config') + listOpenCodeDatabasesMock.mockResolvedValue([]) + discoverOpenCodeSessionsMock.mockResolvedValue({ + agent: 'opencode', + rootDir: '/xdg/data/opencode/storage', + files: [] + }) + const issues = [] + + await Promise.all(opencodeDiscoveries({}, [], 25, issues)) + + expect(discoverOpenCodeSessionsMock).toHaveBeenCalledWith({ + storageDir: join('/xdg/data', 'opencode', 'storage'), + dbPaths: [], + limitPerAgent: 25, + issues + }) + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sources.ts b/src/main/ai-vault/session-scanner-opencode-sources.ts index e4934aa8fda..6d84965f8c0 100644 --- a/src/main/ai-vault/session-scanner-opencode-sources.ts +++ b/src/main/ai-vault/session-scanner-opencode-sources.ts @@ -1,16 +1,11 @@ import { readdir } from 'node:fs/promises' -import { homedir } from 'node:os' import { dirname, join } from 'node:path' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { resolveOpenCodeStorageDirectory } from '../opencode/opencode-data-directory' import { listOpenCodeDatabases } from '../opencode-usage/scanner' import { discoverOpenCodeSessions } from './session-scanner-opencode-sqlite-discovery' import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' -const OPENCODE_STORAGE_DIR = join( - process.env.OPENCODE_CONFIG_DIR?.trim() || join(homedir(), '.local', 'share', 'opencode'), - 'storage' -) - export function opencodeDiscoveries( options: AiVaultScanOptions, wslHomeDirs: readonly string[], @@ -33,7 +28,7 @@ function opencodeStorageDirs( wslHomeDirs: readonly string[] ): string[] { return [ - options.opencodeStorageDir ?? OPENCODE_STORAGE_DIR, + options.opencodeStorageDir ?? resolveOpenCodeStorageDirectory(), ...wslHomeDirs.map((homeDir) => join(homeDir, '.local', 'share', 'opencode', 'storage')) ] } diff --git a/src/main/opencode-usage/scanner-windows-data-directory.test.ts b/src/main/opencode-usage/scanner-windows-data-directory.test.ts new file mode 100644 index 00000000000..317e7b57954 --- /dev/null +++ b/src/main/opencode-usage/scanner-windows-data-directory.test.ts @@ -0,0 +1,90 @@ +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { opencodeDiscoveries } from '../ai-vault/session-scanner-opencode-sources' +import Database from '../sqlite/sync-database' +import { scanOpenCodeUsageDatabases } from './scanner' + +vi.mock('../ai-vault/session-scanner-opencode-sqlite-worker-spawn', async () => { + const { listOpenCodeSqliteSessions } = + await import('../ai-vault/session-scanner-opencode-sqlite-list') + return { listOpenCodeSqliteSessionsViaWorker: listOpenCodeSqliteSessions } +}) + +describe('OpenCode usage discovery on Windows', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! + const environmentKeys = [ + 'HOME', + 'USERPROFILE', + 'LOCALAPPDATA', + 'APPDATA', + 'XDG_DATA_HOME', + 'OPENCODE_DB' + ] as const + let originalEnvironment: Partial> + let homeDirectory: string + + beforeEach(() => { + originalEnvironment = Object.fromEntries(environmentKeys.map((key) => [key, process.env[key]])) + homeDirectory = mkdtempSync(join(tmpdir(), 'orca-opencode-windows-home-')) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + process.env.HOME = homeDirectory + process.env.USERPROFILE = homeDirectory + process.env.LOCALAPPDATA = join(homeDirectory, 'AppData', 'Local') + process.env.APPDATA = join(homeDirectory, 'AppData', 'Roaming') + delete process.env.XDG_DATA_HOME + delete process.env.OPENCODE_DB + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', originalPlatform) + for (const key of environmentKeys) { + const value = originalEnvironment[key] + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + rmSync(homeDirectory, { recursive: true, force: true }) + }) + + it('loads a session from ~/.local/share instead of the Windows app-data directory', async () => { + mkdirSync(process.env.LOCALAPPDATA!, { recursive: true }) + const dataDirectory = join(homeDirectory, '.local', 'share', 'opencode') + mkdirSync(dataDirectory, { recursive: true }) + const databasePath = join(dataDirectory, 'opencode.db') + const database = new Database(databasePath) + database.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + directory TEXT, + title TEXT, + model TEXT, + cost REAL, + tokens_input INTEGER, + tokens_output INTEGER, + tokens_reasoning INTEGER, + tokens_cache_read INTEGER, + time_created INTEGER, + time_updated INTEGER + ); + INSERT INTO session VALUES ( + 'windows-session', 'C:\\repo', 'Windows session', + '{"providerID":"openai","id":"gpt-5"}', 0.01, + 100, 20, 0, 0, 1777777700000, 1777777800000 + ); + `) + database.close() + + const result = await scanOpenCodeUsageDatabases([], []) + const issues = [] + const [discovery] = await Promise.all(opencodeDiscoveries({}, [], 25, issues)) + + expect(discovery?.files.map(({ path }) => path)).toEqual([`${databasePath}#windows-session`]) + expect(issues).toEqual([]) + expect(result.processedDatabases.map(({ path }) => path)).toEqual([databasePath]) + expect(result.sessions.map(({ sessionId }) => sessionId)).toEqual(['windows-session']) + }) +}) diff --git a/src/main/opencode-usage/scanner.test.ts b/src/main/opencode-usage/scanner.test.ts index f938dd815b0..9680fea70fd 100644 --- a/src/main/opencode-usage/scanner.test.ts +++ b/src/main/opencode-usage/scanner.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import Database from '../sqlite/sync-database' import { attributeOpenCodeUsageEvent, + listOpenCodeDatabases, parseOpenCodeUsageDatabase, parseOpenCodeUsageRow, scanOpenCodeUsageDatabases @@ -422,6 +423,19 @@ describe('scanOpenCodeUsageDatabases', () => { return path } + it('does not scan disk databases when OPENCODE_DB uses memory', async () => { + writeSessionTotalsDb('opencode.db', []) + process.env.OPENCODE_DB = ':memory:' + + await expect(listOpenCodeDatabases()).resolves.toEqual([]) + }) + + it('does not return a directory configured as OPENCODE_DB', async () => { + process.env.OPENCODE_DB = '.' + + await expect(listOpenCodeDatabases()).resolves.toEqual([]) + }) + it('counts a session duplicated into a stale backup database exactly once', async () => { // The backup holds a stale snapshot of session-1; the canonical db has // grown since. The canonical totals must win and be counted once. diff --git a/src/main/opencode-usage/scanner.ts b/src/main/opencode-usage/scanner.ts index 7d9b0ee1cdf..ec532560ade 100644 --- a/src/main/opencode-usage/scanner.ts +++ b/src/main/opencode-usage/scanner.ts @@ -1,11 +1,10 @@ /* eslint-disable max-lines -- Why: OpenCode usage analytics need to normalize multiple local DB schema generations, attribute worktrees, and build persisted projections in one auditable pipeline. */ -import { existsSync } from 'node:fs' import { readdir, realpath, stat } from 'node:fs/promises' -import { homedir } from 'node:os' import { basename, isAbsolute, join, posix, win32 } from 'node:path' import { yieldToEventLoop } from '../../shared/event-loop-yield' import type { Repo } from '../../shared/types' import { areWorktreePathsEqual } from '../ipc/worktree-logic' +import { resolveOpenCodeDataDirectory } from '../opencode/opencode-data-directory' import Database from '../sqlite/sync-database' import { columnExists, tableExists } from './schema-helpers' import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer' @@ -80,42 +79,44 @@ function looksLikeWindowsPath(pathValue: string): boolean { return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') } -function getXdgDataHome(): string { - if (process.env.XDG_DATA_HOME?.trim()) { - return process.env.XDG_DATA_HOME.trim() - } - if (process.platform === 'win32') { - return process.env.LOCALAPPDATA || process.env.APPDATA || join(homedir(), 'AppData', 'Local') - } - return join(homedir(), '.local', 'share') +type OpenCodeDatabaseOverride = { + isConfigured: boolean + path: string | null } -function getOpenCodeDataDirectory(): string { - return join(getXdgDataHome(), 'opencode') -} - -function getOpenCodeDatabasePathFromEnv(): string | null { +function getOpenCodeDatabaseOverride(dataDirectory: string): OpenCodeDatabaseOverride { const raw = process.env.OPENCODE_DB?.trim() if (!raw) { - return null + return { isConfigured: false, path: null } } if (raw === ':memory:') { - return null + return { isConfigured: true, path: null } + } + return { + isConfigured: true, + path: isAbsolute(raw) ? raw : join(dataDirectory, raw) } - return isAbsolute(raw) ? raw : join(getOpenCodeDataDirectory(), raw) } export async function listOpenCodeDatabases(): Promise { - const envPath = getOpenCodeDatabasePathFromEnv() - if (envPath) { - return existsSync(envPath) ? [envPath] : [] + const dataDirectory = resolveOpenCodeDataDirectory() + const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory) + if (databaseOverride.isConfigured) { + if (!databaseOverride.path) { + return [] + } + try { + return (await stat(databaseOverride.path)).isFile() ? [databaseOverride.path] : [] + } catch { + return [] + } } try { - const entries = await readdir(getOpenCodeDataDirectory(), { withFileTypes: true }) + const entries = await readdir(dataDirectory, { withFileTypes: true }) return entries .filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name)) - .map((entry) => join(getOpenCodeDataDirectory(), entry.name)) + .map((entry) => join(dataDirectory, entry.name)) .sort() } catch { return [] diff --git a/src/main/opencode/opencode-data-directory.test.ts b/src/main/opencode/opencode-data-directory.test.ts new file mode 100644 index 00000000000..e3b9073aa17 --- /dev/null +++ b/src/main/opencode/opencode-data-directory.test.ts @@ -0,0 +1,46 @@ +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + resolveOpenCodeDataDirectory, + resolveOpenCodeStorageDirectory +} from './opencode-data-directory' + +function stubPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) +} + +describe('resolveOpenCodeDataDirectory', () => { + const realPlatform = process.platform + + afterEach(() => { + stubPlatform(realPlatform) + }) + + it('uses XDG_DATA_HOME when it is configured', () => { + expect(resolveOpenCodeDataDirectory({ XDG_DATA_HOME: ' /custom/data ' }, '/users/test')).toBe( + join('/custom/data', 'opencode') + ) + }) + + it.each(['win32', 'darwin', 'linux'])( + 'uses the OpenCode cross-platform default instead of app-data directories on %s', + (platform) => { + stubPlatform(platform) + const environment = { + LOCALAPPDATA: 'C:\\Users\\test\\AppData\\Local', + APPDATA: 'C:\\Users\\test\\AppData\\Roaming', + OPENCODE_CONFIG_DIR: 'C:\\Users\\test\\.config\\opencode' + } + + expect(resolveOpenCodeDataDirectory(environment, '/users/test')).toBe( + join('/users/test', '.local', 'share', 'opencode') + ) + } + ) + + it('derives legacy storage from the same data directory', () => { + expect(resolveOpenCodeStorageDirectory({}, '/users/test')).toBe( + join('/users/test', '.local', 'share', 'opencode', 'storage') + ) + }) +}) diff --git a/src/main/opencode/opencode-data-directory.ts b/src/main/opencode/opencode-data-directory.ts new file mode 100644 index 00000000000..b3db5598cee --- /dev/null +++ b/src/main/opencode/opencode-data-directory.ts @@ -0,0 +1,17 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' + +export function resolveOpenCodeDataDirectory( + environment: NodeJS.ProcessEnv = process.env, + homeDirectory = homedir() +): string { + const xdgDataHome = environment.XDG_DATA_HOME?.trim() + return join(xdgDataHome || join(homeDirectory, '.local', 'share'), 'opencode') +} + +export function resolveOpenCodeStorageDirectory( + environment: NodeJS.ProcessEnv = process.env, + homeDirectory = homedir() +): string { + return join(resolveOpenCodeDataDirectory(environment, homeDirectory), 'storage') +}