Skip to content

Commit 949f3a9

Browse files
refactor: move assistant workspace install before OpenCode server start
1 parent 30c34e8 commit 949f3a9

9 files changed

Lines changed: 178 additions & 64 deletions

File tree

backend/src/index.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ import { createOpenCodeClient } from './services/opencode/client'
4545
import { NotificationService } from './services/notification'
4646
import { ScheduleRunner, ScheduleService } from './services/schedules'
4747
import { migrateGlobalSkills } from './services/skills'
48-
import { warmAssistantWorkspace } from './services/assistant-mode'
48+
import { installAssistantWorkspace } from './services/assistant-mode'
4949
import { getOpenCodeImportStatus, syncOpenCodeImport } from './services/opencode-import'
5050
import { OpenCodeSupervisor } from './services/opencode-supervisor'
5151
import { OpenCodeConfigSchema } from '@opencode-manager/shared/schemas'
@@ -263,6 +263,12 @@ try {
263263

264264
await migrateGlobalSkills()
265265

266+
await installAssistantWorkspace({
267+
db,
268+
apiBaseUrl: `http://localhost:${PORT}/api/internal`,
269+
})
270+
logger.info('Assistant workspace installed')
271+
266272
ipcServer = await createIPCServer(process.env.STORAGE_PATH || undefined)
267273
await gitAuthService.initialize(ipcServer, db)
268274
logger.info(`Git IPC server running at ${ipcServer.ipcHandlePath}`)
@@ -273,11 +279,6 @@ try {
273279
const openCodeStatus = await openCodeSupervisor.start()
274280
if (openCodeStatus.healthy) {
275281
logger.info(`OpenCode server running on port ${openCodeStatus.port}`)
276-
void warmAssistantWorkspace({
277-
db,
278-
apiBaseUrl: `http://localhost:${PORT}/api/internal`,
279-
openCodeClient,
280-
})
281282
} else {
282283
logger.warn(`OpenCode server unavailable after startup recovery: ${openCodeStatus.lastError ?? openCodeStatus.state}`)
283284
}

backend/src/services/assistant-mode.ts

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +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 type { OpenCodeClient } from './opencode/client'
21-
import { logger } from '../utils/logger'
2220

23-
const ASSISTANT_WARMUP_OPENCODE_TIMEOUT_MS = 90000
2421

2522
const ASSISTANT_MODE_DIR = ASSISTANT_REPO_PATH
2623
const ASSISTANT_MODE_RELATIVE_PATH = 'repos/assistant'
@@ -1080,22 +1077,12 @@ export async function getAssistantModeStatus(repo: Repo): Promise<AssistantModeS
10801077
}
10811078
}
10821079

1083-
export async function warmAssistantWorkspace(deps: {
1080+
export async function installAssistantWorkspace(deps: {
10841081
db: Database
10851082
apiBaseUrl: string
1086-
openCodeClient: OpenCodeClient
1087-
}): Promise<void> {
1088-
try {
1089-
const status = await ensureAssistantMode(buildAssistantRepo(), {
1090-
db: deps.db,
1091-
apiBaseUrl: deps.apiBaseUrl,
1092-
})
1093-
await deps.openCodeClient.getJson('/api/session?limit=1&order=desc', {
1094-
directory: status.directory,
1095-
signal: AbortSignal.timeout(ASSISTANT_WARMUP_OPENCODE_TIMEOUT_MS),
1096-
})
1097-
logger.info('Assistant workspace warmed')
1098-
} catch (error) {
1099-
logger.warn('Assistant workspace warmup failed (non-fatal):', error)
1100-
}
1083+
}): Promise<AssistantModeStatus> {
1084+
return ensureAssistantMode(buildAssistantRepo(), {
1085+
db: deps.db,
1086+
apiBaseUrl: deps.apiBaseUrl,
1087+
})
11011088
}

backend/test/routes/repos.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,8 @@ describe('Repo Routes', () => {
163163
const body = await res.json() as typeof mockStatus
164164
expect(body.repoId).toBe(1)
165165
expect(body.relativePath).toBe('repos/assistant')
166+
167+
expect(ensureAssistantMode).not.toHaveBeenCalled()
166168
})
167169
})
168170

@@ -217,6 +219,17 @@ describe('Repo Routes', () => {
217219
})
218220

219221
expect(res.status).toBe(200)
222+
223+
const body = await res.json() as typeof mockStatus
224+
expect(body).toEqual(mockStatus)
225+
226+
expect(ensureAssistantMode).toHaveBeenCalledTimes(1)
227+
expect(ensureAssistantMode).toHaveBeenCalledWith(
228+
expect.objectContaining({ id: 1, localPath: 'repos/test-repo' }),
229+
expect.objectContaining({ db: mockDb, apiBaseUrl: 'http://localhost:5003/api/internal' }),
230+
expect.objectContaining({ overwriteAgentsMd: true }),
231+
)
232+
220233
expect(opencodeServerManager.clearStartupError).not.toHaveBeenCalled()
221234
expect(opencodeServerManager.restart).not.toHaveBeenCalled()
222235
})

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

Lines changed: 40 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@ import { describe, expect, it, beforeEach, afterEach } from 'bun:test'
22
import path from 'path'
33
import { readFile, stat, writeFile } from 'fs/promises'
44
import { Hono } from 'hono'
5-
import { ensureAssistantMode, getAssistantModeStatus, buildSchedulesSkill, buildReposSkill, buildSettingsSkill, buildAssistantDefaultAgentMd, buildAssistantOpenCodeConfig, buildAssistantRepo, warmAssistantWorkspace } from '../../src/services/assistant-mode'
5+
import { ensureAssistantMode, getAssistantModeStatus, buildSchedulesSkill, buildReposSkill, buildSettingsSkill, buildAssistantDefaultAgentMd, buildAssistantOpenCodeConfig, buildAssistantRepo, installAssistantWorkspace } from '../../src/services/assistant-mode'
66
import { createTempAssistantWorkspace, createTestDb, mockRepo } from '../helpers/assistant-workspace'
7-
import type { OpenCodeClient } from '../../src/services/opencode/client'
87
import { createInternalRoutes } from '../../src/routes/internal'
98
import { ScheduleService } from '../../src/services/schedules'
109
import { NotificationService } from '../../src/services/notification'
@@ -609,7 +608,7 @@ describe('buildAssistantRepo', () => {
609608
})
610609
})
611610

612-
describe('warmAssistantWorkspace', () => {
611+
describe('installAssistantWorkspace', () => {
613612
let ws: Awaited<ReturnType<typeof createTempAssistantWorkspace>>
614613
let db: ReturnType<typeof createTestDb>
615614
const apiBaseUrl = 'http://localhost:5003/api/internal'
@@ -620,34 +619,50 @@ describe('warmAssistantWorkspace', () => {
620619
})
621620
afterEach(async () => { await ws.cleanup() })
622621

623-
it('provisions the workspace and triggers a bounded directory-scoped session request', async () => {
624-
const getJsonCalls: Array<{ path: string; directory?: string }> = []
625-
const client = {
626-
getJson: async (requestPath: string, opts?: { directory?: string }) => {
627-
getJsonCalls.push({ path: requestPath, directory: opts?.directory })
628-
return []
629-
},
630-
} as unknown as OpenCodeClient
631-
632-
await warmAssistantWorkspace({ db, apiBaseUrl, openCodeClient: client })
622+
it('provisions the assistant workspace files without contacting OpenCode', async () => {
623+
const result = await installAssistantWorkspace({ db, apiBaseUrl })
633624

634625
const opencodeJson = await readFile(path.join(ws.assistantDir, 'opencode.json'), 'utf8')
635626
expect(JSON.parse(opencodeJson).default_agent).toBe('assistant')
636-
expect(getJsonCalls).toHaveLength(1)
637-
expect(getJsonCalls[0]?.path).toBe('/api/session?limit=1&order=desc')
638-
expect(getJsonCalls[0]?.directory).toBe(ws.assistantDir)
627+
628+
const agentsMd = await readFile(path.join(ws.assistantDir, 'AGENTS.md'), 'utf8')
629+
expect(agentsMd).toContain('Assistant Mode Workspace')
630+
631+
const assistantAgent = await readFile(path.join(ws.assistantDir, '.opencode/agents/assistant.md'), 'utf8')
632+
expect(assistantAgent).toContain('mode: primary')
633+
634+
expect(result.files.opencodeJson?.exists).toBe(true)
635+
expect(result.files.agentsMd?.exists).toBe(true)
636+
expect(result.defaultAgent?.exists).toBe(true)
639637
})
640638

641-
it('does not throw and still provisions the workspace when the session request fails', async () => {
642-
const client = {
643-
getJson: async () => { throw new Error('opencode unavailable') },
644-
} as unknown as OpenCodeClient
639+
it('is idempotent — second call does not recreate files and content is unchanged', async () => {
640+
await installAssistantWorkspace({ db, apiBaseUrl })
641+
642+
const opencodeJsonPath = path.join(ws.assistantDir, 'opencode.json')
643+
const agentsMdPath = path.join(ws.assistantDir, 'AGENTS.md')
644+
const assistantAgentPath = path.join(ws.assistantDir, '.opencode/agents/assistant.md')
645645

646-
await expect(
647-
warmAssistantWorkspace({ db, apiBaseUrl, openCodeClient: client })
648-
).resolves.toBeUndefined()
646+
const firstContent = {
647+
opencodeJson: await readFile(opencodeJsonPath, 'utf8'),
648+
agentsMd: await readFile(agentsMdPath, 'utf8'),
649+
assistantAgent: await readFile(assistantAgentPath, 'utf8'),
650+
}
649651

650-
const opencodeJson = await stat(path.join(ws.assistantDir, 'opencode.json'))
651-
expect(opencodeJson.isFile()).toBe(true)
652+
const result = await installAssistantWorkspace({ db, apiBaseUrl })
653+
654+
const secondContent = {
655+
opencodeJson: await readFile(opencodeJsonPath, 'utf8'),
656+
agentsMd: await readFile(agentsMdPath, 'utf8'),
657+
assistantAgent: await readFile(assistantAgentPath, 'utf8'),
658+
}
659+
660+
expect(secondContent.opencodeJson).toBe(firstContent.opencodeJson)
661+
expect(secondContent.agentsMd).toBe(firstContent.agentsMd)
662+
expect(secondContent.assistantAgent).toBe(firstContent.assistantAgent)
663+
664+
expect(result.files.opencodeJson?.created).toBe(false)
665+
expect(result.files.agentsMd?.created).toBe(false)
666+
expect(result.defaultAgent?.created).toBe(false)
652667
})
653668
})

frontend/src/hooks/useAssistantSessionLauncher.test.tsx

Lines changed: 96 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,18 @@ import { renderHook, act } from '@testing-library/react'
22
import { describe, it, expect, beforeEach, vi } from 'vitest'
33
import { useAssistantSessionLauncher } from './useAssistantSessionLauncher'
44
import { OpenCodeClient } from '@/api/opencode'
5-
import { initializeAssistantMode } from '@/api/repos'
5+
import { getAssistantModeStatus } from '@/api/repos'
66

77
const mocks = vi.hoisted(() => ({
88
listSessions: vi.fn(),
99
listSessionsPage: vi.fn(),
1010
createSession: vi.fn(),
1111
sendPromptAsync: vi.fn(),
12-
initializeAssistantMode: vi.fn(),
12+
getAssistantModeStatus: vi.fn(),
1313
}))
1414

1515
vi.mock('@/api/repos', () => ({
16-
initializeAssistantMode: mocks.initializeAssistantMode,
16+
getAssistantModeStatus: mocks.getAssistantModeStatus,
1717
}))
1818

1919
vi.mock('@/api/opencode', () => ({
@@ -33,7 +33,14 @@ describe('useAssistantSessionLauncher', () => {
3333
beforeEach(() => {
3434
vi.clearAllMocks()
3535
localStorage.clear()
36-
mocks.initializeAssistantMode.mockResolvedValue({ directory: '/assistant' })
36+
mocks.getAssistantModeStatus.mockResolvedValue({
37+
directory: '/assistant',
38+
files: {
39+
agentsMd: { path: '/assistant/AGENTS.md', exists: true, created: true },
40+
opencodeJson: { path: '/assistant/.opencode.json', exists: true, created: true },
41+
},
42+
defaultAgent: { name: 'assistant', path: '/assistant/.opencode/agents/assistant.md', exists: true, created: true },
43+
})
3744
})
3845

3946
it('opens the latest root session in the assistant directory', async () => {
@@ -56,7 +63,7 @@ describe('useAssistantSessionLauncher', () => {
5663
await result.current.openAssistant()
5764
})
5865

59-
expect(initializeAssistantMode).toHaveBeenCalledWith(123)
66+
expect(getAssistantModeStatus).toHaveBeenCalledWith(123)
6067
expect(OpenCodeClient).toHaveBeenCalledWith('http://localhost:5551', '/assistant')
6168
expect(mocks.listSessionsPage).toHaveBeenCalledWith({ limit: 25, order: 'desc' })
6269
expect(mocks.listSessions).not.toHaveBeenCalled()
@@ -97,8 +104,13 @@ describe('useAssistantSessionLauncher', () => {
97104
})
98105

99106
it('notifies an existing assistant session when some generated updates were preserved', async () => {
100-
mocks.initializeAssistantMode.mockResolvedValue({
107+
mocks.getAssistantModeStatus.mockResolvedValue({
101108
directory: '/assistant',
109+
files: {
110+
agentsMd: { path: '/assistant/AGENTS.md', exists: true, created: true },
111+
opencodeJson: { path: '/assistant/.opencode.json', exists: true, created: true },
112+
},
113+
defaultAgent: { name: 'assistant', path: '/assistant/.opencode/agents/assistant.md', exists: true, created: true },
102114
warnings: [
103115
{
104116
code: 'assistant-agents-md-preserved',
@@ -228,4 +240,82 @@ describe('useAssistantSessionLauncher', () => {
228240
expect(onNavigate).toHaveBeenCalledWith('created')
229241
expect(mocks.sendPromptAsync).toHaveBeenCalled()
230242
})
243+
244+
it('rejects with readiness error when agentMd is missing', async () => {
245+
mocks.getAssistantModeStatus.mockResolvedValue({
246+
directory: '/assistant',
247+
files: {
248+
agentsMd: { path: '/assistant/AGENTS.md', exists: false, created: false },
249+
opencodeJson: { path: '/assistant/.opencode.json', exists: true, created: true },
250+
},
251+
defaultAgent: { name: 'assistant', path: '/assistant/.opencode/agents/assistant.md', exists: true, created: true },
252+
})
253+
const onNavigate = vi.fn()
254+
const { result } = renderHook(() => useAssistantSessionLauncher({
255+
repoId: 123,
256+
opcodeUrl: 'http://localhost:5551',
257+
onNavigate,
258+
}))
259+
260+
await act(async () => {
261+
await expect(result.current.openAssistant()).rejects.toThrow('Assistant workspace is not ready')
262+
})
263+
264+
expect(OpenCodeClient).not.toHaveBeenCalled()
265+
expect(mocks.listSessionsPage).not.toHaveBeenCalled()
266+
expect(mocks.createSession).not.toHaveBeenCalled()
267+
expect(onNavigate).not.toHaveBeenCalled()
268+
})
269+
270+
it('rejects with readiness error when opencodeJson is missing', async () => {
271+
mocks.getAssistantModeStatus.mockResolvedValue({
272+
directory: '/assistant',
273+
files: {
274+
agentsMd: { path: '/assistant/AGENTS.md', exists: true, created: true },
275+
opencodeJson: { path: '/assistant/.opencode.json', exists: false, created: false },
276+
},
277+
defaultAgent: { name: 'assistant', path: '/assistant/.opencode/agents/assistant.md', exists: true, created: true },
278+
})
279+
const onNavigate = vi.fn()
280+
const { result } = renderHook(() => useAssistantSessionLauncher({
281+
repoId: 123,
282+
opcodeUrl: 'http://localhost:5551',
283+
onNavigate,
284+
}))
285+
286+
await act(async () => {
287+
await expect(result.current.openAssistant()).rejects.toThrow('Assistant workspace is not ready')
288+
})
289+
290+
expect(OpenCodeClient).not.toHaveBeenCalled()
291+
expect(mocks.listSessionsPage).not.toHaveBeenCalled()
292+
expect(mocks.createSession).not.toHaveBeenCalled()
293+
expect(onNavigate).not.toHaveBeenCalled()
294+
})
295+
296+
it('rejects with readiness error when defaultAgent is missing', async () => {
297+
mocks.getAssistantModeStatus.mockResolvedValue({
298+
directory: '/assistant',
299+
files: {
300+
agentsMd: { path: '/assistant/AGENTS.md', exists: true, created: true },
301+
opencodeJson: { path: '/assistant/.opencode.json', exists: true, created: true },
302+
},
303+
defaultAgent: undefined,
304+
})
305+
const onNavigate = vi.fn()
306+
const { result } = renderHook(() => useAssistantSessionLauncher({
307+
repoId: 123,
308+
opcodeUrl: 'http://localhost:5551',
309+
onNavigate,
310+
}))
311+
312+
await act(async () => {
313+
await expect(result.current.openAssistant()).rejects.toThrow('Assistant workspace is not ready')
314+
})
315+
316+
expect(OpenCodeClient).not.toHaveBeenCalled()
317+
expect(mocks.listSessionsPage).not.toHaveBeenCalled()
318+
expect(mocks.createSession).not.toHaveBeenCalled()
319+
expect(onNavigate).not.toHaveBeenCalled()
320+
})
231321
})

frontend/src/hooks/useAssistantSessionLauncher.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useCallback } from 'react'
2-
import { initializeAssistantMode } from '@/api/repos'
2+
import { getAssistantModeStatus } from '@/api/repos'
33
import { OpenCodeClient } from '@/api/opencode'
44
import type { AssistantModeStatus } from '@opencode-manager/shared/types'
55
import type { components } from '@/api/opencode-types'
@@ -113,13 +113,21 @@ async function sendAssistantModeWarningsPrompt(client: OpenCodeClient, sessionId
113113
}).catch(() => undefined)
114114
}
115115

116+
function assertAssistantReady(assistant: AssistantModeStatus): void {
117+
if (!assistant.files.agentsMd.exists || !assistant.files.opencodeJson.exists || !assistant.defaultAgent?.exists) {
118+
throw new Error('Assistant workspace is not ready. Restart the server to run Assistant setup.')
119+
}
120+
}
121+
116122
export function useAssistantSessionLauncher({
117123
repoId,
118124
opcodeUrl,
119125
onNavigate,
120126
}: UseAssistantSessionLauncherOptions) {
121127
const openAssistant = useCallback(async () => {
122-
const assistant = await initializeAssistantMode(repoId)
128+
const assistant = await getAssistantModeStatus(repoId)
129+
assertAssistantReady(assistant)
130+
123131
const client = new OpenCodeClient(opcodeUrl, assistant.directory)
124132
const assistantDirectory = assistant.directory
125133

frontend/src/pages/AssistantRedirect.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useCallback, useEffect, useState } from "react"
22
import { useParams, useNavigate, useLocation } from "react-router-dom"
33
import { useQuery, useQueryClient } from "@tanstack/react-query"
4-
import { getRepo, initializeAssistantMode } from "@/api/repos"
4+
import { getRepo, getAssistantModeStatus } from "@/api/repos"
55
import { useAssistantSessionLauncher } from "@/hooks/useAssistantSessionLauncher"
66
import { useCreateSession } from "@/hooks/useOpenCode"
77
import { useDialogParam } from "@/hooks/useDialogParam"
@@ -60,7 +60,7 @@ export function AssistantRedirect() {
6060

6161
const { data: assistantMode, isLoading: assistantModeLoading, error: assistantModeError } = useQuery({
6262
queryKey: ["repo", repoId, "assistant-mode"],
63-
queryFn: () => initializeAssistantMode(repoId),
63+
queryFn: () => getAssistantModeStatus(repoId),
6464
enabled: showSessionList && repoId !== undefined,
6565
})
6666

@@ -204,7 +204,7 @@ export function AssistantRedirect() {
204204
<>
205205
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground mx-auto mb-4" />
206206
<p className="text-muted-foreground">
207-
{status === "preparing" && "Preparing Assistant workspace..."}
207+
{status === "preparing" && "Opening Assistant..."}
208208
{status === "creating" && "Opening your last session chat..."}
209209
{status === "opening" && "Opening your last session chat..."}
210210
</p>

0 commit comments

Comments
 (0)