feat: add backend config settings and tests
This commit is contained in:
@@ -3109,6 +3109,7 @@ export default function ControlClient() {
|
|||||||
workspaceId?: string;
|
workspaceId?: string;
|
||||||
agent?: string;
|
agent?: string;
|
||||||
modelOverride?: string;
|
modelOverride?: string;
|
||||||
|
backend?: string;
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
setMissionLoading(true);
|
setMissionLoading(true);
|
||||||
@@ -3116,6 +3117,7 @@ export default function ControlClient() {
|
|||||||
workspaceId: options?.workspaceId,
|
workspaceId: options?.workspaceId,
|
||||||
agent: options?.agent,
|
agent: options?.agent,
|
||||||
modelOverride: options?.modelOverride,
|
modelOverride: options?.modelOverride,
|
||||||
|
backend: options?.backend,
|
||||||
});
|
});
|
||||||
pendingMissionNavRef.current = mission.id;
|
pendingMissionNavRef.current = mission.id;
|
||||||
router.replace(`/control?mission=${mission.id}`, { scroll: false });
|
router.replace(`/control?mission=${mission.id}`, { scroll: false });
|
||||||
|
|||||||
@@ -46,12 +46,13 @@ export default function OverviewPage() {
|
|||||||
const isActive = (stats?.active_tasks ?? 0) > 0;
|
const isActive = (stats?.active_tasks ?? 0) > 0;
|
||||||
|
|
||||||
const handleNewMission = useCallback(
|
const handleNewMission = useCallback(
|
||||||
async (options?: { workspaceId?: string; agent?: string }) => {
|
async (options?: { workspaceId?: string; agent?: string; backend?: string }) => {
|
||||||
try {
|
try {
|
||||||
setCreatingMission(true);
|
setCreatingMission(true);
|
||||||
const mission = await createMission({
|
const mission = await createMission({
|
||||||
workspaceId: options?.workspaceId,
|
workspaceId: options?.workspaceId,
|
||||||
agent: options?.agent,
|
agent: options?.agent,
|
||||||
|
backend: options?.backend,
|
||||||
});
|
});
|
||||||
toast.success('New mission created');
|
toast.success('New mission created');
|
||||||
router.push(`/control?mission=${mission.id}`);
|
router.push(`/control?mission=${mission.id}`);
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import {
|
|||||||
AIProviderTypeInfo,
|
AIProviderTypeInfo,
|
||||||
getSettings,
|
getSettings,
|
||||||
updateLibraryRemote,
|
updateLibraryRemote,
|
||||||
|
listBackends,
|
||||||
|
getBackendConfig,
|
||||||
|
updateBackendConfig,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import {
|
import {
|
||||||
Server,
|
Server,
|
||||||
@@ -101,6 +104,22 @@ export default function SettingsPage() {
|
|||||||
const [libraryRemoteValue, setLibraryRemoteValue] = useState('');
|
const [libraryRemoteValue, setLibraryRemoteValue] = useState('');
|
||||||
const [savingLibraryRemote, setSavingLibraryRemote] = useState(false);
|
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
|
// SWR: fetch health status
|
||||||
const { data: health, isLoading: healthLoading, mutate: mutateHealth } = useSWR(
|
const { data: health, isLoading: healthLoading, mutate: mutateHealth } = useSWR(
|
||||||
'health',
|
'health',
|
||||||
@@ -129,6 +148,26 @@ export default function SettingsPage() {
|
|||||||
{ revalidateOnFocus: false }
|
{ 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
|
// Check if there are unsaved changes
|
||||||
const hasUnsavedChanges = apiUrl !== originalValues.apiUrl;
|
const hasUnsavedChanges = apiUrl !== originalValues.apiUrl;
|
||||||
|
|
||||||
@@ -175,6 +214,28 @@ export default function SettingsPage() {
|
|||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [apiUrl]);
|
}, [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 = () => {
|
const handleSave = () => {
|
||||||
if (!validateUrl(apiUrl)) {
|
if (!validateUrl(apiUrl)) {
|
||||||
toast.error('Please fix validation errors before saving');
|
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) => {
|
const handleStartEdit = (provider: AIProvider) => {
|
||||||
setEditingProvider(provider.id);
|
setEditingProvider(provider.id);
|
||||||
setEditForm({
|
setEditForm({
|
||||||
@@ -565,6 +678,176 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Library Settings */}
|
||||||
<div className="rounded-xl bg-white/[0.02] border border-white/[0.04] p-5">
|
<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 items-center gap-3 mb-4">
|
||||||
|
|||||||
@@ -2874,12 +2874,13 @@ export async function getBackendConfig(backendId: string): Promise<BackendConfig
|
|||||||
// Update backend configuration
|
// Update backend configuration
|
||||||
export async function updateBackendConfig(
|
export async function updateBackendConfig(
|
||||||
backendId: string,
|
backendId: string,
|
||||||
settings: Record<string, unknown>
|
settings: Record<string, unknown>,
|
||||||
|
options?: { enabled?: boolean }
|
||||||
): Promise<{ ok: boolean; message?: string }> {
|
): Promise<{ ok: boolean; message?: string }> {
|
||||||
const res = await apiFetch(`/api/backends/${encodeURIComponent(backendId)}/config`, {
|
const res = await apiFetch(`/api/backends/${encodeURIComponent(backendId)}/config`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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');
|
if (!res.ok) throw new Error('Failed to update backend config');
|
||||||
return res.json();
|
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",
|
"title": "My Mission",
|
||||||
"workspace_id": "uuid",
|
"workspace_id": "uuid",
|
||||||
"agent": "code-reviewer",
|
"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).
|
**Response**: `Mission` object (see below).
|
||||||
|
|
||||||
## Load/Switch to a Mission
|
## 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",
|
"workspace_name": "my-workspace",
|
||||||
"agent": "code-reviewer",
|
"agent": "code-reviewer",
|
||||||
"model_override": null,
|
"model_override": null,
|
||||||
|
"backend": "opencode",
|
||||||
"history": [],
|
"history": [],
|
||||||
"created_at": "2025-01-13T10:00:00Z",
|
"created_at": "2025-01-13T10:00:00Z",
|
||||||
"updated_at": "2025-01-13T10:05: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.
|
**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
|
## Execute Command
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -111,43 +111,38 @@ pub async fn get_backend_config(
|
|||||||
let backend = registry
|
let backend = registry
|
||||||
.get(&id)
|
.get(&id)
|
||||||
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("Backend {} not found", id)))?;
|
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("Backend {} not found", id)))?;
|
||||||
|
drop(registry);
|
||||||
|
|
||||||
// Return backend-specific configuration
|
let config_entry = state
|
||||||
let settings = match id.as_str() {
|
.backend_configs
|
||||||
"opencode" => {
|
.get(&id)
|
||||||
let base_url = std::env::var("OPENCODE_BASE_URL")
|
.await
|
||||||
.unwrap_or_else(|_| "http://127.0.0.1:4096".to_string());
|
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("Backend {} not configured", id)))?;
|
||||||
let default_agent = std::env::var("OPENCODE_DEFAULT_AGENT").ok();
|
|
||||||
let permissive = std::env::var("OPENCODE_PERMISSIVE")
|
let mut settings = config_entry.settings.clone();
|
||||||
.map(|v| v == "true" || v == "1")
|
|
||||||
.unwrap_or(false);
|
if id == "claudecode" {
|
||||||
serde_json::json!({
|
let api_key_configured = if let Some(store) = state.secrets.as_ref() {
|
||||||
"base_url": base_url,
|
match store.list_secrets("claudecode").await {
|
||||||
"default_agent": default_agent,
|
Ok(secrets) => secrets.iter().any(|s| s.key == "api_key" && !s.is_expired),
|
||||||
"permissive": permissive,
|
Err(_) => false,
|
||||||
})
|
}
|
||||||
}
|
} else {
|
||||||
"claudecode" => {
|
false
|
||||||
// Check if Claude Code API key is configured
|
};
|
||||||
let api_key_configured = state
|
|
||||||
.secrets
|
let mut obj = settings.as_object().cloned().unwrap_or_default();
|
||||||
.as_ref()
|
obj.insert(
|
||||||
.map(|_s| {
|
"api_key_configured".to_string(),
|
||||||
// TODO: implement proper secret check
|
serde_json::Value::Bool(api_key_configured),
|
||||||
false
|
);
|
||||||
})
|
settings = serde_json::Value::Object(obj);
|
||||||
.unwrap_or(false);
|
}
|
||||||
serde_json::json!({
|
|
||||||
"api_key_configured": api_key_configured,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
_ => serde_json::json!({}),
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Json(BackendConfig {
|
Ok(Json(BackendConfig {
|
||||||
id: backend.id().to_string(),
|
id: backend.id().to_string(),
|
||||||
name: backend.name().to_string(),
|
name: backend.name().to_string(),
|
||||||
enabled: true,
|
enabled: config_entry.enabled,
|
||||||
settings,
|
settings,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -156,6 +151,7 @@ pub async fn get_backend_config(
|
|||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct UpdateBackendConfigRequest {
|
pub struct UpdateBackendConfigRequest {
|
||||||
pub settings: serde_json::Value,
|
pub settings: serde_json::Value,
|
||||||
|
pub enabled: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update backend configuration
|
/// Update backend configuration
|
||||||
@@ -163,18 +159,88 @@ pub async fn update_backend_config(
|
|||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Extension(_user): Extension<AuthUser>,
|
Extension(_user): Extension<AuthUser>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(_req): Json<UpdateBackendConfigRequest>,
|
Json(req): Json<UpdateBackendConfigRequest>,
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
let registry = state.backend_registry.read().await;
|
let registry = state.backend_registry.read().await;
|
||||||
if registry.get(&id).is_none() {
|
if registry.get(&id).is_none() {
|
||||||
return Err((StatusCode::NOT_FOUND, format!("Backend {} not found", id)));
|
return Err((StatusCode::NOT_FOUND, format!("Backend {} not found", id)));
|
||||||
}
|
}
|
||||||
|
drop(registry);
|
||||||
|
|
||||||
// Backend configuration is currently read from environment variables
|
let updated_settings = match id.as_str() {
|
||||||
// TODO: Implement persistent backend configuration storage
|
"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(Json(serde_json::json!({
|
||||||
"ok": true,
|
"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.
|
// Context for agent execution.
|
||||||
let mut ctx = AgentContext::new(config.clone(), working_dir_path);
|
let mut ctx = AgentContext::new(config.clone(), working_dir_path);
|
||||||
ctx.mission_control = mission_control;
|
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.frontend_tool_hub = Some(tool_hub);
|
||||||
ctx.control_status = Some(status);
|
ctx.control_status = Some(status);
|
||||||
ctx.cancel_token = Some(cancel);
|
ctx.cancel_token = Some(cancel);
|
||||||
@@ -3685,6 +3685,24 @@ async fn run_single_control_turn(
|
|||||||
ctx.mission_id = mission_id;
|
ctx.mission_id = mission_id;
|
||||||
ctx.mcp = Some(mcp);
|
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;
|
let result = root_agent.execute(&mut task, &ctx).await;
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use tokio::sync::{broadcast, mpsc, RwLock};
|
|||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::agents::{AgentContext, AgentRef, AgentResult};
|
use crate::agents::{AgentContext, AgentRef, AgentResult, TerminalReason};
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::mcp::McpRegistry;
|
use crate::mcp::McpRegistry;
|
||||||
use crate::task::{extract_deliverables, DeliverableSet};
|
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);
|
let mut ctx = AgentContext::new(config.clone(), mission_work_dir);
|
||||||
ctx.mission_control = mission_control;
|
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.frontend_tool_hub = Some(tool_hub);
|
||||||
ctx.control_status = Some(status);
|
ctx.control_status = Some(status);
|
||||||
ctx.cancel_token = Some(cancel);
|
ctx.cancel_token = Some(cancel);
|
||||||
@@ -511,6 +511,19 @@ async fn run_mission_turn(
|
|||||||
ctx.mission_id = Some(mission_id);
|
ctx.mission_id = Some(mission_id);
|
||||||
ctx.mcp = Some(mcp);
|
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;
|
let result = root_agent.execute(&mut task, &ctx).await;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
mission_id = %mission_id,
|
mission_id = %mission_id,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::agents::{AgentContext, AgentRef, OpenCodeAgent};
|
use crate::agents::{AgentContext, AgentRef, OpenCodeAgent};
|
||||||
use crate::backend::registry::BackendRegistry;
|
use crate::backend::registry::BackendRegistry;
|
||||||
|
use crate::backend_config::BackendConfigEntry;
|
||||||
use crate::config::{AuthMode, Config};
|
use crate::config::{AuthMode, Config};
|
||||||
use crate::mcp::McpRegistry;
|
use crate::mcp::McpRegistry;
|
||||||
use crate::workspace;
|
use crate::workspace;
|
||||||
@@ -76,16 +77,16 @@ pub struct AppState {
|
|||||||
pub settings: Arc<crate::settings::SettingsStore>,
|
pub settings: Arc<crate::settings::SettingsStore>,
|
||||||
/// Backend registry for multi-backend support
|
/// Backend registry for multi-backend support
|
||||||
pub backend_registry: Arc<RwLock<BackendRegistry>>,
|
pub backend_registry: Arc<RwLock<BackendRegistry>>,
|
||||||
|
/// Backend configuration store
|
||||||
|
pub backend_configs: Arc<crate::backend_config::BackendConfigStore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start the HTTP server.
|
/// Start the HTTP server.
|
||||||
pub async fn serve(config: Config) -> anyhow::Result<()> {
|
pub async fn serve(config: Config) -> anyhow::Result<()> {
|
||||||
|
let mut config = config;
|
||||||
// Start monitoring background collector early so clients get history immediately
|
// Start monitoring background collector early so clients get history immediately
|
||||||
monitoring::init_monitoring();
|
monitoring::init_monitoring();
|
||||||
|
|
||||||
// Always use OpenCode backend
|
|
||||||
let root_agent: AgentRef = Arc::new(OpenCodeAgent::new(config.clone()));
|
|
||||||
|
|
||||||
// Initialize MCP registry
|
// Initialize MCP registry
|
||||||
let mcp = Arc::new(McpRegistry::new(&config.working_dir).await);
|
let mcp = Arc::new(McpRegistry::new(&config.working_dir).await);
|
||||||
if let Err(e) = crate::opencode_config::ensure_global_config(&mcp).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
|
// Initialize global settings store
|
||||||
let settings = Arc::new(crate::settings::SettingsStore::new(&config.working_dir).await);
|
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
|
// Initialize backend registry with OpenCode and Claude Code backends
|
||||||
let opencode_base_url =
|
let opencode_base_url =
|
||||||
std::env::var("OPENCODE_BASE_URL").unwrap_or_else(|_| "http://127.0.0.1:4096".to_string());
|
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,
|
console_pool,
|
||||||
settings,
|
settings,
|
||||||
backend_registry,
|
backend_registry,
|
||||||
|
backend_configs,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start background desktop session cleanup task
|
// 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 ai_providers;
|
||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod backend;
|
pub mod backend;
|
||||||
|
pub mod backend_config;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
|
|||||||
Reference in New Issue
Block a user