Skip to content
Merged
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
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,12 @@ COPY --from=deps --chown=node:node /app/node_modules ./node_modules
COPY --from=builder /app/shared ./shared
COPY --from=builder /app/backend ./backend
COPY --from=builder /app/frontend/dist ./frontend/dist
COPY --from=deps --chown=node:node /app/backend/node_modules ./backend/node_modules
COPY --from=deps --chown=node:node /app/frontend/node_modules ./frontend/node_modules
COPY package.json pnpm-workspace.yaml ./

RUN mkdir -p /app/backend/node_modules/@opencode-manager && \
ln -s /app/shared /app/backend/node_modules/@opencode-manager/shared
ln -sfn /app/shared /app/backend/node_modules/@opencode-manager/shared

COPY scripts/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
Expand Down
5 changes: 4 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
"start": "bun src/index.ts",
"build": "bun build src/index.ts --outdir=dist --target=bun",
"typecheck": "tsc --noEmit",
"test": "vitest",
"test": "bun test src/",
"test:vitest": "vitest",
"test:all": "bun test src/ && vitest test/",
"test:ui": "vitest --ui",
"test:watch": "vitest --watch",
"lint": "eslint . --ext .ts",
Expand Down Expand Up @@ -37,6 +39,7 @@
"@types/web-push": "^3.6.4",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"better-sqlite3": "^12.9.0",
"eslint": "^9.39.1",
"typescript-eslint": "^8.45.0",
"vitest": "^3.2.4"
Expand Down
15 changes: 15 additions & 0 deletions backend/src/db/migrations/012-opencode-model-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Migration } from '../migration-runner'
import { ensureOpenCodeModelStateTable } from '../model-state'

const migration: Migration = {
version: 12,
name: 'opencode-model-state',
up(db) {
ensureOpenCodeModelStateTable(db)
},
down(db) {
db.run('DROP TABLE IF EXISTS opencode_model_state')
},
}

export default migration
2 changes: 2 additions & 0 deletions backend/src/db/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import migration008 from './008-schedule-cron-support'
import migration009 from './009-repo-source-path'
import migration010 from './009-prompt-templates'
import migration011 from './011-repo-last-accessed'
import migration012 from './012-opencode-model-state'

export const allMigrations: Migration[] = [
migration001,
Expand All @@ -23,4 +24,5 @@ export const allMigrations: Migration[] = [
migration009,
migration010,
migration011,
migration012,
]
140 changes: 140 additions & 0 deletions backend/src/db/model-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { Database } from 'bun:sqlite'
import { migrate } from './migration-runner'
import { allMigrations } from './migrations'
import {
getOpenCodeModelState,
addRecentOpenCodeModel,
toggleFavoriteOpenCodeModel,
setOpenCodeVariant,
MAX_RECENT_MODELS,
} from './model-state'

function createTestDb(): Database {
const db = new Database(':memory:')
migrate(db, allMigrations)
return db
}

describe('model-state', () => {
let db: Database

beforeEach(() => {
db = createTestDb()
})

describe('getOpenCodeModelState', () => {
it('returns empty defaults when no row exists', () => {
const state = getOpenCodeModelState(db)
expect(state).toEqual({ recent: [], favorite: [], variant: {} })
})

it('creates the model state table when an existing database is missing it', () => {
db.run('DROP TABLE opencode_model_state')

const state = getOpenCodeModelState(db)
const table = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'opencode_model_state'").get()

expect(state).toEqual({ recent: [], favorite: [], variant: {} })
expect(table).toBeTruthy()
})

it('returns defaults with explicit userId when no row exists', () => {
const state = getOpenCodeModelState(db, 'user123')
expect(state).toEqual({ recent: [], favorite: [], variant: {} })
})
})

describe('addRecentOpenCodeModel', () => {
it('inserts new state and returns the model in recent[0]', () => {
const model = { providerID: 'anthropic', modelID: 'claude-sonnet-4-20250514' }
const state = addRecentOpenCodeModel(db, model)
expect(state.recent).toHaveLength(1)
expect(state.recent[0]).toEqual(model)
})

it('deduplicates re-selections (same model added twice → length 1, model at index 0)', () => {
const model = { providerID: 'openai', modelID: 'gpt-4o' }
addRecentOpenCodeModel(db, model)
const state = addRecentOpenCodeModel(db, model)
expect(state.recent).toHaveLength(1)
expect(state.recent[0]).toEqual(model)
})

it('caps at MAX_RECENT_MODELS (insert 12 distinct, expect 10)', () => {
for (let i = 0; i < 12; i++) {
addRecentOpenCodeModel(db, { providerID: `provider-${i}`, modelID: `model-${i}` })
}
const state = getOpenCodeModelState(db)
expect(state.recent).toHaveLength(MAX_RECENT_MODELS)
expect(state.recent[0]).toEqual({ providerID: 'provider-11', modelID: 'model-11' })
})
})

describe('toggleFavoriteOpenCodeModel', () => {
it('adds when missing', () => {
const model = { providerID: 'anthropic', modelID: 'claude' }
const state = toggleFavoriteOpenCodeModel(db, model)
expect(state.favorite).toHaveLength(1)
expect(state.favorite[0]).toEqual(model)
})

it('removes when present', () => {
const model = { providerID: 'openai', modelID: 'gpt-4' }
toggleFavoriteOpenCodeModel(db, model)
const state = toggleFavoriteOpenCodeModel(db, model)
expect(state.favorite).toHaveLength(0)
})
})

describe('setOpenCodeVariant', () => {
it('adds variant entry', () => {
const state = setOpenCodeVariant(db, 'key1', 'variant1')
expect(state.variant.key1).toBe('variant1')
})

it('updates variant entry', () => {
setOpenCodeVariant(db, 'key1', 'variant1')
const state = setOpenCodeVariant(db, 'key1', 'variant2')
expect(state.variant.key1).toBe('variant2')
})

it('deletes variant when undefined', () => {
setOpenCodeVariant(db, 'key1', 'variant1')
const state = setOpenCodeVariant(db, 'key1', undefined)
expect(state.variant.key1).toBeUndefined()
})
})

it('corrupt JSON in recent column → getOpenCodeModelState returns [] for recent, preserves valid favorite', () => {
const now = Date.now()
db.prepare(`
INSERT INTO opencode_model_state(user_id, recent, favorite, variant, updated_at)
VALUES(?,?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET recent=excluded.recent, favorite=excluded.favorite, variant=excluded.variant, updated_at=excluded.updated_at
`).run('default', '{ invalid json }', JSON.stringify([{ providerID: 'test', modelID: 'test' }]), '{}', now)

const state = getOpenCodeModelState(db)
expect(state.recent).toEqual([])
expect(state.favorite).toHaveLength(1)
expect(state.favorite[0]).toEqual({ providerID: 'test', modelID: 'test' })
})

it('50 concurrent addRecentOpenCodeModel calls → final recent.length <= MAX_RECENT_MODELS, no exceptions, all entries unique', async () => {
const db = createTestDb()
const numOps = 50

const operations = Array.from({ length: numOps }, (_, i) =>
addRecentOpenCodeModel(db, { providerID: `provider-${i}`, modelID: `model-${i}` }),
)

await Promise.all(operations)
const finalState = getOpenCodeModelState(db)

expect(finalState.recent.length).toBeLessThanOrEqual(MAX_RECENT_MODELS)
expect(finalState.recent.length).toBeGreaterThan(0)

const uniqueKeys = new Set(finalState.recent.map((m) => `${m.providerID}/${m.modelID}`))
expect(uniqueKeys.size).toBe(finalState.recent.length)
})
})
141 changes: 141 additions & 0 deletions backend/src/db/model-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { Database } from 'bun:sqlite'
import { logger } from '../utils/logger'

export interface ModelSelectionRecord {
providerID: string
modelID: string
}

export interface OpenCodeModelStateRecord {
recent: ModelSelectionRecord[]
favorite: ModelSelectionRecord[]
variant: Record<string, string | undefined>
}

export const MAX_RECENT_MODELS = 10

const EMPTY_STATE: OpenCodeModelStateRecord = { recent: [], favorite: [], variant: {} }

export function ensureOpenCodeModelStateTable(db: Database): void {
db.run(`
CREATE TABLE IF NOT EXISTS opencode_model_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL DEFAULT 'default',
recent TEXT NOT NULL DEFAULT '[]',
favorite TEXT NOT NULL DEFAULT '[]',
variant TEXT NOT NULL DEFAULT '{}',
updated_at INTEGER NOT NULL,
UNIQUE(user_id)
)
`)
db.run('CREATE INDEX IF NOT EXISTS idx_opencode_model_state_user ON opencode_model_state(user_id)')
}

function parseJsonSafe<T>(json: string, fallback: T): T {
try {
return JSON.parse(json) as T
} catch (error) {
logger.warn(`Failed to parse JSON: ${error instanceof Error ? error.message : String(error)}`)
return fallback
}
}

export function getOpenCodeModelState(db: Database, userId = 'default'): OpenCodeModelStateRecord {
ensureOpenCodeModelStateTable(db)

const row = db.prepare('SELECT recent, favorite, variant FROM opencode_model_state WHERE user_id = ?').get(userId) as
| { recent: string; favorite: string; variant: string }
| undefined

if (!row) {
return EMPTY_STATE
}

const recent = parseJsonSafe<ModelSelectionRecord[]>(row.recent, [])
const favorite = parseJsonSafe<ModelSelectionRecord[]>(row.favorite, [])
const variant = parseJsonSafe<Record<string, string | undefined>>(row.variant, {})

return { recent, favorite, variant }
}

export function addRecentOpenCodeModel(
db: Database,
model: ModelSelectionRecord,
userId = 'default',
): OpenCodeModelStateRecord {
const insertMany = db.transaction(() => {
const current = getOpenCodeModelState(db, userId)
const deduped = [model, ...current.recent.filter(m => m.providerID !== model.providerID || m.modelID !== model.modelID)]
const sliced = deduped.slice(0, MAX_RECENT_MODELS)
const now = Date.now()

db.prepare(`
INSERT INTO opencode_model_state(user_id, recent, favorite, variant, updated_at)
VALUES(?,?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET recent=excluded.recent, updated_at=excluded.updated_at
`).run(userId, JSON.stringify(sliced), JSON.stringify(current.favorite), JSON.stringify(current.variant), now)

return { recent: sliced, favorite: current.favorite, variant: current.variant }
})

return insertMany()
}

export function toggleFavoriteOpenCodeModel(
db: Database,
model: ModelSelectionRecord,
userId = 'default',
): OpenCodeModelStateRecord {
const toggle = db.transaction(() => {
const current = getOpenCodeModelState(db, userId)
const exists = current.favorite.some(
m => m.providerID === model.providerID && m.modelID === model.modelID,
)

const updated = exists
? current.favorite.filter(m => m.providerID !== model.providerID || m.modelID !== model.modelID)
: [...current.favorite, model]

const now = Date.now()

db.prepare(`
INSERT INTO opencode_model_state(user_id, recent, favorite, variant, updated_at)
VALUES(?,?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET favorite=excluded.favorite, updated_at=excluded.updated_at
`).run(userId, JSON.stringify(current.recent), JSON.stringify(updated), JSON.stringify(current.variant), now)

return { recent: current.recent, favorite: updated, variant: current.variant }
})

return toggle()
}

export function setOpenCodeVariant(
db: Database,
key: string,
variant: string | undefined,
userId = 'default',
): OpenCodeModelStateRecord {
const setVariant = db.transaction(() => {
const current = getOpenCodeModelState(db, userId)
const updated = { ...current.variant }

if (variant === undefined) {
delete updated[key]
} else {
updated[key] = variant
}

const now = Date.now()

db.prepare(`
INSERT INTO opencode_model_state(user_id, recent, favorite, variant, updated_at)
VALUES(?,?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET variant=excluded.variant, updated_at=excluded.updated_at
`).run(userId, JSON.stringify(current.recent), JSON.stringify(current.favorite), JSON.stringify(updated), now)

return { recent: current.recent, favorite: current.favorite, variant: updated }
})

return setVariant()
}
2 changes: 2 additions & 0 deletions backend/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import { mkdirSync } from 'fs'
import { dirname } from 'path'
import { migrate } from './migration-runner'
import { allMigrations } from './migrations'
import { ensureOpenCodeModelStateTable } from './model-state'

export function initializeDatabase(dbPath: string = './data/opencode.db'): Database {
mkdirSync(dirname(dbPath), { recursive: true })
const db = new Database(dbPath)

migrate(db, allMigrations)
ensureOpenCodeModelStateTable(db)

db.prepare('INSERT OR IGNORE INTO user_preferences (user_id, preferences, updated_at) VALUES (?, ?, ?)')
.run('default', '{}', Date.now())
Expand Down
Loading
Loading