Skip to content

Commit 8133b65

Browse files
refactor: persist assistant repo in database with ID migration (#271)
1 parent 0d384d6 commit 8133b65

12 files changed

Lines changed: 188 additions & 112 deletions

File tree

backend/src/db/queries.ts

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Database } from 'bun:sqlite'
22
import type { Repo, CreateRepoInput } from '../types/repo'
33
import { getReposPath } from '@opencode-manager/shared/config/env'
4+
import { ASSISTANT_REPO_ID, ASSISTANT_REPO_PATH } from '@opencode-manager/shared/utils'
45
import { getErrorMessage } from '../utils/error-utils'
56
import path from 'path'
67

@@ -41,13 +42,84 @@ function rowToRepo(row: RepoRow): Repo {
4142
}
4243
}
4344

45+
const TABLES_WITH_REPO_ID = ['schedule_jobs', 'schedule_runs', 'repo_settings'] as const
46+
type RepoIdTable = typeof TABLES_WITH_REPO_ID[number]
47+
48+
function updateRepoIdReference(db: Database, tableName: RepoIdTable, fromRepoId: number, toRepoId: number): void {
49+
db.prepare(`UPDATE ${tableName} SET repo_id = ? WHERE repo_id = ?`).run(toRepoId, fromRepoId)
50+
}
51+
4452
export function getRepoById(db: Database, id: number): Repo | null {
4553
const stmt = db.prepare('SELECT * FROM repos WHERE id = ?')
4654
const row = stmt.get(id) as RepoRow | undefined
4755

4856
return row ? rowToRepo(row) : null
4957
}
5058

59+
export function ensureAssistantRepo(db: Database): Repo {
60+
const now = Date.now()
61+
62+
const syncAssistantRepo = db.transaction(() => {
63+
const existingAssistantPathRow = db.prepare('SELECT id FROM repos WHERE local_path = ? AND id != ?')
64+
.get(ASSISTANT_REPO_PATH, ASSISTANT_REPO_ID) as { id: number } | undefined
65+
66+
if (existingAssistantPathRow) {
67+
for (const table of TABLES_WITH_REPO_ID) {
68+
updateRepoIdReference(db, table, existingAssistantPathRow.id, ASSISTANT_REPO_ID)
69+
}
70+
db.prepare('DELETE FROM repos WHERE id = ?').run(existingAssistantPathRow.id)
71+
}
72+
73+
db.prepare(`
74+
INSERT INTO repos (
75+
id,
76+
repo_url,
77+
local_path,
78+
source_path,
79+
branch,
80+
default_branch,
81+
clone_status,
82+
cloned_at,
83+
last_accessed_at,
84+
is_worktree,
85+
is_local
86+
)
87+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
88+
ON CONFLICT(id) DO UPDATE SET
89+
repo_url = excluded.repo_url,
90+
local_path = excluded.local_path,
91+
source_path = excluded.source_path,
92+
branch = excluded.branch,
93+
default_branch = excluded.default_branch,
94+
clone_status = excluded.clone_status,
95+
last_accessed_at = excluded.last_accessed_at,
96+
is_worktree = excluded.is_worktree,
97+
is_local = excluded.is_local
98+
`).run(
99+
ASSISTANT_REPO_ID,
100+
null,
101+
ASSISTANT_REPO_PATH,
102+
null,
103+
null,
104+
'main',
105+
'ready',
106+
now,
107+
now,
108+
0,
109+
0,
110+
)
111+
})
112+
113+
syncAssistantRepo()
114+
115+
const repo = getRepoById(db, ASSISTANT_REPO_ID)
116+
if (!repo) {
117+
throw new Error('Failed to sync Assistant repository')
118+
}
119+
120+
return repo
121+
}
122+
51123
export function createRepo(db: Database, repo: CreateRepoInput): Repo {
52124
const normalizedPath = repo.localPath.trim().replace(/\/+$/, '')
53125

@@ -209,8 +281,9 @@ export function updateRepoBranch(db: Database, id: number, branch: string): void
209281
}
210282

211283
export function deleteRepo(db: Database, id: number): void {
212-
db.prepare('DELETE FROM schedule_runs WHERE repo_id = ?').run(id)
213-
db.prepare('DELETE FROM schedule_jobs WHERE repo_id = ?').run(id)
284+
for (const table of TABLES_WITH_REPO_ID) {
285+
db.prepare(`DELETE FROM ${table} WHERE repo_id = ?`).run(id)
286+
}
214287
const stmt = db.prepare('DELETE FROM repos WHERE id = ?')
215288
stmt.run(id)
216289
}

backend/src/routes/repos.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { OpenCodeClient } from '../services/opencode/client'
1414
import { logger } from '../utils/logger'
1515
import { getErrorMessage, getStatusCode } from '../utils/error-utils'
1616
import { getOpenCodeConfigFilePath } from '@opencode-manager/shared/config/env'
17+
import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils'
1718
import { createRepoGitRoutes } from './repo-git'
1819
import { createScheduleRoutes } from './schedules'
1920
import type { GitAuthService } from '../services/git-auth'
@@ -31,6 +32,10 @@ async function restartOpenCode(openCodeSupervisor?: OpenCodeSupervisor): Promise
3132
await opencodeServerManager.restart()
3233
}
3334

35+
function resolveRepo(database: Database, id: number): Repo | null {
36+
return getRepoById(database, id) ?? (id === ASSISTANT_REPO_ID ? buildAssistantRepo() : null)
37+
}
38+
3439
export function createRepoRoutes(
3540
database: Database,
3641
gitAuthService: GitAuthService,
@@ -122,7 +127,7 @@ app.get('/', async (c) => {
122127
const reposWithCurrentBranch = await Promise.all(
123128
repos.map(async (repo) => {
124129
const env = gitAuthService.getGitEnvironment()
125-
const currentBranch = await repoService.getCurrentBranch(repo, env)
130+
const currentBranch = repo.id === ASSISTANT_REPO_ID ? undefined : await repoService.getCurrentBranch(repo, env)
126131
return { ...repo, currentBranch }
127132
})
128133
)
@@ -157,14 +162,13 @@ app.get('/', async (c) => {
157162
try {
158163
const id = parseInt(c.req.param('id'))
159164

160-
const isAssistant = id === 0
161-
const repo: Repo | null = isAssistant ? buildAssistantRepo() : getRepoById(database, id)
165+
const repo: Repo | null = resolveRepo(database, id)
162166

163167
if (!repo) {
164168
return c.json({ error: 'Repo not found' }, 404)
165169
}
166170

167-
const currentBranch = isAssistant ? undefined : await repoService.getCurrentBranch(repo, gitAuthService.getGitEnvironment())
171+
const currentBranch = id === ASSISTANT_REPO_ID ? undefined : await repoService.getCurrentBranch(repo, gitAuthService.getGitEnvironment())
168172

169173
return c.json({ ...repo, currentBranch })
170174
} catch (error: unknown) {
@@ -267,6 +271,11 @@ app.get('/', async (c) => {
267271
app.delete('/:id', async (c) => {
268272
try {
269273
const id = parseInt(c.req.param('id'))
274+
275+
if (id === ASSISTANT_REPO_ID) {
276+
return c.json({ error: 'Cannot delete the assistant repository' }, 403)
277+
}
278+
270279
const repo = getRepoById(database, id)
271280

272281
if (!repo) {
@@ -480,7 +489,7 @@ app.get('/', async (c) => {
480489
try {
481490
const id = parseInt(c.req.param('id'))
482491

483-
const repo: Repo | null = id === 0 ? buildAssistantRepo() : getRepoById(database, id)
492+
const repo: Repo | null = resolveRepo(database, id)
484493

485494
if (!repo) {
486495
return c.json({ error: 'Repo not found' }, 404)
@@ -498,7 +507,7 @@ app.get('/', async (c) => {
498507
try {
499508
const id = parseInt(c.req.param('id'))
500509

501-
const repo: Repo | null = id === 0 ? buildAssistantRepo() : getRepoById(database, id)
510+
const repo: Repo | null = resolveRepo(database, id)
502511

503512
if (!repo) {
504513
return c.json({ error: 'Repo not found' }, 404)

backend/src/services/assistant-mode.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { ASSISTANT_REPO_ID, ASSISTANT_REPO_PATH } from '@opencode-manager/shared
1717
import { getReposPath, ENV } from '@opencode-manager/shared/config/env'
1818
import type { Database } from 'bun:sqlite'
1919
import { getOrCreateInternalToken } from './internal-token'
20+
import { ensureAssistantRepo } from '../db/queries'
2021

2122

2223
const ASSISTANT_MODE_DIR = ASSISTANT_REPO_PATH
@@ -1081,7 +1082,9 @@ export async function installAssistantWorkspace(deps: {
10811082
db: Database
10821083
apiBaseUrl: string
10831084
}): Promise<AssistantModeStatus> {
1084-
return ensureAssistantMode(buildAssistantRepo(), {
1085+
const assistantRepo = ensureAssistantRepo(deps.db)
1086+
1087+
return ensureAssistantMode(assistantRepo, {
10851088
db: deps.db,
10861089
apiBaseUrl: deps.apiBaseUrl,
10871090
})

backend/src/services/schedules.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1080,6 +1080,8 @@ export class ScheduleService {
10801080

10811081
private assertRepo(repoId: number) {
10821082
if (repoId === ASSISTANT_REPO_ID) {
1083+
const repo = getRepoById(this.db, ASSISTANT_REPO_ID)
1084+
if (repo) return repo
10831085
return { ...buildAssistantRepo(), lastAccessedAt: Date.now(), isLocal: true, currentBranch: undefined }
10841086
}
10851087
const repo = getRepoById(this.db, repoId)

backend/test/db/queries.test.ts

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -259,27 +259,25 @@ describe('Database Queries', () => {
259259

260260
describe('deleteRepo', () => {
261261
it('should delete repo schedules before deleting repo by ID', () => {
262-
const deleteRunsStmt = {
263-
run: vi.fn().mockReturnValue({ changes: 2 })
264-
}
265-
const deleteJobsStmt = {
266-
run: vi.fn().mockReturnValue({ changes: 1 })
267-
}
268-
const deleteRepoStmt = {
269-
run: vi.fn().mockReturnValue({ changes: 1 })
270-
}
262+
const deleteRunsStmt = { run: vi.fn().mockReturnValue({ changes: 2 }) }
263+
const deleteJobsStmt = { run: vi.fn().mockReturnValue({ changes: 1 }) }
264+
const deleteSettingsStmt = { run: vi.fn().mockReturnValue({ changes: 0 }) }
265+
const deleteRepoStmt = { run: vi.fn().mockReturnValue({ changes: 1 }) }
271266
mockDb.prepare
272267
.mockReturnValueOnce(deleteRunsStmt)
273268
.mockReturnValueOnce(deleteJobsStmt)
269+
.mockReturnValueOnce(deleteSettingsStmt)
274270
.mockReturnValueOnce(deleteRepoStmt)
275271

276272
db.deleteRepo(mockDb, 1)
277273

278-
expect(mockDb.prepare).toHaveBeenNthCalledWith(1, 'DELETE FROM schedule_runs WHERE repo_id = ?')
279-
expect(deleteRunsStmt.run).toHaveBeenCalledWith(1)
280-
expect(mockDb.prepare).toHaveBeenNthCalledWith(2, 'DELETE FROM schedule_jobs WHERE repo_id = ?')
274+
expect(mockDb.prepare).toHaveBeenNthCalledWith(1, 'DELETE FROM schedule_jobs WHERE repo_id = ?')
281275
expect(deleteJobsStmt.run).toHaveBeenCalledWith(1)
282-
expect(mockDb.prepare).toHaveBeenNthCalledWith(3,
276+
expect(mockDb.prepare).toHaveBeenNthCalledWith(2, 'DELETE FROM schedule_runs WHERE repo_id = ?')
277+
expect(deleteRunsStmt.run).toHaveBeenCalledWith(1)
278+
expect(mockDb.prepare).toHaveBeenNthCalledWith(3, 'DELETE FROM repo_settings WHERE repo_id = ?')
279+
expect(deleteSettingsStmt.run).toHaveBeenCalledWith(1)
280+
expect(mockDb.prepare).toHaveBeenNthCalledWith(4,
283281
'DELETE FROM repos WHERE id = ?'
284282
)
285283
expect(deleteRepoStmt.run).toHaveBeenCalledWith(1)

backend/test/services/assistant-mode.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ScheduleService } from '../../src/services/schedules'
99
import { NotificationService } from '../../src/services/notification'
1010
import { SettingsService } from '../../src/services/settings'
1111
import { createOpenCodeClient } from '../../src/services/opencode/client'
12+
import { getRepoById } from '../../src/db/queries'
1213
import { ENV } from '@opencode-manager/shared/config/env'
1314

1415
describe('buildSchedulesSkill', () => {
@@ -634,6 +635,61 @@ describe('installAssistantWorkspace', () => {
634635
expect(result.files.opencodeJson?.exists).toBe(true)
635636
expect(result.files.agentsMd?.exists).toBe(true)
636637
expect(result.defaultAgent?.exists).toBe(true)
638+
expect(result.repoId).toBe(0)
639+
640+
const assistantRepo = getRepoById(db, 0)
641+
expect(assistantRepo?.id).toBe(0)
642+
expect(assistantRepo?.localPath).toBe('assistant')
643+
expect(assistantRepo?.fullPath).toBe(ws.assistantDir)
644+
expect(assistantRepo?.cloneStatus).toBe('ready')
645+
expect(assistantRepo?.defaultBranch).toBe('main')
646+
})
647+
648+
it('repairs an assistant row created with a non-zero id', async () => {
649+
db.prepare(`
650+
INSERT INTO repos (
651+
id,
652+
repo_url,
653+
local_path,
654+
source_path,
655+
branch,
656+
default_branch,
657+
clone_status,
658+
cloned_at,
659+
last_accessed_at,
660+
is_worktree,
661+
is_local
662+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
663+
`).run(99, null, 'assistant', null, null, 'main', 'ready', Date.now(), Date.now(), 0, 0)
664+
db.prepare(`
665+
INSERT INTO schedule_jobs (
666+
repo_id,
667+
name,
668+
description,
669+
enabled,
670+
interval_minutes,
671+
schedule_mode,
672+
cron_expression,
673+
timezone,
674+
agent_slug,
675+
prompt,
676+
model,
677+
skill_metadata,
678+
created_at,
679+
updated_at,
680+
last_run_at,
681+
next_run_at
682+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
683+
`).run(99, 'Assistant job', null, 1, 60, 'interval', null, null, null, 'hello', null, null, Date.now(), Date.now(), null, null)
684+
685+
await installAssistantWorkspace({ db, apiBaseUrl })
686+
687+
expect(getRepoById(db, 99)).toBeNull()
688+
const assistantRepo = getRepoById(db, 0)
689+
expect(assistantRepo?.localPath).toBe('assistant')
690+
691+
const migratedJob = db.prepare('SELECT repo_id FROM schedule_jobs WHERE name = ?').get('Assistant job') as { repo_id: number }
692+
expect(migratedJob.repo_id).toBe(0)
637693
})
638694

639695
it('is idempotent — second call does not recreate files and content is unchanged', async () => {

frontend/src/components/navigation/RepoQuickSwitchSheet.tsx

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import { cn, getRepoDisplayName } from '@/lib/utils'
88
import { listRepos } from '@/api/repos'
99
import { AddRepoDialog } from '@/components/repo/AddRepoDialog'
1010
import { FolderGit2, Check, Plus, House } from 'lucide-react'
11-
import { isAssistantPath, getAssistantPath } from '@/lib/navigation'
1211
import { useUrlParams } from '@/hooks/useUrlParams'
12+
import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils'
1313

1414
interface RepoQuickSwitchSheetProps {
1515
isOpen: boolean
@@ -23,29 +23,31 @@ export function RepoQuickSwitchSheet({ isOpen, onClose }: RepoQuickSwitchSheetPr
2323
const [searchQuery, setSearchQuery] = useState('')
2424
const [addRepoOpen, setAddRepoOpen] = useState(false)
2525

26-
const isAssistantRoute = useMemo(() => isAssistantPath(location.pathname), [location.pathname])
27-
2826
const activeRepoId = useMemo(() => {
29-
if (isAssistantRoute) return null
3027
const match = location.pathname.match(/^\/repos\/(\d+)/)
3128
return match ? Number(match[1]) : null
32-
}, [location.pathname, isAssistantRoute])
29+
}, [location.pathname])
3330

3431
const { data: repos, isLoading } = useQuery({
3532
queryKey: ['repos'],
3633
queryFn: listRepos,
3734
enabled: isOpen,
3835
})
3936

37+
const regularRepos = useMemo(
38+
() => repos?.filter((r) => r.id !== ASSISTANT_REPO_ID) ?? null,
39+
[repos],
40+
)
41+
4042
const filteredRepos = useMemo(() => {
41-
if (!repos) return []
42-
const sorted = [...repos].sort((a, b) => (b.lastAccessedAt ?? 0) - (a.lastAccessedAt ?? 0))
43+
if (!regularRepos) return []
44+
const sorted = [...regularRepos].sort((a, b) => (b.lastAccessedAt ?? 0) - (a.lastAccessedAt ?? 0))
4345
if (!searchQuery.trim()) return sorted
4446
const query = searchQuery.toLowerCase()
4547
return sorted.filter((repo) =>
4648
getRepoDisplayName(repo.repoUrl, repo.localPath, repo.sourcePath).toLowerCase().includes(query)
4749
)
48-
}, [repos, searchQuery])
50+
}, [regularRepos, searchQuery])
4951

5052
const isUrlControlledSheet = searchParams.get('mobileTab') === 'repos'
5153

@@ -57,18 +59,6 @@ export function RepoQuickSwitchSheet({ isOpen, onClose }: RepoQuickSwitchSheetPr
5759
}
5860

5961
const handleClick = (id: number) => {
60-
const pendingAction = searchParams.get('mobileTabAction')
61-
62-
if (isAssistantRoute) {
63-
navigateAndClose(`/repos/${id}`, { replace: true })
64-
return
65-
}
66-
67-
if (pendingAction === 'assistant') {
68-
navigateAndClose(getAssistantPath())
69-
return
70-
}
71-
7262
if (id === activeRepoId) {
7363
onClose()
7464
return

0 commit comments

Comments
 (0)