Skip to content

Commit 295d24c

Browse files
refactor(config): make on-disk opencode.json the single source of truth (#354)
* refactor(config): make on-disk opencode.json the single source of truth Make <workspace>/.config/opencode/opencode.json the only store for the active OpenCode configuration. One owner (opencode-config-file.ts) reads, validates, writes, archives, and seeds the file; one owner (opencode-config-apply.ts) decides how an edit is applied: restart pending, or a live patch with recovery. Settings routes, the internal assistant API, the health-watch supervisor, boot, and host import all go through them, so an edit that lands on the file without going through the database is visible immediately instead of at the next Manager boot. Remove the named-config profiles. Migration 019 archives every opencode_configs row to .config/opencode-configs-archive/<name>.json, restores the default row to opencode.json when no file exists, then drops the table, its indexes, and repos.opencode_config_name. The /opencode-configs* and config/switch routes and the CreateConfigDialog/SwitchConfigDialog dialogs are gone, and the ocm tool gains GET/PUT /opencode-config backed by the same apply owner. Harden the write path: serialize and atomically write config updates while preserving the file mode, keep withFileLock usable after a rejection, route reloadConfig's cleaned-config write through the file owner, stop boot from rewriting an existing valid config when importing state, prune the health-watch directory to its newest 20 entries, resolve archive paths with realpath, and share one frontend config query across dialogs. * fix(mirror): canonicalize worktree paths before branch ownership check * refactor(config): unify opencode config apply and rollback paths - share one config route factory between public and internal mounts - capture last-known-good before every config write and restore via one helper - stop auto-restarting on save; flag restart-required fields and live-patch the rest - add CONFIG_PATCH_TIMEOUT_MS for config patch requests - extract canonicalPath helpers, health-watch artifact writer, and useProvidersWithModels hook * refactor(model-state): use OpenCode state file as model state source of truth
1 parent 7a3c522 commit 295d24c

116 files changed

Lines changed: 10190 additions & 4107 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎backend/package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
"build": "bun build src/index.ts --outdir=dist --target=bun",
1010
"typecheck": "tsc --noEmit",
1111
"test": "pnpm run test:bun && pnpm run test:vitest",
12-
"test:bun": "bun test test/services/assistant-mode.test.ts test/services/internal-token.test.ts test/auth/internal-token-middleware.test.ts test/routes/internal-schedules.test.ts test/routes/internal-notifications.test.ts test/routes/internal-settings.test.ts test/routes/internal-repos.test.ts test/routes/internal-assistant.test.ts test/routes/internal-sandbox.test.ts src/db/model-state.test.ts src/routes/providers.test.ts src/routes/repos.test.ts src/routes/session-pins.test.ts",
13-
"test:vitest": "vitest run",
12+
"test:bun": "bun test test/routes/internal-sandbox.test.ts src/routes/repos.test.ts",
13+
"test:vitest": "vitest run --coverage",
1414
"test:ui": "vitest --ui",
1515
"test:watch": "vitest --watch",
1616
"lint": "eslint . --ext .ts",

‎backend/src/db/migrations/012-opencode-model-state.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
import type { Migration } from '../migration-runner'
2-
import { ensureOpenCodeModelStateTable } from '../model-state'
32

43
const migration: Migration = {
54
version: 12,
65
name: 'opencode-model-state',
76
up(db) {
8-
ensureOpenCodeModelStateTable(db)
7+
db.run(`
8+
CREATE TABLE IF NOT EXISTS opencode_model_state (
9+
id INTEGER PRIMARY KEY AUTOINCREMENT,
10+
user_id TEXT NOT NULL DEFAULT 'default',
11+
recent TEXT NOT NULL DEFAULT '[]',
12+
favorite TEXT NOT NULL DEFAULT '[]',
13+
variant TEXT NOT NULL DEFAULT '{}',
14+
updated_at INTEGER NOT NULL,
15+
UNIQUE(user_id)
16+
)
17+
`)
18+
db.run('CREATE INDEX IF NOT EXISTS idx_opencode_model_state_user ON opencode_model_state(user_id)')
919
},
1020
down(db) {
1121
db.run('DROP TABLE IF EXISTS opencode_model_state')
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
2+
import { randomBytes } from 'node:crypto'
3+
import os from 'node:os'
4+
import path from 'node:path'
5+
import { getOpenCodeConfigFilePath, getOpenCodeConfigHome } from '@opencode-manager/shared/config/env'
6+
import type { Migration } from '../migration-runner'
7+
import { logger } from '../../utils/logger'
8+
9+
function firstExistingConfigSourcePath(): string | null {
10+
const candidates = [
11+
process.env.OPENCODE_IMPORT_CONFIG_PATH,
12+
path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
13+
]
14+
.filter((value): value is string => Boolean(value))
15+
.map((value) => path.resolve(value))
16+
17+
return Array.from(new Set(candidates)).find((candidate) => existsSync(candidate)) ?? null
18+
}
19+
20+
function writeArchivedConfig(archiveDir: string, configName: string, content: string): void {
21+
const base = configName.replace(/[^A-Za-z0-9._-]/g, '_')
22+
try {
23+
writeFileSync(path.join(archiveDir, `${base}.json`), content, { flag: 'wx' })
24+
return
25+
} catch (error) {
26+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
27+
throw error
28+
}
29+
}
30+
31+
const suffix = `${Date.now()}-${randomBytes(4).toString('hex')}`
32+
writeFileSync(path.join(archiveDir, `${base}-${suffix}.json`), content, { flag: 'wx' })
33+
}
34+
35+
function archiveConfigs(rows: Array<{ config_name: string; config_content: string }>): void {
36+
const archiveDir = path.join(getOpenCodeConfigHome(), 'opencode-configs-archive')
37+
mkdirSync(archiveDir, { recursive: true })
38+
for (const row of rows) {
39+
try {
40+
writeArchivedConfig(archiveDir, row.config_name, row.config_content)
41+
} catch (error) {
42+
logger.warn('Failed to archive an opencode config before dropping the table', error)
43+
}
44+
}
45+
}
46+
47+
const migration: Migration = {
48+
version: 19,
49+
name: 'drop-opencode-configs',
50+
51+
up(db) {
52+
const rows = db.prepare('SELECT config_name, config_content, is_default FROM opencode_configs').all() as Array<{ config_name: string; config_content: string; is_default: number | null }>
53+
54+
try {
55+
archiveConfigs(rows)
56+
} catch (error) {
57+
logger.warn('Failed to archive opencode configs before dropping the table', error)
58+
}
59+
60+
const defaultRow = rows.find(row => row.is_default)
61+
if (defaultRow) {
62+
try {
63+
const configFilePath = getOpenCodeConfigFilePath()
64+
if (!existsSync(configFilePath) && !firstExistingConfigSourcePath()) {
65+
mkdirSync(path.dirname(configFilePath), { recursive: true })
66+
writeFileSync(configFilePath, defaultRow.config_content)
67+
}
68+
} catch (error) {
69+
logger.warn('Failed to restore the default opencode config file', error)
70+
}
71+
}
72+
73+
db.run('DROP INDEX IF EXISTS idx_opencode_default')
74+
db.run('DROP INDEX IF EXISTS idx_opencode_user_id')
75+
db.run('DROP TABLE IF EXISTS opencode_configs')
76+
db.run('ALTER TABLE repos DROP COLUMN opencode_config_name')
77+
},
78+
79+
down(db) {
80+
db.run(`
81+
CREATE TABLE IF NOT EXISTS opencode_configs (
82+
id INTEGER PRIMARY KEY AUTOINCREMENT,
83+
user_id TEXT NOT NULL DEFAULT 'default',
84+
config_name TEXT NOT NULL,
85+
config_content TEXT NOT NULL,
86+
is_default BOOLEAN DEFAULT FALSE,
87+
created_at INTEGER NOT NULL,
88+
updated_at INTEGER NOT NULL,
89+
UNIQUE(user_id, config_name)
90+
)
91+
`)
92+
db.run('CREATE INDEX IF NOT EXISTS idx_opencode_user_id ON opencode_configs(user_id)')
93+
db.run('CREATE INDEX IF NOT EXISTS idx_opencode_default ON opencode_configs(user_id, is_default)')
94+
db.run('ALTER TABLE repos ADD COLUMN opencode_config_name TEXT')
95+
},
96+
}
97+
98+
export default migration
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
2+
import path from 'node:path'
3+
import { getOpenCodeModelStatePath } from '@opencode-manager/shared/config/env'
4+
import type { Migration } from '../migration-runner'
5+
import { logger } from '../../utils/logger'
6+
7+
interface ModelStateRow {
8+
recent: string
9+
favorite: string
10+
variant: string
11+
updated_at: number
12+
}
13+
14+
function readDefaultRow(db: Parameters<Migration['up']>[0]): ModelStateRow | undefined {
15+
try {
16+
return db.prepare("SELECT recent, favorite, variant, updated_at FROM opencode_model_state WHERE user_id = 'default'").get() as ModelStateRow | undefined
17+
} catch {
18+
return undefined
19+
}
20+
}
21+
22+
function parseJson<T>(value: string, fallback: T): T {
23+
try {
24+
return JSON.parse(value) as T
25+
} catch {
26+
return fallback
27+
}
28+
}
29+
30+
function restoreModelStateFile(row: ModelStateRow): void {
31+
const filePath = getOpenCodeModelStatePath()
32+
const fileExists = existsSync(filePath)
33+
34+
if (fileExists && statSync(filePath).mtimeMs >= row.updated_at) {
35+
return
36+
}
37+
38+
const existing = fileExists
39+
? parseJson<Record<string, unknown>>(readFileSync(filePath, 'utf8'), {})
40+
: {}
41+
42+
mkdirSync(path.dirname(filePath), { recursive: true })
43+
writeFileSync(filePath, JSON.stringify({
44+
...existing,
45+
recent: parseJson<unknown[]>(row.recent, []),
46+
favorite: parseJson<unknown[]>(row.favorite, []),
47+
variant: parseJson<Record<string, unknown>>(row.variant, {}),
48+
}, null, 2))
49+
}
50+
51+
const migration: Migration = {
52+
version: 20,
53+
name: 'drop-opencode-model-state',
54+
55+
up(db) {
56+
const row = readDefaultRow(db)
57+
if (row) {
58+
try {
59+
restoreModelStateFile(row)
60+
} catch (error) {
61+
logger.warn('Failed to restore the OpenCode model state file', error)
62+
}
63+
}
64+
65+
db.run('DROP INDEX IF EXISTS idx_opencode_model_state_user')
66+
db.run('DROP TABLE IF EXISTS opencode_model_state')
67+
},
68+
69+
down(db) {
70+
db.run(`
71+
CREATE TABLE IF NOT EXISTS opencode_model_state (
72+
id INTEGER PRIMARY KEY AUTOINCREMENT,
73+
user_id TEXT NOT NULL DEFAULT 'default',
74+
recent TEXT NOT NULL DEFAULT '[]',
75+
favorite TEXT NOT NULL DEFAULT '[]',
76+
variant TEXT NOT NULL DEFAULT '{}',
77+
updated_at INTEGER NOT NULL,
78+
UNIQUE(user_id)
79+
)
80+
`)
81+
db.run('CREATE INDEX IF NOT EXISTS idx_opencode_model_state_user ON opencode_model_state(user_id)')
82+
},
83+
}
84+
85+
export default migration

‎backend/src/db/migrations/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import migration015 from './015-schedule-worktree-isolation'
1717
import migration016 from './016-schedule-permission-config'
1818
import migration017 from './017-schedule-run-workspace-id'
1919
import migration018 from './018-session-pins'
20+
import migration019 from './019-drop-opencode-configs'
21+
import migration020 from './020-drop-opencode-model-state'
2022

2123
export const allMigrations: Migration[] = [
2224
migration001,
@@ -37,4 +39,6 @@ export const allMigrations: Migration[] = [
3739
migration016,
3840
migration017,
3941
migration018,
42+
migration019,
43+
migration020,
4044
]

‎backend/src/db/model-state.test.ts‎

Lines changed: 0 additions & 140 deletions
This file was deleted.

0 commit comments

Comments
 (0)