feat: add backend config settings and tests
This commit is contained in:
@@ -3109,6 +3109,7 @@ export default function ControlClient() {
|
||||
workspaceId?: string;
|
||||
agent?: string;
|
||||
modelOverride?: string;
|
||||
backend?: string;
|
||||
}) => {
|
||||
try {
|
||||
setMissionLoading(true);
|
||||
@@ -3116,6 +3117,7 @@ export default function ControlClient() {
|
||||
workspaceId: options?.workspaceId,
|
||||
agent: options?.agent,
|
||||
modelOverride: options?.modelOverride,
|
||||
backend: options?.backend,
|
||||
});
|
||||
pendingMissionNavRef.current = mission.id;
|
||||
router.replace(`/control?mission=${mission.id}`, { scroll: false });
|
||||
|
||||
@@ -46,12 +46,13 @@ export default function OverviewPage() {
|
||||
const isActive = (stats?.active_tasks ?? 0) > 0;
|
||||
|
||||
const handleNewMission = useCallback(
|
||||
async (options?: { workspaceId?: string; agent?: string }) => {
|
||||
async (options?: { workspaceId?: string; agent?: string; backend?: string }) => {
|
||||
try {
|
||||
setCreatingMission(true);
|
||||
const mission = await createMission({
|
||||
workspaceId: options?.workspaceId,
|
||||
agent: options?.agent,
|
||||
backend: options?.backend,
|
||||
});
|
||||
toast.success('New mission created');
|
||||
router.push(`/control?mission=${mission.id}`);
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
AIProviderTypeInfo,
|
||||
getSettings,
|
||||
updateLibraryRemote,
|
||||
listBackends,
|
||||
getBackendConfig,
|
||||
updateBackendConfig,
|
||||
} from '@/lib/api';
|
||||
import {
|
||||
Server,
|
||||
@@ -101,6 +104,22 @@ export default function SettingsPage() {
|
||||
const [libraryRemoteValue, setLibraryRemoteValue] = useState('');
|
||||
const [savingLibraryRemote, setSavingLibraryRemote] = useState(false);
|
||||
|
||||
// Backend settings state
|
||||
const [activeBackendTab, setActiveBackendTab] = useState<'opencode' | 'claudecode'>('opencode');
|
||||
const [savingBackend, setSavingBackend] = useState(false);
|
||||
const [opencodeForm, setOpencodeForm] = useState({
|
||||
base_url: '',
|
||||
default_agent: '',
|
||||
permissive: false,
|
||||
enabled: true,
|
||||
});
|
||||
const [claudeForm, setClaudeForm] = useState({
|
||||
api_key: '',
|
||||
default_model: '',
|
||||
api_key_configured: false,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
// SWR: fetch health status
|
||||
const { data: health, isLoading: healthLoading, mutate: mutateHealth } = useSWR(
|
||||
'health',
|
||||
@@ -129,6 +148,26 @@ export default function SettingsPage() {
|
||||
{ revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
// SWR: fetch backends
|
||||
const { data: backends = [] } = useSWR('backends', listBackends, {
|
||||
revalidateOnFocus: false,
|
||||
fallbackData: [
|
||||
{ id: 'opencode', name: 'OpenCode' },
|
||||
{ id: 'claudecode', name: 'Claude Code' },
|
||||
],
|
||||
});
|
||||
|
||||
const { data: opencodeBackendConfig, mutate: mutateOpenCodeBackend } = useSWR(
|
||||
'backend-opencode-config',
|
||||
() => getBackendConfig('opencode'),
|
||||
{ revalidateOnFocus: false }
|
||||
);
|
||||
const { data: claudecodeBackendConfig, mutate: mutateClaudeBackend } = useSWR(
|
||||
'backend-claudecode-config',
|
||||
() => getBackendConfig('claudecode'),
|
||||
{ revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
// Check if there are unsaved changes
|
||||
const hasUnsavedChanges = apiUrl !== originalValues.apiUrl;
|
||||
|
||||
@@ -175,6 +214,28 @@ export default function SettingsPage() {
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [apiUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opencodeBackendConfig?.settings) return;
|
||||
const settings = opencodeBackendConfig.settings as Record<string, unknown>;
|
||||
setOpencodeForm({
|
||||
base_url: typeof settings.base_url === 'string' ? settings.base_url : '',
|
||||
default_agent: typeof settings.default_agent === 'string' ? settings.default_agent : '',
|
||||
permissive: Boolean(settings.permissive),
|
||||
enabled: opencodeBackendConfig.enabled,
|
||||
});
|
||||
}, [opencodeBackendConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!claudecodeBackendConfig?.settings) return;
|
||||
const settings = claudecodeBackendConfig.settings as Record<string, unknown>;
|
||||
setClaudeForm((prev) => ({
|
||||
...prev,
|
||||
default_model: typeof settings.default_model === 'string' ? settings.default_model : '',
|
||||
api_key_configured: Boolean(settings.api_key_configured),
|
||||
enabled: claudecodeBackendConfig.enabled,
|
||||
}));
|
||||
}, [claudecodeBackendConfig]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!validateUrl(apiUrl)) {
|
||||
toast.error('Please fix validation errors before saving');
|
||||
@@ -259,6 +320,58 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveOpenCodeBackend = async () => {
|
||||
setSavingBackend(true);
|
||||
try {
|
||||
const result = await updateBackendConfig(
|
||||
'opencode',
|
||||
{
|
||||
base_url: opencodeForm.base_url,
|
||||
default_agent: opencodeForm.default_agent || null,
|
||||
permissive: opencodeForm.permissive,
|
||||
},
|
||||
{ enabled: opencodeForm.enabled }
|
||||
);
|
||||
toast.success(result.message || 'OpenCode settings updated');
|
||||
mutateOpenCodeBackend();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Failed to update OpenCode settings: ${
|
||||
err instanceof Error ? err.message : 'Unknown error'
|
||||
}`
|
||||
);
|
||||
} finally {
|
||||
setSavingBackend(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveClaudeBackend = async () => {
|
||||
setSavingBackend(true);
|
||||
try {
|
||||
const settings: Record<string, unknown> = {
|
||||
default_model: claudeForm.default_model || null,
|
||||
};
|
||||
if (claudeForm.api_key) {
|
||||
settings.api_key = claudeForm.api_key;
|
||||
}
|
||||
|
||||
const result = await updateBackendConfig('claudecode', settings, {
|
||||
enabled: claudeForm.enabled,
|
||||
});
|
||||
toast.success(result.message || 'Claude Code settings updated');
|
||||
setClaudeForm((prev) => ({ ...prev, api_key: '' }));
|
||||
mutateClaudeBackend();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Failed to update Claude Code settings: ${
|
||||
err instanceof Error ? err.message : 'Unknown error'
|
||||
}`
|
||||
);
|
||||
} finally {
|
||||
setSavingBackend(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartEdit = (provider: AIProvider) => {
|
||||
setEditingProvider(provider.id);
|
||||
setEditForm({
|
||||
@@ -565,6 +678,176 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backends */}
|
||||
<div className="rounded-xl bg-white/[0.02] border border-white/[0.04] p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-500/10">
|
||||
<Server className="h-5 w-5 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-white">Backends</h2>
|
||||
<p className="text-xs text-white/40">
|
||||
Configure execution backends and authentication
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
{backends.map((backend) => (
|
||||
<button
|
||||
key={backend.id}
|
||||
onClick={() =>
|
||||
setActiveBackendTab(
|
||||
backend.id === 'claudecode' ? 'claudecode' : 'opencode'
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
|
||||
activeBackendTab === backend.id
|
||||
? 'bg-white/[0.08] border-white/[0.12] text-white'
|
||||
: 'bg-white/[0.02] border-white/[0.06] text-white/50 hover:text-white/70'
|
||||
)}
|
||||
>
|
||||
{backend.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeBackendTab === 'opencode' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-white/60">Enabled</span>
|
||||
<label className="flex items-center gap-2 text-xs text-white/60 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={opencodeForm.enabled}
|
||||
onChange={(e) =>
|
||||
setOpencodeForm((prev) => ({ ...prev, enabled: e.target.checked }))
|
||||
}
|
||||
className="rounded border-white/20 cursor-pointer"
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-white/60 mb-1.5">Base URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={opencodeForm.base_url}
|
||||
onChange={(e) =>
|
||||
setOpencodeForm((prev) => ({ ...prev, base_url: e.target.value }))
|
||||
}
|
||||
placeholder="http://127.0.0.1:4096"
|
||||
className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2 text-sm text-white focus:outline-none focus:border-indigo-500/50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-white/60 mb-1.5">Default Agent</label>
|
||||
<input
|
||||
type="text"
|
||||
value={opencodeForm.default_agent}
|
||||
onChange={(e) =>
|
||||
setOpencodeForm((prev) => ({ ...prev, default_agent: e.target.value }))
|
||||
}
|
||||
placeholder="Sisyphus"
|
||||
className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2 text-sm text-white focus:outline-none focus:border-indigo-500/50"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-white/60 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={opencodeForm.permissive}
|
||||
onChange={(e) =>
|
||||
setOpencodeForm((prev) => ({ ...prev, permissive: e.target.checked }))
|
||||
}
|
||||
className="rounded border-white/20 cursor-pointer"
|
||||
/>
|
||||
Permissive mode (auto-allow tool permissions)
|
||||
</label>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
onClick={handleSaveOpenCodeBackend}
|
||||
disabled={savingBackend}
|
||||
className="flex items-center gap-2 rounded-lg bg-indigo-500 px-3 py-1.5 text-xs text-white hover:bg-indigo-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{savingBackend ? (
|
||||
<Loader className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Save OpenCode
|
||||
</button>
|
||||
<span className="text-xs text-white/40">Restart required to apply runtime changes</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-white/60">Enabled</span>
|
||||
<label className="flex items-center gap-2 text-xs text-white/60 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={claudeForm.enabled}
|
||||
onChange={(e) =>
|
||||
setClaudeForm((prev) => ({ ...prev, enabled: e.target.checked }))
|
||||
}
|
||||
className="rounded border-white/20 cursor-pointer"
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
<div className="text-xs text-white/50">
|
||||
API key status:{' '}
|
||||
<span className={claudeForm.api_key_configured ? 'text-emerald-400' : 'text-amber-400'}>
|
||||
{claudeForm.api_key_configured ? 'Configured' : 'Not configured'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-white/60 mb-1.5">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={claudeForm.api_key}
|
||||
onChange={(e) =>
|
||||
setClaudeForm((prev) => ({ ...prev, api_key: e.target.value }))
|
||||
}
|
||||
placeholder="sk-..."
|
||||
className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2 text-sm text-white focus:outline-none focus:border-indigo-500/50"
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-white/30">
|
||||
Stored securely in the secrets vault; leave blank to keep existing key.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-white/60 mb-1.5">Default Model</label>
|
||||
<input
|
||||
type="text"
|
||||
value={claudeForm.default_model}
|
||||
onChange={(e) =>
|
||||
setClaudeForm((prev) => ({ ...prev, default_model: e.target.value }))
|
||||
}
|
||||
placeholder="claude-sonnet-4-20250514"
|
||||
className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2 text-sm text-white focus:outline-none focus:border-indigo-500/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
onClick={handleSaveClaudeBackend}
|
||||
disabled={savingBackend}
|
||||
className="flex items-center gap-2 rounded-lg bg-indigo-500 px-3 py-1.5 text-xs text-white hover:bg-indigo-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{savingBackend ? (
|
||||
<Loader className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Save Claude Code
|
||||
</button>
|
||||
<span className="text-xs text-white/40">Restart required to apply runtime changes</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Library Settings */}
|
||||
<div className="rounded-xl bg-white/[0.02] border border-white/[0.04] p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
|
||||
@@ -2874,12 +2874,13 @@ export async function getBackendConfig(backendId: string): Promise<BackendConfig
|
||||
// Update backend configuration
|
||||
export async function updateBackendConfig(
|
||||
backendId: string,
|
||||
settings: Record<string, unknown>
|
||||
settings: Record<string, unknown>,
|
||||
options?: { enabled?: boolean }
|
||||
): Promise<{ ok: boolean; message?: string }> {
|
||||
const res = await apiFetch(`/api/backends/${encodeURIComponent(backendId)}/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ settings }),
|
||||
body: JSON.stringify({ settings, enabled: options?.enabled }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update backend config');
|
||||
return res.json();
|
||||
|
||||
21
dashboard/tests/backend-selection.spec.ts
Normal file
21
dashboard/tests/backend-selection.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Backend Selection', () => {
|
||||
test('can select backend when creating mission', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
const newMissionButton = page.getByRole('button', { name: /New\s+Mission/i });
|
||||
await expect(newMissionButton).toBeVisible();
|
||||
await newMissionButton.click();
|
||||
|
||||
const backendSelect = page.getByText('Backend').locator('..').locator('select');
|
||||
await expect(backendSelect).toBeVisible();
|
||||
|
||||
const options = await backendSelect.locator('option').allTextContents();
|
||||
expect(options.some((opt) => opt.toLowerCase().includes('opencode'))).toBeTruthy();
|
||||
expect(options.some((opt) => opt.toLowerCase().includes('claude'))).toBeTruthy();
|
||||
|
||||
await backendSelect.selectOption('claudecode');
|
||||
await expect(backendSelect).toHaveValue('claudecode');
|
||||
});
|
||||
});
|
||||
69
dashboard/tests/claudecode-config.spec.ts
Normal file
69
dashboard/tests/claudecode-config.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
async function waitForFile(filePath: string, timeoutMs = 10000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return;
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
throw new Error(`Timed out waiting for file: ${filePath}`);
|
||||
}
|
||||
|
||||
test('claude code mission generates correct config', async ({ page }) => {
|
||||
const workspacesRes = await page.request.get('/api/workspaces');
|
||||
if (!workspacesRes.ok()) {
|
||||
test.skip(true, 'Backend API not available');
|
||||
}
|
||||
const workspaces = await workspacesRes.json();
|
||||
const hostWorkspace =
|
||||
workspaces.find((ws: { id: string }) => ws.id === '00000000-0000-0000-0000-000000000000') ||
|
||||
workspaces.find((ws: { workspace_type: string }) => ws.workspace_type === 'host');
|
||||
|
||||
if (!hostWorkspace) {
|
||||
test.skip(true, 'No host workspace found');
|
||||
}
|
||||
|
||||
const missionRes = await page.request.post('/api/control/missions', {
|
||||
data: {
|
||||
title: `pw-claude-${Date.now()}`,
|
||||
workspace_id: hostWorkspace.id,
|
||||
backend: 'claudecode',
|
||||
},
|
||||
});
|
||||
if (!missionRes.ok()) {
|
||||
test.skip(true, `Mission create failed (${missionRes.status()})`);
|
||||
}
|
||||
const mission = await missionRes.json();
|
||||
|
||||
const messageRes = await page.request.post('/api/control/message', {
|
||||
data: { content: 'Generate Claude config', mission_id: mission.id },
|
||||
});
|
||||
if (!messageRes.ok()) {
|
||||
test.skip(true, 'Mission execution not available');
|
||||
}
|
||||
|
||||
const shortId = mission.id.slice(0, 8);
|
||||
const missionDir = path.join(hostWorkspace.path, 'workspaces', `mission-${shortId}`);
|
||||
const claudeSettings = path.join(missionDir, '.claude', 'settings.local.json');
|
||||
|
||||
await waitForFile(claudeSettings);
|
||||
const settingsContent = await fs.readFile(claudeSettings, 'utf8');
|
||||
expect(settingsContent).toContain('mcpServers');
|
||||
|
||||
const skillsRes = await page.request.get('/api/library/skills');
|
||||
if (skillsRes.ok()) {
|
||||
const skills = await skillsRes.json();
|
||||
if (skills.length > 0) {
|
||||
const claudeMd = path.join(missionDir, 'CLAUDE.md');
|
||||
await waitForFile(claudeMd);
|
||||
const claudeContent = await fs.readFile(claudeMd, 'utf8');
|
||||
expect(claudeContent).toContain('Project Context');
|
||||
}
|
||||
}
|
||||
});
|
||||
61
dashboard/tests/opencode-config.spec.ts
Normal file
61
dashboard/tests/opencode-config.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
async function waitForFile(filePath: string, timeoutMs = 10000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return;
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
throw new Error(`Timed out waiting for file: ${filePath}`);
|
||||
}
|
||||
|
||||
test('opencode mission generates correct config', async ({ page }) => {
|
||||
const workspacesRes = await page.request.get('/api/workspaces');
|
||||
if (!workspacesRes.ok()) {
|
||||
test.skip(true, 'Backend API not available');
|
||||
}
|
||||
const workspaces = await workspacesRes.json();
|
||||
const hostWorkspace =
|
||||
workspaces.find((ws: { id: string }) => ws.id === '00000000-0000-0000-0000-000000000000') ||
|
||||
workspaces.find((ws: { workspace_type: string }) => ws.workspace_type === 'host');
|
||||
|
||||
if (!hostWorkspace) {
|
||||
test.skip(true, 'No host workspace found');
|
||||
}
|
||||
|
||||
const missionRes = await page.request.post('/api/control/missions', {
|
||||
data: {
|
||||
title: `pw-opencode-${Date.now()}`,
|
||||
workspace_id: hostWorkspace.id,
|
||||
backend: 'opencode',
|
||||
},
|
||||
});
|
||||
if (!missionRes.ok()) {
|
||||
test.skip(true, `Mission create failed (${missionRes.status()})`);
|
||||
}
|
||||
const mission = await missionRes.json();
|
||||
|
||||
const messageRes = await page.request.post('/api/control/message', {
|
||||
data: { content: 'Ping', mission_id: mission.id },
|
||||
});
|
||||
if (!messageRes.ok()) {
|
||||
test.skip(true, 'Mission execution not available');
|
||||
}
|
||||
|
||||
const shortId = mission.id.slice(0, 8);
|
||||
const missionDir = path.join(hostWorkspace.path, 'workspaces', `mission-${shortId}`);
|
||||
const opencodeConfig = path.join(missionDir, '.opencode', 'opencode.json');
|
||||
const rootConfig = path.join(missionDir, 'opencode.json');
|
||||
|
||||
await waitForFile(opencodeConfig);
|
||||
await waitForFile(rootConfig);
|
||||
|
||||
const contents = await fs.readFile(opencodeConfig, 'utf8');
|
||||
expect(contents).toContain('mcp');
|
||||
});
|
||||
89
docs/BACKEND_API.md
Normal file
89
docs/BACKEND_API.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Backend API
|
||||
|
||||
All endpoints require authentication via `Authorization: Bearer <token>` header.
|
||||
|
||||
## List Backends
|
||||
|
||||
```
|
||||
GET /api/backends
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
[
|
||||
{"id": "opencode", "name": "OpenCode"},
|
||||
{"id": "claudecode", "name": "Claude Code"}
|
||||
]
|
||||
```
|
||||
|
||||
## Get Backend
|
||||
|
||||
```
|
||||
GET /api/backends/:id
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{"id": "opencode", "name": "OpenCode"}
|
||||
```
|
||||
|
||||
## List Backend Agents
|
||||
|
||||
```
|
||||
GET /api/backends/:id/agents
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
[{"id": "Sisyphus", "name": "Sisyphus"}]
|
||||
```
|
||||
|
||||
## Get Backend Config
|
||||
|
||||
```
|
||||
GET /api/backends/:id/config
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"id": "opencode",
|
||||
"name": "OpenCode",
|
||||
"enabled": true,
|
||||
"settings": {
|
||||
"base_url": "http://127.0.0.1:4096",
|
||||
"default_agent": "Sisyphus",
|
||||
"permissive": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For `claudecode`, `settings` includes `api_key_configured` and optional fields like `default_model`.
|
||||
|
||||
## Update Backend Config
|
||||
|
||||
```
|
||||
PUT /api/backends/:id/config
|
||||
```
|
||||
|
||||
**Body**:
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"settings": {
|
||||
"base_url": "http://127.0.0.1:4096",
|
||||
"default_agent": "Sisyphus",
|
||||
"permissive": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Claude Code accepts `api_key` in `settings` to store it securely in the secrets vault.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"message": "Backend configuration updated. Restart Open Agent to apply runtime changes."
|
||||
}
|
||||
```
|
||||
@@ -14,10 +14,13 @@ POST /api/control/missions
|
||||
"title": "My Mission",
|
||||
"workspace_id": "uuid",
|
||||
"agent": "code-reviewer",
|
||||
"model_override": "anthropic/claude-sonnet-4-20250514"
|
||||
"model_override": "anthropic/claude-sonnet-4-20250514",
|
||||
"backend": "opencode"
|
||||
}
|
||||
```
|
||||
|
||||
`backend` can be `"opencode"` or `"claudecode"`. Defaults to `"opencode"` if omitted.
|
||||
|
||||
**Response**: `Mission` object (see below).
|
||||
|
||||
## Load/Switch to a Mission
|
||||
@@ -155,9 +158,9 @@ data: {"id":"uuid","content":"Done!","success":true,"cost_cents":5,"model":"clau
|
||||
"workspace_name": "my-workspace",
|
||||
"agent": "code-reviewer",
|
||||
"model_override": null,
|
||||
"backend": "opencode",
|
||||
"history": [],
|
||||
"created_at": "2025-01-13T10:00:00Z",
|
||||
"updated_at": "2025-01-13T10:05:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -124,6 +124,10 @@ Manually syncs the workspace's skills and tools from the library to the `.openco
|
||||
|
||||
**Response**: `Workspace` object.
|
||||
|
||||
Note: Mission workspaces also generate backend-specific config on execution:
|
||||
- OpenCode: `.opencode/opencode.json` (and `opencode.json` in the workspace root)
|
||||
- Claude Code: `.claude/settings.local.json` + `CLAUDE.md`
|
||||
|
||||
## Execute Command
|
||||
|
||||
```
|
||||
|
||||
@@ -111,43 +111,38 @@ pub async fn get_backend_config(
|
||||
let backend = registry
|
||||
.get(&id)
|
||||
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("Backend {} not found", id)))?;
|
||||
drop(registry);
|
||||
|
||||
// Return backend-specific configuration
|
||||
let settings = match id.as_str() {
|
||||
"opencode" => {
|
||||
let base_url = std::env::var("OPENCODE_BASE_URL")
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:4096".to_string());
|
||||
let default_agent = std::env::var("OPENCODE_DEFAULT_AGENT").ok();
|
||||
let permissive = std::env::var("OPENCODE_PERMISSIVE")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
serde_json::json!({
|
||||
"base_url": base_url,
|
||||
"default_agent": default_agent,
|
||||
"permissive": permissive,
|
||||
})
|
||||
}
|
||||
"claudecode" => {
|
||||
// Check if Claude Code API key is configured
|
||||
let api_key_configured = state
|
||||
.secrets
|
||||
.as_ref()
|
||||
.map(|_s| {
|
||||
// TODO: implement proper secret check
|
||||
false
|
||||
})
|
||||
.unwrap_or(false);
|
||||
serde_json::json!({
|
||||
"api_key_configured": api_key_configured,
|
||||
})
|
||||
}
|
||||
_ => serde_json::json!({}),
|
||||
};
|
||||
let config_entry = state
|
||||
.backend_configs
|
||||
.get(&id)
|
||||
.await
|
||||
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("Backend {} not configured", id)))?;
|
||||
|
||||
let mut settings = config_entry.settings.clone();
|
||||
|
||||
if id == "claudecode" {
|
||||
let api_key_configured = if let Some(store) = state.secrets.as_ref() {
|
||||
match store.list_secrets("claudecode").await {
|
||||
Ok(secrets) => secrets.iter().any(|s| s.key == "api_key" && !s.is_expired),
|
||||
Err(_) => false,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let mut obj = settings.as_object().cloned().unwrap_or_default();
|
||||
obj.insert(
|
||||
"api_key_configured".to_string(),
|
||||
serde_json::Value::Bool(api_key_configured),
|
||||
);
|
||||
settings = serde_json::Value::Object(obj);
|
||||
}
|
||||
|
||||
Ok(Json(BackendConfig {
|
||||
id: backend.id().to_string(),
|
||||
name: backend.name().to_string(),
|
||||
enabled: true,
|
||||
enabled: config_entry.enabled,
|
||||
settings,
|
||||
}))
|
||||
}
|
||||
@@ -156,6 +151,7 @@ pub async fn get_backend_config(
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct UpdateBackendConfigRequest {
|
||||
pub settings: serde_json::Value,
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
/// Update backend configuration
|
||||
@@ -163,18 +159,88 @@ pub async fn update_backend_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(_user): Extension<AuthUser>,
|
||||
Path(id): Path<String>,
|
||||
Json(_req): Json<UpdateBackendConfigRequest>,
|
||||
Json(req): Json<UpdateBackendConfigRequest>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let registry = state.backend_registry.read().await;
|
||||
if registry.get(&id).is_none() {
|
||||
return Err((StatusCode::NOT_FOUND, format!("Backend {} not found", id)));
|
||||
}
|
||||
drop(registry);
|
||||
|
||||
// Backend configuration is currently read from environment variables
|
||||
// TODO: Implement persistent backend configuration storage
|
||||
let updated_settings = match id.as_str() {
|
||||
"opencode" => {
|
||||
let settings = req
|
||||
.settings
|
||||
.as_object()
|
||||
.ok_or_else(|| (StatusCode::BAD_REQUEST, "Invalid settings payload".to_string()))?;
|
||||
let base_url = settings
|
||||
.get("base_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| (StatusCode::BAD_REQUEST, "base_url is required".to_string()))?;
|
||||
let default_agent = settings
|
||||
.get("default_agent")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let permissive = settings
|
||||
.get("permissive")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
serde_json::json!({
|
||||
"base_url": base_url,
|
||||
"default_agent": default_agent,
|
||||
"permissive": permissive,
|
||||
})
|
||||
}
|
||||
"claudecode" => {
|
||||
let mut settings = req.settings.clone();
|
||||
if let Some(api_key) = settings.get("api_key").and_then(|v| v.as_str()) {
|
||||
let store = state
|
||||
.secrets
|
||||
.as_ref()
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Secrets store not available".to_string(),
|
||||
)
|
||||
})?;
|
||||
store
|
||||
.set_secret("claudecode", "api_key", api_key, None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Failed to store API key: {}", e),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
if let Some(obj) = settings.as_object_mut() {
|
||||
obj.remove("api_key");
|
||||
}
|
||||
settings
|
||||
}
|
||||
_ => req.settings.clone(),
|
||||
};
|
||||
|
||||
let updated = state
|
||||
.backend_configs
|
||||
.update_settings(&id, updated_settings, req.enabled)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to persist backend config: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
if updated.is_none() {
|
||||
return Err((StatusCode::NOT_FOUND, format!("Backend {} not found", id)));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"message": "Backend configuration is currently read-only"
|
||||
"message": "Backend configuration updated. Restart Open Agent to apply runtime changes."
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -3676,7 +3676,7 @@ async fn run_single_control_turn(
|
||||
// Context for agent execution.
|
||||
let mut ctx = AgentContext::new(config.clone(), working_dir_path);
|
||||
ctx.mission_control = mission_control;
|
||||
ctx.control_events = Some(events_tx);
|
||||
ctx.control_events = Some(events_tx.clone());
|
||||
ctx.frontend_tool_hub = Some(tool_hub);
|
||||
ctx.control_status = Some(status);
|
||||
ctx.cancel_token = Some(cancel);
|
||||
@@ -3685,6 +3685,24 @@ async fn run_single_control_turn(
|
||||
ctx.mission_id = mission_id;
|
||||
ctx.mcp = Some(mcp);
|
||||
|
||||
if let Some(ref backend) = backend_id {
|
||||
if backend != "opencode" {
|
||||
let _ = events_tx.send(AgentEvent::Error {
|
||||
message: format!(
|
||||
"Backend '{}' is not supported for in-app execution yet. Please use OpenCode or run Claude Code locally.",
|
||||
backend
|
||||
),
|
||||
mission_id,
|
||||
resumable: mission_id.is_some(),
|
||||
});
|
||||
return crate::agents::AgentResult::failure(
|
||||
format!("Unsupported backend: {}", backend),
|
||||
0,
|
||||
)
|
||||
.with_terminal_reason(TerminalReason::LlmError);
|
||||
}
|
||||
}
|
||||
|
||||
let result = root_agent.execute(&mut task, &ctx).await;
|
||||
result
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ use tokio::sync::{broadcast, mpsc, RwLock};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::agents::{AgentContext, AgentRef, AgentResult};
|
||||
use crate::agents::{AgentContext, AgentRef, AgentResult, TerminalReason};
|
||||
use crate::config::Config;
|
||||
use crate::mcp::McpRegistry;
|
||||
use crate::task::{extract_deliverables, DeliverableSet};
|
||||
@@ -502,7 +502,7 @@ async fn run_mission_turn(
|
||||
|
||||
let mut ctx = AgentContext::new(config.clone(), mission_work_dir);
|
||||
ctx.mission_control = mission_control;
|
||||
ctx.control_events = Some(events_tx);
|
||||
ctx.control_events = Some(events_tx.clone());
|
||||
ctx.frontend_tool_hub = Some(tool_hub);
|
||||
ctx.control_status = Some(status);
|
||||
ctx.cancel_token = Some(cancel);
|
||||
@@ -511,6 +511,19 @@ async fn run_mission_turn(
|
||||
ctx.mission_id = Some(mission_id);
|
||||
ctx.mcp = Some(mcp);
|
||||
|
||||
if backend_id != "opencode" {
|
||||
let _ = events_tx.send(AgentEvent::Error {
|
||||
message: format!(
|
||||
"Backend '{}' is not supported for in-app execution yet. Please use OpenCode or run Claude Code locally.",
|
||||
backend_id
|
||||
),
|
||||
mission_id: Some(mission_id),
|
||||
resumable: true,
|
||||
});
|
||||
return AgentResult::failure(format!("Unsupported backend: {}", backend_id), 0)
|
||||
.with_terminal_reason(TerminalReason::LlmError);
|
||||
}
|
||||
|
||||
let result = root_agent.execute(&mut task, &ctx).await;
|
||||
tracing::info!(
|
||||
mission_id = %mission_id,
|
||||
|
||||
@@ -23,6 +23,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::agents::{AgentContext, AgentRef, OpenCodeAgent};
|
||||
use crate::backend::registry::BackendRegistry;
|
||||
use crate::backend_config::BackendConfigEntry;
|
||||
use crate::config::{AuthMode, Config};
|
||||
use crate::mcp::McpRegistry;
|
||||
use crate::workspace;
|
||||
@@ -76,16 +77,16 @@ pub struct AppState {
|
||||
pub settings: Arc<crate::settings::SettingsStore>,
|
||||
/// Backend registry for multi-backend support
|
||||
pub backend_registry: Arc<RwLock<BackendRegistry>>,
|
||||
/// Backend configuration store
|
||||
pub backend_configs: Arc<crate::backend_config::BackendConfigStore>,
|
||||
}
|
||||
|
||||
/// Start the HTTP server.
|
||||
pub async fn serve(config: Config) -> anyhow::Result<()> {
|
||||
let mut config = config;
|
||||
// Start monitoring background collector early so clients get history immediately
|
||||
monitoring::init_monitoring();
|
||||
|
||||
// Always use OpenCode backend
|
||||
let root_agent: AgentRef = Arc::new(OpenCodeAgent::new(config.clone()));
|
||||
|
||||
// Initialize MCP registry
|
||||
let mcp = Arc::new(McpRegistry::new(&config.working_dir).await);
|
||||
if let Err(e) = crate::opencode_config::ensure_global_config(&mcp).await {
|
||||
@@ -140,6 +141,51 @@ pub async fn serve(config: Config) -> anyhow::Result<()> {
|
||||
// Initialize global settings store
|
||||
let settings = Arc::new(crate::settings::SettingsStore::new(&config.working_dir).await);
|
||||
|
||||
// Initialize backend config store (persisted settings)
|
||||
let backend_defaults = vec![
|
||||
BackendConfigEntry::new(
|
||||
"opencode",
|
||||
"OpenCode",
|
||||
serde_json::json!({
|
||||
"base_url": config.opencode_base_url,
|
||||
"default_agent": config.opencode_agent,
|
||||
"permissive": config.opencode_permissive,
|
||||
}),
|
||||
),
|
||||
BackendConfigEntry::new("claudecode", "Claude Code", serde_json::json!({})),
|
||||
];
|
||||
let backend_configs = Arc::new(
|
||||
crate::backend_config::BackendConfigStore::new(
|
||||
config
|
||||
.working_dir
|
||||
.join(".openagent/backend_config.json"),
|
||||
backend_defaults,
|
||||
)
|
||||
.await,
|
||||
);
|
||||
|
||||
// Apply persisted OpenCode settings (if present)
|
||||
if let Some(entry) = backend_configs.get("opencode").await {
|
||||
if let Some(settings) = entry.settings.as_object() {
|
||||
if let Some(base_url) = settings.get("base_url").and_then(|v| v.as_str()) {
|
||||
if !base_url.trim().is_empty() {
|
||||
config.opencode_base_url = base_url.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(agent) = settings.get("default_agent").and_then(|v| v.as_str()) {
|
||||
if !agent.trim().is_empty() {
|
||||
config.opencode_agent = Some(agent.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(permissive) = settings.get("permissive").and_then(|v| v.as_bool()) {
|
||||
config.opencode_permissive = permissive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always use OpenCode backend
|
||||
let root_agent: AgentRef = Arc::new(OpenCodeAgent::new(config.clone()));
|
||||
|
||||
// Initialize backend registry with OpenCode and Claude Code backends
|
||||
let opencode_base_url =
|
||||
std::env::var("OPENCODE_BASE_URL").unwrap_or_else(|_| "http://127.0.0.1:4096".to_string());
|
||||
@@ -251,6 +297,7 @@ pub async fn serve(config: Config) -> anyhow::Result<()> {
|
||||
console_pool,
|
||||
settings,
|
||||
backend_registry,
|
||||
backend_configs,
|
||||
});
|
||||
|
||||
// Start background desktop session cleanup task
|
||||
|
||||
147
src/backend_config.rs
Normal file
147
src/backend_config.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
//! Backend configuration storage and persistence.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BackendConfigEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub settings: serde_json::Value,
|
||||
}
|
||||
|
||||
fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl BackendConfigEntry {
|
||||
pub fn new(id: impl Into<String>, name: impl Into<String>, settings: serde_json::Value) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
enabled: true,
|
||||
settings,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct BackendConfigStore {
|
||||
configs: Arc<RwLock<HashMap<String, BackendConfigEntry>>>,
|
||||
storage_path: PathBuf,
|
||||
}
|
||||
|
||||
impl BackendConfigStore {
|
||||
pub async fn new(storage_path: PathBuf, defaults: Vec<BackendConfigEntry>) -> Self {
|
||||
let mut configs = HashMap::new();
|
||||
let mut needs_save = false;
|
||||
|
||||
if storage_path.exists() {
|
||||
if let Ok(loaded) = Self::load_from_disk(&storage_path) {
|
||||
configs = loaded;
|
||||
}
|
||||
} else {
|
||||
needs_save = true;
|
||||
}
|
||||
|
||||
for default in defaults {
|
||||
match configs.get_mut(&default.id) {
|
||||
Some(existing) => {
|
||||
if existing.name.is_empty() {
|
||||
existing.name = default.name.clone();
|
||||
needs_save = true;
|
||||
}
|
||||
if existing.settings.is_null() {
|
||||
existing.settings = default.settings.clone();
|
||||
needs_save = true;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
configs.insert(default.id.clone(), default);
|
||||
needs_save = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let store = Self {
|
||||
configs: Arc::new(RwLock::new(configs)),
|
||||
storage_path,
|
||||
};
|
||||
|
||||
if needs_save {
|
||||
if let Err(e) = store.save_to_disk().await {
|
||||
tracing::warn!("Failed to persist backend config defaults: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
store
|
||||
}
|
||||
|
||||
fn load_from_disk(path: &Path) -> Result<HashMap<String, BackendConfigEntry>, std::io::Error> {
|
||||
let contents = std::fs::read_to_string(path)?;
|
||||
let entries: Vec<BackendConfigEntry> = serde_json::from_str(&contents)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
Ok(entries
|
||||
.into_iter()
|
||||
.map(|entry| (entry.id.clone(), entry))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn save_to_disk(&self) -> Result<(), std::io::Error> {
|
||||
let configs = self.configs.read().await;
|
||||
let mut entries: Vec<BackendConfigEntry> = configs.values().cloned().collect();
|
||||
entries.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
if let Some(parent) = self.storage_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let contents = serde_json::to_string_pretty(&entries)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
std::fs::write(&self.storage_path, contents)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list(&self) -> Vec<BackendConfigEntry> {
|
||||
let configs = self.configs.read().await;
|
||||
let mut list: Vec<_> = configs.values().cloned().collect();
|
||||
list.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
list
|
||||
}
|
||||
|
||||
pub async fn get(&self, id: &str) -> Option<BackendConfigEntry> {
|
||||
let configs = self.configs.read().await;
|
||||
configs.get(id).cloned()
|
||||
}
|
||||
|
||||
pub async fn update_settings(
|
||||
&self,
|
||||
id: &str,
|
||||
settings: serde_json::Value,
|
||||
enabled: Option<bool>,
|
||||
) -> Result<Option<BackendConfigEntry>, std::io::Error> {
|
||||
let mut configs = self.configs.write().await;
|
||||
let entry = configs.get_mut(id);
|
||||
let Some(entry) = entry else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
entry.settings = settings;
|
||||
if let Some(enabled) = enabled {
|
||||
entry.enabled = enabled;
|
||||
}
|
||||
|
||||
let updated = entry.clone();
|
||||
drop(configs);
|
||||
self.save_to_disk().await?;
|
||||
Ok(Some(updated))
|
||||
}
|
||||
}
|
||||
|
||||
pub type SharedBackendConfigStore = Arc<BackendConfigStore>;
|
||||
@@ -37,6 +37,7 @@ pub mod agents;
|
||||
pub mod ai_providers;
|
||||
pub mod api;
|
||||
pub mod backend;
|
||||
pub mod backend_config;
|
||||
pub mod config;
|
||||
pub mod library;
|
||||
pub mod mcp;
|
||||
|
||||
Reference in New Issue
Block a user