Skip to content

Commit bfebc79

Browse files
fix: reload/restart OpenCode server when changing default config
Set-default route now diffs the previous default against the new one and triggers opencodeServerManager.restart() for agent changes or reloadConfig() for plugin/skills/provider changes, mirroring the PUT /opencode-configs/:name pattern. Previously users had to manually restart the server to apply the new default's runtime sections. Frontend invalidates config caches after set-default and surfaces the reload/restart state (or reloadError) in the toast.
1 parent 29b9d02 commit bfebc79

4 files changed

Lines changed: 357 additions & 6 deletions

File tree

backend/src/routes/settings.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,9 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic
444444
const userId = c.req.query('userId') || 'default'
445445
const configName = c.req.param('name')
446446

447+
const previousDefault = settingsService.getDefaultOpenCodeConfig(userId)
448+
const previousContent = previousDefault?.content ?? null
449+
447450
settingsService.saveLastKnownGoodConfig(userId)
448451

449452
const existingConfig = settingsService.getOpenCodeConfigByName(configName, userId)
@@ -483,12 +486,43 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic
483486
await writeFileContent(configPath, contentToWrite)
484487
logger.info(`Wrote default config '${configName}' to: ${configPath}`)
485488

489+
const newContent = existingConfig.content as Record<string, unknown>
490+
const agentsChanged = JSON.stringify(previousContent?.agent) !== JSON.stringify(newContent.agent)
491+
const pluginsChanged = JSON.stringify(previousContent?.plugin) !== JSON.stringify(newContent.plugin)
492+
const skillsChanged = JSON.stringify(previousContent?.skills) !== JSON.stringify(newContent.skills)
493+
const providersChanged = JSON.stringify(previousContent?.provider) !== JSON.stringify(newContent.provider)
494+
495+
let reloaded = false
496+
let restarted = false
497+
let reloadError: string | undefined
498+
499+
if (agentsChanged) {
500+
try {
501+
logger.info('Agent configuration changed on set-default, restarting OpenCode server')
502+
await opencodeServerManager.restart()
503+
restarted = true
504+
} catch (error) {
505+
logger.warn('Failed to restart OpenCode after set-default:', error)
506+
reloadError = error instanceof Error ? error.message : 'Unknown error'
507+
}
508+
} else if (pluginsChanged || skillsChanged || providersChanged) {
509+
try {
510+
logger.info('Runtime config sections changed on set-default, reloading OpenCode server')
511+
await opencodeServerManager.reloadConfig()
512+
reloaded = true
513+
} catch (error) {
514+
logger.warn('Failed to reload OpenCode after set-default:', error)
515+
reloadError = error instanceof Error ? error.message : 'Unknown error'
516+
}
517+
}
518+
519+
const base = { ...config, reloaded, restarted, reloadError }
486520
if (patchResult.removedFields && patchResult.removedFields.length > 0) {
487521
logger.info(`Config applied with auto-removed fields: ${patchResult.removedFields.join(', ')}`)
488-
return c.json({ ...config, removedFields: patchResult.removedFields })
522+
return c.json({ ...base, removedFields: patchResult.removedFields })
489523
}
490524

491-
return c.json(config)
525+
return c.json(base)
492526
} catch (error) {
493527
logger.error('Failed to set default OpenCode config:', error)
494528
return c.json({ error: 'Failed to set default OpenCode config' }, 500)

backend/test/routes/settings.test.ts

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const mockUpdateOpenCodeConfig = vi.fn()
99
const mockDeleteOpenCodeConfig = vi.fn()
1010
const mockGetOpenCodeConfigByName = vi.fn()
1111
const mockSetDefaultOpenCodeConfig = vi.fn()
12+
const mockGetDefaultOpenCodeConfig = vi.fn()
1213

1314
vi.mock('fs', () => ({
1415
existsSync: vi.fn(() => false),
@@ -53,6 +54,7 @@ vi.mock('../../src/services/settings', () => ({
5354
deleteOpenCodeConfig: mockDeleteOpenCodeConfig,
5455
getOpenCodeConfigByName: mockGetOpenCodeConfigByName,
5556
setDefaultOpenCodeConfig: mockSetDefaultOpenCodeConfig,
57+
getDefaultOpenCodeConfig: mockGetDefaultOpenCodeConfig,
5658
})),
5759
}))
5860

@@ -188,6 +190,7 @@ describe('Settings Routes - OpenCode Upgrade', () => {
188190
mockRelinkReposFromSessionDirectories.mockReset()
189191
mockWriteFileContent.mockReset()
190192
mockPatchOpenCodeConfig.mockReset()
193+
mockGetDefaultOpenCodeConfig.mockReset()
191194

192195
testDb = {} as any
193196
settingsApp = createSettingsRoutes(testDb, { getGitEnvironment: vi.fn().mockReturnValue({}) } as any)
@@ -215,6 +218,7 @@ describe('Settings Routes - OpenCode Upgrade', () => {
215218
duplicatePathCount: 0,
216219
errors: [],
217220
})
221+
mockGetDefaultOpenCodeConfig.mockReturnValue(null)
218222
})
219223

220224
describe('OpenCode config routes', () => {
@@ -410,6 +414,306 @@ describe('Settings Routes - OpenCode Upgrade', () => {
410414
)
411415
expect(json.removedFields).toEqual(['command.review'])
412416
})
417+
418+
it('should restart when agents changed on set-default', async () => {
419+
mockGetDefaultOpenCodeConfig.mockReturnValue({
420+
id: 1,
421+
name: 'old-default',
422+
content: { agent: { primary: { model: 'a' } } },
423+
rawContent: '{"agent":{"primary":{"model":"a"}}}',
424+
isValid: true,
425+
isDefault: true,
426+
createdAt: 1,
427+
updatedAt: 1,
428+
})
429+
mockGetOpenCodeConfigByName.mockReturnValue({
430+
id: 2,
431+
name: 'new-config',
432+
content: { agent: { primary: { model: 'b' } } },
433+
rawContent: '{"agent":{"primary":{"model":"b"}}}',
434+
isValid: true,
435+
isDefault: false,
436+
createdAt: 2,
437+
updatedAt: 2,
438+
})
439+
mockPatchOpenCodeConfig.mockResolvedValueOnce({
440+
success: true,
441+
appliedConfig: { agent: { primary: { model: 'b' } } },
442+
})
443+
mockUpdateOpenCodeConfig.mockReturnValue({
444+
id: 2,
445+
name: 'new-config',
446+
content: { agent: { primary: { model: 'b' } } },
447+
rawContent: '{"agent":{"primary":{"model":"b"}}}',
448+
isValid: true,
449+
isDefault: false,
450+
createdAt: 2,
451+
updatedAt: 3,
452+
})
453+
mockSetDefaultOpenCodeConfig.mockReturnValue({
454+
id: 2,
455+
name: 'new-config',
456+
content: { agent: { primary: { model: 'b' } } },
457+
rawContent: '{"agent":{"primary":{"model":"b"}}}',
458+
isValid: true,
459+
isDefault: true,
460+
createdAt: 2,
461+
updatedAt: 4,
462+
})
463+
464+
const req = new Request('http://localhost/opencode-configs/new-config/set-default', {
465+
method: 'POST',
466+
})
467+
const res = await settingsApp.fetch(req)
468+
const json = await res.json() as Record<string, unknown>
469+
470+
expect(res.status).toBe(200)
471+
expect(mockRestart).toHaveBeenCalledTimes(1)
472+
expect(mockReloadConfig).not.toHaveBeenCalled()
473+
expect(json.restarted).toBe(true)
474+
expect(json.reloaded).toBe(false)
475+
expect(json.reloadError).toBeUndefined()
476+
})
477+
478+
it('should reload when plugins/skills/providers changed on set-default', async () => {
479+
mockGetDefaultOpenCodeConfig.mockReturnValue({
480+
id: 1,
481+
name: 'old-default',
482+
content: { plugin: ['old'] },
483+
rawContent: '{"plugin":["old"]}',
484+
isValid: true,
485+
isDefault: true,
486+
createdAt: 1,
487+
updatedAt: 1,
488+
})
489+
mockGetOpenCodeConfigByName.mockReturnValue({
490+
id: 2,
491+
name: 'new-config',
492+
content: { plugin: ['old', 'new'] },
493+
rawContent: '{"plugin":["old","new"]}',
494+
isValid: true,
495+
isDefault: false,
496+
createdAt: 2,
497+
updatedAt: 2,
498+
})
499+
mockPatchOpenCodeConfig.mockResolvedValueOnce({
500+
success: true,
501+
appliedConfig: { plugin: ['old', 'new'] },
502+
})
503+
mockUpdateOpenCodeConfig.mockReturnValue({
504+
id: 2,
505+
name: 'new-config',
506+
content: { plugin: ['old', 'new'] },
507+
rawContent: '{"plugin":["old","new"]}',
508+
isValid: true,
509+
isDefault: false,
510+
createdAt: 2,
511+
updatedAt: 3,
512+
})
513+
mockSetDefaultOpenCodeConfig.mockReturnValue({
514+
id: 2,
515+
name: 'new-config',
516+
content: { plugin: ['old', 'new'] },
517+
rawContent: '{"plugin":["old","new"]}',
518+
isValid: true,
519+
isDefault: true,
520+
createdAt: 2,
521+
updatedAt: 4,
522+
})
523+
524+
const req = new Request('http://localhost/opencode-configs/new-config/set-default', {
525+
method: 'POST',
526+
})
527+
const res = await settingsApp.fetch(req)
528+
const json = await res.json() as Record<string, unknown>
529+
530+
expect(res.status).toBe(200)
531+
expect(mockReloadConfig).toHaveBeenCalledTimes(1)
532+
expect(mockRestart).not.toHaveBeenCalled()
533+
expect(json.reloaded).toBe(true)
534+
expect(json.restarted).toBe(false)
535+
})
536+
537+
it('should not reload or restart when no runtime fields changed', async () => {
538+
mockGetDefaultOpenCodeConfig.mockReturnValue({
539+
id: 1,
540+
name: 'old-default',
541+
content: { theme: 'dark', agent: {} },
542+
rawContent: '{"theme":"dark","agent":{}}',
543+
isValid: true,
544+
isDefault: true,
545+
createdAt: 1,
546+
updatedAt: 1,
547+
})
548+
mockGetOpenCodeConfigByName.mockReturnValue({
549+
id: 2,
550+
name: 'new-config',
551+
content: { theme: 'light', agent: {} },
552+
rawContent: '{"theme":"light","agent":{}}',
553+
isValid: true,
554+
isDefault: false,
555+
createdAt: 2,
556+
updatedAt: 2,
557+
})
558+
mockPatchOpenCodeConfig.mockResolvedValueOnce({
559+
success: true,
560+
appliedConfig: { theme: 'light', agent: {} },
561+
})
562+
mockUpdateOpenCodeConfig.mockReturnValue({
563+
id: 2,
564+
name: 'new-config',
565+
content: { theme: 'light', agent: {} },
566+
rawContent: '{"theme":"light","agent":{}}',
567+
isValid: true,
568+
isDefault: false,
569+
createdAt: 2,
570+
updatedAt: 3,
571+
})
572+
mockSetDefaultOpenCodeConfig.mockReturnValue({
573+
id: 2,
574+
name: 'new-config',
575+
content: { theme: 'light', agent: {} },
576+
rawContent: '{"theme":"light","agent":{}}',
577+
isValid: true,
578+
isDefault: true,
579+
createdAt: 2,
580+
updatedAt: 4,
581+
})
582+
583+
const req = new Request('http://localhost/opencode-configs/new-config/set-default', {
584+
method: 'POST',
585+
})
586+
const res = await settingsApp.fetch(req)
587+
const json = await res.json() as Record<string, unknown>
588+
589+
expect(res.status).toBe(200)
590+
expect(mockRestart).not.toHaveBeenCalled()
591+
expect(mockReloadConfig).not.toHaveBeenCalled()
592+
expect(json.restarted).toBe(false)
593+
expect(json.reloaded).toBe(false)
594+
})
595+
596+
it('should return reloadError in response when restart fails', async () => {
597+
mockGetDefaultOpenCodeConfig.mockReturnValue({
598+
id: 1,
599+
name: 'old-default',
600+
content: { agent: { primary: { model: 'a' } } },
601+
rawContent: '{"agent":{"primary":{"model":"a"}}}',
602+
isValid: true,
603+
isDefault: true,
604+
createdAt: 1,
605+
updatedAt: 1,
606+
})
607+
mockGetOpenCodeConfigByName.mockReturnValue({
608+
id: 2,
609+
name: 'new-config',
610+
content: { agent: { primary: { model: 'b' } } },
611+
rawContent: '{"agent":{"primary":{"model":"b"}}}',
612+
isValid: true,
613+
isDefault: false,
614+
createdAt: 2,
615+
updatedAt: 2,
616+
})
617+
mockPatchOpenCodeConfig.mockResolvedValueOnce({
618+
success: true,
619+
appliedConfig: { agent: { primary: { model: 'b' } } },
620+
})
621+
mockUpdateOpenCodeConfig.mockReturnValue({
622+
id: 2,
623+
name: 'new-config',
624+
content: { agent: { primary: { model: 'b' } } },
625+
rawContent: '{"agent":{"primary":{"model":"b"}}}',
626+
isValid: true,
627+
isDefault: false,
628+
createdAt: 2,
629+
updatedAt: 3,
630+
})
631+
mockSetDefaultOpenCodeConfig.mockReturnValue({
632+
id: 2,
633+
name: 'new-config',
634+
content: { agent: { primary: { model: 'b' } } },
635+
rawContent: '{"agent":{"primary":{"model":"b"}}}',
636+
isValid: true,
637+
isDefault: true,
638+
createdAt: 2,
639+
updatedAt: 4,
640+
})
641+
mockRestart.mockRejectedValueOnce(new Error('boom'))
642+
643+
const req = new Request('http://localhost/opencode-configs/new-config/set-default', {
644+
method: 'POST',
645+
})
646+
const res = await settingsApp.fetch(req)
647+
const json = await res.json() as Record<string, unknown>
648+
649+
expect(res.status).toBe(200)
650+
expect(mockRestart).toHaveBeenCalledTimes(1)
651+
expect(json.reloadError).toBe('boom')
652+
expect(json.restarted).toBe(false)
653+
expect(json.id).toBeDefined()
654+
expect(json.name).toBe('new-config')
655+
expect(json.isDefault).toBe(true)
656+
})
657+
658+
it('should preserve removedFields alongside new flags', async () => {
659+
mockGetDefaultOpenCodeConfig.mockReturnValue({
660+
id: 1,
661+
name: 'old-default',
662+
content: { plugin: ['old'] },
663+
rawContent: '{"plugin":["old"]}',
664+
isValid: true,
665+
isDefault: true,
666+
createdAt: 1,
667+
updatedAt: 1,
668+
})
669+
mockGetOpenCodeConfigByName.mockReturnValue({
670+
id: 2,
671+
name: 'new-config',
672+
content: { plugin: ['old', 'new'], command: { review: true } },
673+
rawContent: '{"plugin":["old","new"],"command":{"review":true}}',
674+
isValid: true,
675+
isDefault: false,
676+
createdAt: 2,
677+
updatedAt: 2,
678+
})
679+
mockPatchOpenCodeConfig.mockResolvedValueOnce({
680+
success: true,
681+
appliedConfig: { plugin: ['old', 'new'] },
682+
removedFields: ['command.review'],
683+
details: [{ path: 'command.review', message: 'Invalid field' }],
684+
})
685+
mockUpdateOpenCodeConfig.mockReturnValue({
686+
id: 2,
687+
name: 'new-config',
688+
content: { plugin: ['old', 'new'] },
689+
rawContent: '{"plugin":["old","new"]}',
690+
isValid: true,
691+
isDefault: false,
692+
createdAt: 2,
693+
updatedAt: 3,
694+
})
695+
mockSetDefaultOpenCodeConfig.mockReturnValue({
696+
id: 2,
697+
name: 'new-config',
698+
content: { plugin: ['old', 'new'] },
699+
rawContent: '{"plugin":["old","new"]}',
700+
isValid: true,
701+
isDefault: true,
702+
createdAt: 2,
703+
updatedAt: 4,
704+
})
705+
706+
const req = new Request('http://localhost/opencode-configs/new-config/set-default', {
707+
method: 'POST',
708+
})
709+
const res = await settingsApp.fetch(req)
710+
const json = await res.json() as Record<string, unknown>
711+
712+
expect(res.status).toBe(200)
713+
expect(mockReloadConfig).toHaveBeenCalledTimes(1)
714+
expect(json.reloaded).toBe(true)
715+
expect(json.removedFields).toEqual(['command.review'])
716+
})
413717
})
414718

415719
describe('OpenCode import routes', () => {

0 commit comments

Comments
 (0)