mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 00:03:20 +00:00
570 lines
17 KiB
TypeScript
570 lines
17 KiB
TypeScript
import { mkdtempSync, rmSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
import Database from '../sqlite/sync-database'
|
|
import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths'
|
|
import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-discovery'
|
|
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
|
|
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
|
|
|
let tempDirs: string[] = []
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs) {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
tempDirs = []
|
|
})
|
|
|
|
function createTempDb(): { db: Database.Database; path: string } {
|
|
const dir = mkdtempSync(join(tmpdir(), 'orca-opencode-sqlite-'))
|
|
tempDirs.push(dir)
|
|
const path = join(dir, 'opencode.db')
|
|
return { db: new Database(path), path }
|
|
}
|
|
|
|
function applyOpenCodeSchema(db: Database.Database): void {
|
|
db.exec(`
|
|
CREATE TABLE session (
|
|
id TEXT PRIMARY KEY,
|
|
project_id TEXT NOT NULL,
|
|
parent_id TEXT,
|
|
slug TEXT NOT NULL,
|
|
directory TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
version TEXT NOT NULL,
|
|
share_url TEXT,
|
|
summary_additions INTEGER,
|
|
summary_deletions INTEGER,
|
|
summary_files INTEGER,
|
|
summary_diffs TEXT,
|
|
revert TEXT,
|
|
permission TEXT,
|
|
time_created INTEGER NOT NULL,
|
|
time_updated INTEGER NOT NULL,
|
|
time_compacting INTEGER,
|
|
time_archived INTEGER,
|
|
workspace_id TEXT,
|
|
path TEXT,
|
|
agent TEXT,
|
|
model TEXT,
|
|
cost REAL DEFAULT 0 NOT NULL,
|
|
tokens_input INTEGER DEFAULT 0 NOT NULL,
|
|
tokens_output INTEGER DEFAULT 0 NOT NULL,
|
|
tokens_reasoning INTEGER DEFAULT 0 NOT NULL,
|
|
tokens_cache_read INTEGER DEFAULT 0 NOT NULL,
|
|
tokens_cache_write INTEGER DEFAULT 0 NOT NULL,
|
|
metadata TEXT
|
|
);
|
|
CREATE TABLE message (
|
|
id TEXT PRIMARY KEY,
|
|
session_id TEXT NOT NULL,
|
|
time_created INTEGER NOT NULL,
|
|
time_updated INTEGER NOT NULL,
|
|
data TEXT NOT NULL
|
|
);
|
|
CREATE TABLE part (
|
|
id TEXT PRIMARY KEY,
|
|
message_id TEXT NOT NULL,
|
|
session_id TEXT NOT NULL,
|
|
time_created INTEGER NOT NULL,
|
|
time_updated INTEGER NOT NULL,
|
|
data TEXT NOT NULL
|
|
);
|
|
CREATE TABLE project (
|
|
id TEXT PRIMARY KEY,
|
|
worktree TEXT NOT NULL,
|
|
vcs TEXT,
|
|
name TEXT,
|
|
icon_url TEXT,
|
|
icon_color TEXT,
|
|
time_created INTEGER NOT NULL,
|
|
time_updated INTEGER NOT NULL,
|
|
time_initialized INTEGER,
|
|
sandboxes TEXT NOT NULL,
|
|
commands TEXT,
|
|
icon_url_override TEXT
|
|
);
|
|
`)
|
|
}
|
|
|
|
function applyMinimalOpenCodeSchema(db: Database.Database): void {
|
|
db.exec(`CREATE TABLE session (
|
|
id TEXT PRIMARY KEY,
|
|
time_created INTEGER NOT NULL,
|
|
time_updated INTEGER NOT NULL
|
|
);`)
|
|
}
|
|
|
|
function insertSession(
|
|
db: Database.Database,
|
|
args: {
|
|
id: string
|
|
title?: string
|
|
directory?: string
|
|
timeCreated: number
|
|
timeUpdated: number
|
|
parentId?: string | null
|
|
timeArchived?: number | null
|
|
model?: string | null
|
|
agent?: string | null
|
|
tokensInput?: number
|
|
tokensOutput?: number
|
|
tokensReasoning?: number
|
|
tokensCacheRead?: number
|
|
cost?: number
|
|
}
|
|
): void {
|
|
db.prepare(
|
|
`INSERT INTO session (id, project_id, parent_id, slug, directory, title, version,
|
|
time_created, time_updated, time_archived, model, agent, cost,
|
|
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write)
|
|
VALUES (?, 'proj-1', ?, ?, ?, ?, '1.0.0',
|
|
?, ?, ?, ?, ?, ?,
|
|
?, ?, ?, ?, 0)`
|
|
).run(
|
|
args.id,
|
|
args.parentId ?? null,
|
|
`slug-${args.id}`,
|
|
args.directory ?? '/tmp/opencode',
|
|
args.title ?? 'OpenCode title',
|
|
args.timeCreated,
|
|
args.timeUpdated,
|
|
args.timeArchived ?? null,
|
|
args.model ?? JSON.stringify({ id: 'glm-5.2', providerID: 'zai-coding-plan' }),
|
|
args.agent ?? 'build',
|
|
args.cost ?? 0,
|
|
args.tokensInput ?? 100,
|
|
args.tokensOutput ?? 40,
|
|
args.tokensReasoning ?? 10,
|
|
args.tokensCacheRead ?? 5
|
|
)
|
|
}
|
|
|
|
function insertMessage(
|
|
db: Database.Database,
|
|
args: {
|
|
id: string
|
|
sessionId: string
|
|
role: 'user' | 'assistant'
|
|
timeCreated: number
|
|
summaryTitle?: string | null
|
|
summaryBody?: string | null
|
|
}
|
|
): void {
|
|
const data = JSON.stringify({
|
|
role: args.role,
|
|
time: { created: args.timeCreated },
|
|
agent: 'build',
|
|
summary:
|
|
args.summaryTitle || args.summaryBody
|
|
? { title: args.summaryTitle ?? null, body: args.summaryBody ?? null }
|
|
: undefined
|
|
})
|
|
db.prepare(
|
|
`INSERT INTO message (id, session_id, time_created, time_updated, data)
|
|
VALUES (?, ?, ?, ?, ?)`
|
|
).run(args.id, args.sessionId, args.timeCreated, args.timeCreated, data)
|
|
}
|
|
|
|
function insertPart(
|
|
db: Database.Database,
|
|
args: {
|
|
id: string
|
|
messageId: string
|
|
sessionId: string
|
|
timeCreated: number
|
|
type?: 'text' | 'tool' | 'reasoning'
|
|
text?: string
|
|
data?: string
|
|
}
|
|
): void {
|
|
const data =
|
|
args.data ??
|
|
JSON.stringify({
|
|
type: args.type ?? 'text',
|
|
text: args.text ?? 'hello world'
|
|
})
|
|
db.prepare(
|
|
`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data)
|
|
VALUES (?, ?, ?, ?, ?, ?)`
|
|
).run(args.id, args.messageId, args.sessionId, args.timeCreated, args.timeCreated, data)
|
|
}
|
|
describe('listOpenCodeSqliteSessions', () => {
|
|
it('returns candidates sorted by time_updated desc via the synthesized mtimeMs', async () => {
|
|
const { db, path } = createTempDb()
|
|
applyOpenCodeSchema(db)
|
|
insertSession(db, {
|
|
id: 'ses_old',
|
|
title: 'Old',
|
|
timeCreated: 1_777_634_000_000,
|
|
timeUpdated: 1_777_634_001_000
|
|
})
|
|
insertSession(db, {
|
|
id: 'ses_new',
|
|
title: 'New',
|
|
timeCreated: 1_777_634_002_000,
|
|
timeUpdated: 1_777_634_003_000
|
|
})
|
|
db.close()
|
|
|
|
const issues: AiVaultScanIssue[] = []
|
|
const candidates = await listOpenCodeSqliteSessions({
|
|
dbPaths: [path],
|
|
limit: 10,
|
|
issues
|
|
})
|
|
expect(issues).toEqual([])
|
|
expect(candidates).toHaveLength(2)
|
|
expect(candidates[0].agent).toBe('opencode')
|
|
expect(candidates[0].file.mtimeMs).toBe(1_777_634_003_000)
|
|
expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_new'))
|
|
expect(candidates[1].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_old'))
|
|
})
|
|
|
|
it('discovers sessions when message content is malformed and part/event rows are noisy', async () => {
|
|
const { db, path } = createTempDb()
|
|
applyOpenCodeSchema(db)
|
|
db.exec(`CREATE TABLE event (id TEXT PRIMARY KEY, data TEXT NOT NULL)`)
|
|
insertSession(db, {
|
|
id: 'ses_noise',
|
|
timeCreated: 1_777_633_999_000,
|
|
timeUpdated: 1_777_634_000_000
|
|
})
|
|
insertSession(db, {
|
|
id: 'ses_clean',
|
|
timeCreated: 1_777_634_000_000,
|
|
timeUpdated: 1_777_634_001_000
|
|
})
|
|
db.prepare(
|
|
`INSERT INTO message (id, session_id, time_created, time_updated, data)
|
|
VALUES ('msg_malformed', 'ses_noise', 1777634000500, 1777634000500, ?)`
|
|
).run('malformed message JSON')
|
|
db.prepare(`INSERT INTO event (id, data) VALUES ('event_1', ?)`).run('malformed event content')
|
|
db.close()
|
|
|
|
const { db: partDb, path: partPath } = createTempDb()
|
|
applyOpenCodeSchema(partDb)
|
|
insertSession(partDb, {
|
|
id: 'ses_part_noise',
|
|
timeCreated: 1_777_634_002_000,
|
|
timeUpdated: 1_777_634_002_000
|
|
})
|
|
insertMessage(partDb, {
|
|
id: 'msg_part_noise',
|
|
sessionId: 'ses_part_noise',
|
|
role: 'assistant',
|
|
timeCreated: 1_777_634_002_000
|
|
})
|
|
insertPart(partDb, {
|
|
id: 'part_malformed',
|
|
messageId: 'msg_part_noise',
|
|
sessionId: 'ses_part_noise',
|
|
timeCreated: 1_777_634_002_000,
|
|
data: 'malformed part JSON'
|
|
})
|
|
partDb.close()
|
|
|
|
const issues: AiVaultScanIssue[] = []
|
|
const candidates = await listOpenCodeSqliteSessions({
|
|
dbPaths: [path, partPath],
|
|
limit: 10,
|
|
issues
|
|
})
|
|
|
|
expect(issues).toEqual([])
|
|
expect(candidates.map((candidate) => candidate.file.path)).toEqual([
|
|
buildOpenCodeSqliteCandidatePath(partPath, 'ses_part_noise'),
|
|
buildOpenCodeSqliteCandidatePath(path, 'ses_clean'),
|
|
buildOpenCodeSqliteCandidatePath(path, 'ses_noise')
|
|
])
|
|
const cleanSession = await parseOpenCodeSqliteSession({
|
|
dbPath: path,
|
|
sessionId: 'ses_clean',
|
|
platform: 'darwin'
|
|
})
|
|
expect(cleanSession?.sessionId).toBe('ses_clean')
|
|
await expect(
|
|
parseOpenCodeSqliteSession({ dbPath: path, sessionId: 'ses_noise', platform: 'darwin' })
|
|
).rejects.toThrow('malformed JSON')
|
|
})
|
|
|
|
it('dedups matching session ids across databases and keeps the newest row', async () => {
|
|
const { db: oldDb, path: oldPath } = createTempDb()
|
|
applyOpenCodeSchema(oldDb)
|
|
insertSession(oldDb, {
|
|
id: 'ses_duplicate',
|
|
title: 'Old duplicate',
|
|
timeCreated: 1_777_634_000_000,
|
|
timeUpdated: 1_777_634_001_000
|
|
})
|
|
oldDb.close()
|
|
|
|
const { db: newDb, path: newPath } = createTempDb()
|
|
applyOpenCodeSchema(newDb)
|
|
insertSession(newDb, {
|
|
id: 'ses_duplicate',
|
|
title: 'New duplicate',
|
|
timeCreated: 1_777_634_002_000,
|
|
timeUpdated: 1_777_634_003_000
|
|
})
|
|
newDb.close()
|
|
|
|
const candidates = await listOpenCodeSqliteSessions({
|
|
dbPaths: [oldPath, newPath],
|
|
limit: 10,
|
|
issues: []
|
|
})
|
|
expect(candidates).toHaveLength(1)
|
|
expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(newPath, 'ses_duplicate'))
|
|
})
|
|
it('excludes archived and child sessions', async () => {
|
|
const { db, path } = createTempDb()
|
|
applyOpenCodeSchema(db)
|
|
insertSession(db, {
|
|
id: 'ses_normal',
|
|
timeCreated: 1_777_634_000_000,
|
|
timeUpdated: 1_777_634_001_000
|
|
})
|
|
insertSession(db, {
|
|
id: 'ses_archived',
|
|
timeCreated: 1_777_634_000_000,
|
|
timeUpdated: 1_777_634_002_000,
|
|
timeArchived: 1_777_634_002_500
|
|
})
|
|
insertSession(db, {
|
|
id: 'ses_child',
|
|
timeCreated: 1_777_634_000_000,
|
|
timeUpdated: 1_777_634_003_000,
|
|
parentId: 'ses_normal'
|
|
})
|
|
db.close()
|
|
|
|
const candidates = await listOpenCodeSqliteSessions({
|
|
dbPaths: [path],
|
|
limit: 10,
|
|
issues: []
|
|
})
|
|
expect(candidates.map((c) => c.file.path)).toEqual([
|
|
buildOpenCodeSqliteCandidatePath(path, 'ses_normal')
|
|
])
|
|
})
|
|
|
|
it('returns [] when the session table is missing (legacy install)', async () => {
|
|
const { db, path } = createTempDb()
|
|
db.exec('CREATE TABLE other (id TEXT)')
|
|
db.close()
|
|
const candidates = await listOpenCodeSqliteSessions({
|
|
dbPaths: [path],
|
|
limit: 10,
|
|
issues: []
|
|
})
|
|
expect(candidates).toEqual([])
|
|
})
|
|
|
|
it('records an issue when the DB file does not exist', async () => {
|
|
const issues: AiVaultScanIssue[] = []
|
|
const candidates = await listOpenCodeSqliteSessions({
|
|
dbPaths: ['/nonexistent/opencode.db'],
|
|
limit: 10,
|
|
issues
|
|
})
|
|
expect(candidates).toEqual([])
|
|
expect(issues).toHaveLength(1)
|
|
expect(issues[0].agent).toBe('opencode')
|
|
expect(issues[0].path).toBe('/nonexistent/opencode.db')
|
|
})
|
|
|
|
it('lists sessions from a minimal readable session table', async () => {
|
|
const { db, path } = createTempDb()
|
|
applyMinimalOpenCodeSchema(db)
|
|
db.prepare(`INSERT INTO session VALUES ('ses_minimal', 1777634000000, 1777634001000)`).run()
|
|
db.close()
|
|
|
|
const issues: AiVaultScanIssue[] = []
|
|
const candidates = await listOpenCodeSqliteSessions({
|
|
dbPaths: [path],
|
|
limit: 10,
|
|
issues
|
|
})
|
|
expect(issues).toEqual([])
|
|
expect(candidates).toHaveLength(1)
|
|
expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_minimal'))
|
|
})
|
|
})
|
|
|
|
describe('parseOpenCodeSqliteSession', () => {
|
|
it('builds an AiVaultSession with title, cwd, model, tokens, and resume command', async () => {
|
|
const { db, path } = createTempDb()
|
|
applyOpenCodeSchema(db)
|
|
insertSession(db, {
|
|
id: 'ses_1',
|
|
title: 'OpenCode title',
|
|
directory: '/tmp/opencode',
|
|
timeCreated: 1_777_634_000_000,
|
|
timeUpdated: 1_777_634_001_000,
|
|
tokensInput: 100,
|
|
tokensOutput: 40,
|
|
tokensReasoning: 10,
|
|
tokensCacheRead: 5,
|
|
cost: 0.01
|
|
})
|
|
insertMessage(db, {
|
|
id: 'msg_1',
|
|
sessionId: 'ses_1',
|
|
role: 'user',
|
|
timeCreated: 1_777_634_000_500,
|
|
summaryTitle: 'OpenCode title'
|
|
})
|
|
insertPart(db, {
|
|
id: 'prt_1',
|
|
messageId: 'msg_1',
|
|
sessionId: 'ses_1',
|
|
timeCreated: 1_777_634_000_500,
|
|
text: 'Plan the work'
|
|
})
|
|
insertMessage(db, {
|
|
id: 'msg_2',
|
|
sessionId: 'ses_1',
|
|
role: 'assistant',
|
|
timeCreated: 1_777_634_000_900
|
|
})
|
|
insertPart(db, {
|
|
id: 'prt_2',
|
|
messageId: 'msg_2',
|
|
sessionId: 'ses_1',
|
|
timeCreated: 1_777_634_001_000,
|
|
text: 'Done'
|
|
})
|
|
db.close()
|
|
|
|
const session = await parseOpenCodeSqliteSession({
|
|
dbPath: path,
|
|
sessionId: 'ses_1',
|
|
platform: 'darwin'
|
|
})
|
|
expect(session).not.toBeNull()
|
|
expect(session!.agent).toBe('opencode')
|
|
expect(session!.sessionId).toBe('ses_1')
|
|
expect(session!.filePath).toBe(path)
|
|
expect(session!.title).toBe('OpenCode title')
|
|
expect(session!.cwd).toBe('/tmp/opencode')
|
|
expect(session!.model).toBe('glm-5.2')
|
|
expect(session!.totalTokens).toBe(150)
|
|
expect(session!.messageCount).toBe(2)
|
|
expect(session!.createdAt).toBe(new Date(1_777_634_000_000).toISOString())
|
|
expect(session!.updatedAt).toBe(new Date(1_777_634_001_000).toISOString())
|
|
expect(session!.resumeCommand).toBe("cd '/tmp/opencode' && opencode --session 'ses_1'")
|
|
expect(session!.previewMessages).toHaveLength(2)
|
|
expect(session!.previewMessages[0].text).toBe('Plan the work')
|
|
expect(session!.previewMessages[0].role).toBe('user')
|
|
expect(session!.previewMessages[1].text).toBe('Done')
|
|
expect(session!.previewMessages[1].role).toBe('assistant')
|
|
})
|
|
|
|
it('falls back to summary.body for title when session.title is empty', async () => {
|
|
const { db, path } = createTempDb()
|
|
applyOpenCodeSchema(db)
|
|
insertSession(db, {
|
|
id: 'ses_2',
|
|
title: '',
|
|
timeCreated: 1_777_634_000_000,
|
|
timeUpdated: 1_777_634_001_000
|
|
})
|
|
insertMessage(db, {
|
|
id: 'msg_1',
|
|
sessionId: 'ses_2',
|
|
role: 'user',
|
|
timeCreated: 1_777_634_000_500,
|
|
summaryBody: 'fallback title from summary'
|
|
})
|
|
insertPart(db, {
|
|
id: 'prt_1',
|
|
messageId: 'msg_1',
|
|
sessionId: 'ses_2',
|
|
timeCreated: 1_777_634_000_500,
|
|
text: 'hello'
|
|
})
|
|
db.close()
|
|
|
|
const session = await parseOpenCodeSqliteSession({
|
|
dbPath: path,
|
|
sessionId: 'ses_2',
|
|
platform: 'darwin'
|
|
})
|
|
expect(session).not.toBeNull()
|
|
expect(session!.title).toBe('fallback title from summary')
|
|
})
|
|
|
|
it('returns null when the session id is not found', async () => {
|
|
const { db, path } = createTempDb()
|
|
applyOpenCodeSchema(db)
|
|
insertSession(db, {
|
|
id: 'ses_real',
|
|
timeCreated: 1_777_634_000_000,
|
|
timeUpdated: 1_777_634_001_000
|
|
})
|
|
db.close()
|
|
const session = await parseOpenCodeSqliteSession({
|
|
dbPath: path,
|
|
sessionId: 'ses_missing',
|
|
platform: 'darwin'
|
|
})
|
|
expect(session).toBeNull()
|
|
})
|
|
|
|
it('returns null when the DB has no session table', async () => {
|
|
const { db, path } = createTempDb()
|
|
db.exec('CREATE TABLE other (id TEXT)')
|
|
db.close()
|
|
const session = await parseOpenCodeSqliteSession({
|
|
dbPath: path,
|
|
sessionId: 'ses_1',
|
|
platform: 'darwin'
|
|
})
|
|
expect(session).toBeNull()
|
|
})
|
|
|
|
it('parses a minimal readable session table without optional columns or messages', async () => {
|
|
const { db, path } = createTempDb()
|
|
applyMinimalOpenCodeSchema(db)
|
|
db.prepare(`INSERT INTO session VALUES ('ses_minimal', 1777634000000, 1777634001000)`).run()
|
|
db.close()
|
|
|
|
const session = await parseOpenCodeSqliteSession({
|
|
dbPath: path,
|
|
sessionId: 'ses_minimal',
|
|
platform: 'darwin'
|
|
})
|
|
expect(session).not.toBeNull()
|
|
expect(session!.sessionId).toBe('ses_minimal')
|
|
expect(session!.filePath).toBe(path)
|
|
expect(session!.title).toBe('OpenCode ses_mini')
|
|
expect(session!.cwd).toBeNull()
|
|
expect(session!.model).toBeNull()
|
|
expect(session!.messageCount).toBe(0)
|
|
expect(session!.totalTokens).toBe(0)
|
|
expect(session!.previewMessages).toEqual([])
|
|
})
|
|
|
|
it('extracts model from older modelID schema', async () => {
|
|
const { db, path } = createTempDb()
|
|
applyOpenCodeSchema(db)
|
|
insertSession(db, {
|
|
id: 'ses_3',
|
|
timeCreated: 1_777_634_000_000,
|
|
timeUpdated: 1_777_634_001_000,
|
|
model: JSON.stringify({ modelID: 'claude-sonnet-4-5' })
|
|
})
|
|
db.close()
|
|
const session = await parseOpenCodeSqliteSession({
|
|
dbPath: path,
|
|
sessionId: 'ses_3',
|
|
platform: 'darwin'
|
|
})
|
|
expect(session).not.toBeNull()
|
|
expect(session!.model).toBe('claude-sonnet-4-5')
|
|
})
|
|
})
|