Add persistent settings storage for library_remote
Replace env-var-only LIBRARY_REMOTE configuration with disk-persisted settings. The setting can now be edited in the dashboard Settings page, with LIBRARY_REMOTE env var serving as initial default when no settings file exists. Changes: - Add src/settings.rs for settings storage with JSON persistence - Add src/api/settings.rs for settings API endpoints - Update dashboard Settings page with editable library remote field - Update library-unavailable component to link to Settings page - Update documentation to recommend Settings page method
This commit is contained in:
@@ -21,6 +21,8 @@ OPENCODE_PERMISSIVE=true
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
WORKING_DIR=/root
|
WORKING_DIR=/root
|
||||||
LIBRARY_PATH=/root/.openagent/library
|
LIBRARY_PATH=/root/.openagent/library
|
||||||
|
# Library remote URL - can also be configured via dashboard Settings page (preferred).
|
||||||
|
# This env var is used as the initial default when no settings file exists.
|
||||||
# LIBRARY_REMOTE=git@github.com:your-org/agent-library.git
|
# LIBRARY_REMOTE=git@github.com:your-org/agent-library.git
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
10
INSTALL.md
10
INSTALL.md
@@ -391,8 +391,15 @@ git push -u origin HEAD:main
|
|||||||
|
|
||||||
### 5.2 Configure Open Agent to use it
|
### 5.2 Configure Open Agent to use it
|
||||||
|
|
||||||
|
**Option A: Via Dashboard Settings (recommended)**
|
||||||
|
|
||||||
|
After starting Open Agent, go to **Settings** in the dashboard and set the Library Remote URL.
|
||||||
|
This is the preferred method as it persists the setting to disk and allows runtime updates without restart.
|
||||||
|
|
||||||
|
**Option B: Via environment variable (initial default)**
|
||||||
|
|
||||||
Set in `/etc/open_agent/open_agent.env`:
|
Set in `/etc/open_agent/open_agent.env`:
|
||||||
- `LIBRARY_REMOTE=git@github.com:<your-org>/<your-library-repo>.git`
|
- `LIBRARY_REMOTE=git@github.com:<your-org>/<your-library-repo>.git` (used as initial default if not configured in Settings)
|
||||||
- optional: `LIBRARY_PATH=/root/.openagent/library`
|
- optional: `LIBRARY_PATH=/root/.openagent/library`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -424,6 +431,7 @@ PORT=3000
|
|||||||
# Default filesystem root for Open Agent (agent still has full system access)
|
# Default filesystem root for Open Agent (agent still has full system access)
|
||||||
WORKING_DIR=/root
|
WORKING_DIR=/root
|
||||||
LIBRARY_PATH=/root/.openagent/library
|
LIBRARY_PATH=/root/.openagent/library
|
||||||
|
# Library remote (optional, can also be set via dashboard Settings page)
|
||||||
LIBRARY_REMOTE=git@github.com:<your-org>/<your-library-repo>.git
|
LIBRARY_REMOTE=git@github.com:<your-org>/<your-library-repo>.git
|
||||||
|
|
||||||
# Auth (set DEV_MODE=false on real deployments)
|
# Auth (set DEV_MODE=false on real deployments)
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
setDefaultAIProvider,
|
setDefaultAIProvider,
|
||||||
AIProvider,
|
AIProvider,
|
||||||
AIProviderTypeInfo,
|
AIProviderTypeInfo,
|
||||||
|
getSettings,
|
||||||
|
updateLibraryRemote,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import {
|
import {
|
||||||
Server,
|
Server,
|
||||||
@@ -28,6 +30,8 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
Loader,
|
Loader,
|
||||||
Key,
|
Key,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { readSavedSettings, writeSavedSettings } from '@/lib/settings';
|
import { readSavedSettings, writeSavedSettings } from '@/lib/settings';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -92,6 +96,11 @@ export default function SettingsPage() {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}>({});
|
}>({});
|
||||||
|
|
||||||
|
// Library remote edit state
|
||||||
|
const [editingLibraryRemote, setEditingLibraryRemote] = useState(false);
|
||||||
|
const [libraryRemoteValue, setLibraryRemoteValue] = useState('');
|
||||||
|
const [savingLibraryRemote, setSavingLibraryRemote] = useState(false);
|
||||||
|
|
||||||
// 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',
|
||||||
@@ -113,6 +122,13 @@ export default function SettingsPage() {
|
|||||||
{ revalidateOnFocus: false, fallbackData: defaultProviderTypes }
|
{ revalidateOnFocus: false, fallbackData: defaultProviderTypes }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// SWR: fetch server settings
|
||||||
|
const { data: serverSettings, mutate: mutateSettings } = useSWR(
|
||||||
|
'settings',
|
||||||
|
getSettings,
|
||||||
|
{ revalidateOnFocus: false }
|
||||||
|
);
|
||||||
|
|
||||||
// Check if there are unsaved changes
|
// Check if there are unsaved changes
|
||||||
const hasUnsavedChanges = apiUrl !== originalValues.apiUrl;
|
const hasUnsavedChanges = apiUrl !== originalValues.apiUrl;
|
||||||
|
|
||||||
@@ -278,6 +294,49 @@ export default function SettingsPage() {
|
|||||||
setEditForm({});
|
setEditForm({});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Library remote handlers
|
||||||
|
const handleStartEditLibraryRemote = () => {
|
||||||
|
setLibraryRemoteValue(serverSettings?.library_remote || '');
|
||||||
|
setEditingLibraryRemote(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEditLibraryRemote = () => {
|
||||||
|
setEditingLibraryRemote(false);
|
||||||
|
setLibraryRemoteValue('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveLibraryRemote = async () => {
|
||||||
|
setSavingLibraryRemote(true);
|
||||||
|
try {
|
||||||
|
const trimmed = libraryRemoteValue.trim();
|
||||||
|
const result = await updateLibraryRemote(trimmed || null);
|
||||||
|
|
||||||
|
// Revalidate both settings and health (which also exposes library_remote)
|
||||||
|
mutateSettings();
|
||||||
|
mutateHealth();
|
||||||
|
|
||||||
|
setEditingLibraryRemote(false);
|
||||||
|
|
||||||
|
if (result.library_reinitialized) {
|
||||||
|
if (result.library_error) {
|
||||||
|
toast.error(`Library saved but failed to initialize: ${result.library_error}`);
|
||||||
|
} else if (result.library_remote) {
|
||||||
|
toast.success('Library remote updated and reinitialized');
|
||||||
|
} else {
|
||||||
|
toast.success('Library remote cleared');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.success('Library remote saved (no change)');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
`Failed to save: ${err instanceof Error ? err.message : 'Unknown error'}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSavingLibraryRemote(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col items-center p-6">
|
<div className="min-h-screen flex flex-col items-center p-6">
|
||||||
{/* Add Provider Modal */}
|
{/* Add Provider Modal */}
|
||||||
@@ -515,7 +574,7 @@ export default function SettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-medium text-white">Library</h2>
|
<h2 className="text-sm font-medium text-white">Library</h2>
|
||||||
<p className="text-xs text-white/40">
|
<p className="text-xs text-white/40">
|
||||||
Configuration library (server-managed)
|
Git-based configuration library for skills, tools, and agents
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -529,17 +588,59 @@ export default function SettingsPage() {
|
|||||||
<Loader className="h-4 w-4 animate-spin text-white/40" />
|
<Loader className="h-4 w-4 animate-spin text-white/40" />
|
||||||
<span className="text-sm text-white/40">Loading...</span>
|
<span className="text-sm text-white/40">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
) : health?.library_remote ? (
|
) : editingLibraryRemote ? (
|
||||||
<div className="w-full rounded-lg border border-white/[0.06] bg-white/[0.01] px-3 py-2.5 text-sm text-white/70 font-mono">
|
<div className="space-y-2">
|
||||||
{health.library_remote}
|
<input
|
||||||
|
type="text"
|
||||||
|
value={libraryRemoteValue}
|
||||||
|
onChange={(e) => setLibraryRemoteValue(e.target.value)}
|
||||||
|
placeholder="git@github.com:your-org/agent-library.git"
|
||||||
|
className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2 text-sm text-white font-mono focus:outline-none focus:border-indigo-500/50"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSaveLibraryRemote();
|
||||||
|
if (e.key === 'Escape') handleCancelEditLibraryRemote();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSaveLibraryRemote}
|
||||||
|
disabled={savingLibraryRemote}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg bg-indigo-500 px-3 py-1.5 text-xs text-white hover:bg-indigo-600 transition-colors cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{savingLibraryRemote ? (
|
||||||
|
<Loader className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancelEditLibraryRemote}
|
||||||
|
disabled={savingLibraryRemote}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-white/[0.06] px-3 py-1.5 text-xs text-white/60 hover:bg-white/[0.04] transition-colors cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2.5 text-sm text-amber-400/80">
|
<div
|
||||||
Not configured
|
onClick={handleStartEditLibraryRemote}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-lg border px-3 py-2.5 text-sm font-mono cursor-pointer transition-colors',
|
||||||
|
serverSettings?.library_remote
|
||||||
|
? 'border-white/[0.06] bg-white/[0.01] text-white/70 hover:border-indigo-500/30 hover:bg-white/[0.02]'
|
||||||
|
: 'border-amber-500/20 bg-amber-500/5 text-amber-400/80 hover:border-amber-500/30 hover:bg-amber-500/10'
|
||||||
|
)}
|
||||||
|
title="Click to edit"
|
||||||
|
>
|
||||||
|
{serverSettings?.library_remote || 'Not configured'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="mt-1.5 text-xs text-white/30">
|
<p className="mt-1.5 text-xs text-white/30">
|
||||||
Set via <code className="text-white/50">LIBRARY_REMOTE</code> environment variable on the server.
|
Git remote URL for skills, tools, agents, and rules. Click to edit.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { GitBranch, AlertTriangle, ExternalLink, Terminal } from 'lucide-react';
|
import Link from 'next/link';
|
||||||
|
import { GitBranch, AlertTriangle, ExternalLink, Settings } from 'lucide-react';
|
||||||
|
|
||||||
type LibraryUnavailableProps = {
|
type LibraryUnavailableProps = {
|
||||||
message?: string | null;
|
message?: string | null;
|
||||||
@@ -22,22 +23,26 @@ export function LibraryUnavailable({ message }: LibraryUnavailableProps) {
|
|||||||
|
|
||||||
<h2 className="text-lg font-semibold text-white mb-2">Library Not Configured</h2>
|
<h2 className="text-lg font-semibold text-white mb-2">Library Not Configured</h2>
|
||||||
<p className="text-sm text-white/50 mb-6">
|
<p className="text-sm text-white/50 mb-6">
|
||||||
The configuration library is not set up on the server. Configure it to enable skills, commands, and templates.
|
The configuration library is not set up. Configure it to enable skills, commands, and templates.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="rounded-xl border border-white/[0.08] bg-white/[0.02] p-5 text-left space-y-4">
|
<div className="rounded-xl border border-white/[0.08] bg-white/[0.02] p-5 text-left space-y-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-indigo-500/10 flex-shrink-0">
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-indigo-500/10 flex-shrink-0">
|
||||||
<Terminal className="h-4 w-4 text-indigo-400" />
|
<Settings className="h-4 w-4 text-indigo-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-white mb-1">Server Configuration</h3>
|
<h3 className="text-sm font-medium text-white mb-1">Configure Library Remote</h3>
|
||||||
<p className="text-xs text-white/40 mb-2">
|
<p className="text-xs text-white/40 mb-2">
|
||||||
Set the <code className="text-white/60 bg-white/[0.05] px-1 py-0.5 rounded">LIBRARY_REMOTE</code> environment variable on your server:
|
Set the library remote URL in Settings to connect to your configuration repository.
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-lg bg-black/30 border border-white/[0.06] px-3 py-2 font-mono text-xs text-white/70 overflow-x-auto">
|
<Link
|
||||||
LIBRARY_REMOTE=git@github.com:your/library.git
|
href="/settings"
|
||||||
</div>
|
className="inline-flex items-center gap-1.5 text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||||
|
>
|
||||||
|
Go to Settings
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -63,10 +68,6 @@ export function LibraryUnavailable({ message }: LibraryUnavailableProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-6 text-xs text-white/30">
|
|
||||||
After configuring, restart the Open Agent server for changes to take effect.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{showDetails && (
|
{showDetails && (
|
||||||
<p className="mt-4 text-[11px] text-white/20">Details: {details}</p>
|
<p className="mt-4 text-[11px] text-white/20">Details: {details}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2778,3 +2778,40 @@ export async function updateSystemComponent(
|
|||||||
onError(e instanceof Error ? e.message : 'Unknown error');
|
onError(e instanceof Error ? e.message : 'Unknown error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Global Settings API
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface SettingsResponse {
|
||||||
|
library_remote: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLibraryRemoteResponse {
|
||||||
|
library_remote: string | null;
|
||||||
|
library_reinitialized: boolean;
|
||||||
|
library_error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all settings
|
||||||
|
export async function getSettings(): Promise<SettingsResponse> {
|
||||||
|
const res = await apiFetch('/api/settings');
|
||||||
|
if (!res.ok) throw new Error('Failed to get settings');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the library remote URL
|
||||||
|
export async function updateLibraryRemote(
|
||||||
|
libraryRemote: string | null
|
||||||
|
): Promise<UpdateLibraryRemoteResponse> {
|
||||||
|
const res = await apiFetch('/api/settings/library-remote', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ library_remote: libraryRemote }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(text || 'Failed to update library remote');
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ git remote set-url origin git@github.com:you/your-library.git
|
|||||||
git push -u origin main
|
git push -u origin main
|
||||||
```
|
```
|
||||||
|
|
||||||
Then set `LIBRARY_REMOTE` in your Open Agent config to point to your repo.
|
Then configure your library in Open Agent's **Settings** page (preferred) or set `LIBRARY_REMOTE` as an environment variable.
|
||||||
|
|
||||||
## Skills
|
## Skills
|
||||||
|
|
||||||
|
|||||||
@@ -142,7 +142,11 @@ async fn ensure_library(
|
|||||||
state: &super::routes::AppState,
|
state: &super::routes::AppState,
|
||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
) -> Result<Arc<LibraryStore>, (StatusCode, String)> {
|
) -> Result<Arc<LibraryStore>, (StatusCode, String)> {
|
||||||
let remote = extract_library_remote(headers).or_else(|| state.config.library_remote.clone());
|
// Check HTTP header override first, then fall back to settings store
|
||||||
|
let remote = match extract_library_remote(headers) {
|
||||||
|
Some(r) => Some(r),
|
||||||
|
None => state.settings.get_library_remote().await,
|
||||||
|
};
|
||||||
let remote = remote.ok_or_else(|| {
|
let remote = remote.ok_or_else(|| {
|
||||||
(
|
(
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ pub mod opencode;
|
|||||||
mod providers;
|
mod providers;
|
||||||
mod routes;
|
mod routes;
|
||||||
pub mod secrets;
|
pub mod secrets;
|
||||||
|
pub mod settings;
|
||||||
pub mod system;
|
pub mod system;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
pub mod workspaces;
|
pub mod workspaces;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ use super::mcp as mcp_api;
|
|||||||
use super::monitoring;
|
use super::monitoring;
|
||||||
use super::opencode as opencode_api;
|
use super::opencode as opencode_api;
|
||||||
use super::secrets as secrets_api;
|
use super::secrets as secrets_api;
|
||||||
|
use super::settings as settings_api;
|
||||||
use super::system as system_api;
|
use super::system as system_api;
|
||||||
use super::types::*;
|
use super::types::*;
|
||||||
use super::workspaces as workspaces_api;
|
use super::workspaces as workspaces_api;
|
||||||
@@ -69,6 +70,8 @@ pub struct AppState {
|
|||||||
pub secrets: Option<Arc<crate::secrets::SecretsStore>>,
|
pub secrets: Option<Arc<crate::secrets::SecretsStore>>,
|
||||||
/// Console session pool for WebSocket reconnection
|
/// Console session pool for WebSocket reconnection
|
||||||
pub console_pool: Arc<console::SessionPool>,
|
pub console_pool: Arc<console::SessionPool>,
|
||||||
|
/// Global settings store
|
||||||
|
pub settings: Arc<crate::settings::SettingsStore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start the HTTP server.
|
/// Start the HTTP server.
|
||||||
@@ -130,6 +133,9 @@ pub async fn serve(config: Config) -> anyhow::Result<()> {
|
|||||||
let console_pool = Arc::new(console::SessionPool::new());
|
let console_pool = Arc::new(console::SessionPool::new());
|
||||||
Arc::clone(&console_pool).start_cleanup_task();
|
Arc::clone(&console_pool).start_cleanup_task();
|
||||||
|
|
||||||
|
// Initialize global settings store
|
||||||
|
let settings = Arc::new(crate::settings::SettingsStore::new(&config.working_dir).await);
|
||||||
|
|
||||||
// Start background OpenCode session cleanup task
|
// Start background OpenCode session cleanup task
|
||||||
{
|
{
|
||||||
let opencode_base_url = std::env::var("OPENCODE_BASE_URL")
|
let opencode_base_url = std::env::var("OPENCODE_BASE_URL")
|
||||||
@@ -142,7 +148,9 @@ pub async fn serve(config: Config) -> anyhow::Result<()> {
|
|||||||
// Initialize configuration library (optional - can also be configured at runtime)
|
// Initialize configuration library (optional - can also be configured at runtime)
|
||||||
// Must be created before ControlHub so it can be passed to control sessions
|
// Must be created before ControlHub so it can be passed to control sessions
|
||||||
let library: library_api::SharedLibrary = Arc::new(RwLock::new(None));
|
let library: library_api::SharedLibrary = Arc::new(RwLock::new(None));
|
||||||
if let Some(library_remote) = config.library_remote.clone() {
|
// Read library_remote from settings (which falls back to env var if not configured)
|
||||||
|
let library_remote = settings.get_library_remote().await;
|
||||||
|
if let Some(library_remote) = library_remote {
|
||||||
let library_clone = Arc::clone(&library);
|
let library_clone = Arc::clone(&library);
|
||||||
let library_path = config.library_path.clone();
|
let library_path = config.library_path.clone();
|
||||||
let workspaces_clone = Arc::clone(&workspaces);
|
let workspaces_clone = Arc::clone(&workspaces);
|
||||||
@@ -220,6 +228,7 @@ pub async fn serve(config: Config) -> anyhow::Result<()> {
|
|||||||
pending_oauth,
|
pending_oauth,
|
||||||
secrets,
|
secrets,
|
||||||
console_pool,
|
console_pool,
|
||||||
|
settings,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start background desktop session cleanup task
|
// Start background desktop session cleanup task
|
||||||
@@ -388,6 +397,8 @@ pub async fn serve(config: Config) -> anyhow::Result<()> {
|
|||||||
.nest("/api/ai/providers", ai_providers_api::routes())
|
.nest("/api/ai/providers", ai_providers_api::routes())
|
||||||
// Secrets management endpoints
|
// Secrets management endpoints
|
||||||
.nest("/api/secrets", secrets_api::routes())
|
.nest("/api/secrets", secrets_api::routes())
|
||||||
|
// Global settings endpoints
|
||||||
|
.nest("/api/settings", settings_api::routes())
|
||||||
// Desktop session management endpoints
|
// Desktop session management endpoints
|
||||||
.nest("/api/desktop", desktop::routes())
|
.nest("/api/desktop", desktop::routes())
|
||||||
// System component management endpoints
|
// System component management endpoints
|
||||||
@@ -495,6 +506,8 @@ async fn health(State(state): State<Arc<AppState>>) -> Json<HealthResponse> {
|
|||||||
AuthMode::SingleTenant => "single_tenant",
|
AuthMode::SingleTenant => "single_tenant",
|
||||||
AuthMode::MultiUser => "multi_user",
|
AuthMode::MultiUser => "multi_user",
|
||||||
};
|
};
|
||||||
|
// Read library_remote from settings store (persisted to disk)
|
||||||
|
let library_remote = state.settings.get_library_remote().await;
|
||||||
Json(HealthResponse {
|
Json(HealthResponse {
|
||||||
status: "ok".to_string(),
|
status: "ok".to_string(),
|
||||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
@@ -502,7 +515,7 @@ async fn health(State(state): State<Arc<AppState>>) -> Json<HealthResponse> {
|
|||||||
auth_required: state.config.auth.auth_required(state.config.dev_mode),
|
auth_required: state.config.auth.auth_required(state.config.dev_mode),
|
||||||
auth_mode: auth_mode.to_string(),
|
auth_mode: auth_mode.to_string(),
|
||||||
max_iterations: state.config.max_iterations,
|
max_iterations: state.config.max_iterations,
|
||||||
library_remote: state.config.library_remote.clone(),
|
library_remote,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
184
src/api/settings.rs
Normal file
184
src/api/settings.rs
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
//! API endpoints for global settings management.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::StatusCode,
|
||||||
|
response::Json,
|
||||||
|
routing::{get, put},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::settings::Settings;
|
||||||
|
use crate::workspace;
|
||||||
|
|
||||||
|
use super::routes::AppState;
|
||||||
|
|
||||||
|
/// Create the settings API routes.
|
||||||
|
pub fn routes() -> Router<Arc<AppState>> {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(get_settings).put(update_settings))
|
||||||
|
.route("/library-remote", put(update_library_remote))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response for settings endpoints.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SettingsResponse {
|
||||||
|
pub library_remote: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Settings> for SettingsResponse {
|
||||||
|
fn from(settings: Settings) -> Self {
|
||||||
|
Self {
|
||||||
|
library_remote: settings.library_remote,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request to update all settings.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateSettingsRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
pub library_remote: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request to update library remote specifically.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateLibraryRemoteRequest {
|
||||||
|
/// Git remote URL. Set to null or empty string to clear.
|
||||||
|
pub library_remote: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response after updating library remote.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct UpdateLibraryRemoteResponse {
|
||||||
|
pub library_remote: Option<String>,
|
||||||
|
/// Whether the library was reinitialized.
|
||||||
|
pub library_reinitialized: bool,
|
||||||
|
/// Error message if library initialization failed.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub library_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/settings
|
||||||
|
/// Get all settings.
|
||||||
|
async fn get_settings(State(state): State<Arc<AppState>>) -> Json<SettingsResponse> {
|
||||||
|
let settings = state.settings.get().await;
|
||||||
|
Json(settings.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/settings
|
||||||
|
/// Update all settings.
|
||||||
|
async fn update_settings(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(req): Json<UpdateSettingsRequest>,
|
||||||
|
) -> Result<Json<SettingsResponse>, (StatusCode, String)> {
|
||||||
|
let new_settings = Settings {
|
||||||
|
library_remote: req.library_remote,
|
||||||
|
};
|
||||||
|
|
||||||
|
state
|
||||||
|
.settings
|
||||||
|
.update(new_settings.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(new_settings.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/settings/library-remote
|
||||||
|
/// Update the library remote URL and optionally reinitialize the library.
|
||||||
|
async fn update_library_remote(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(req): Json<UpdateLibraryRemoteRequest>,
|
||||||
|
) -> Result<Json<UpdateLibraryRemoteResponse>, (StatusCode, String)> {
|
||||||
|
// Normalize empty string to None
|
||||||
|
let new_remote = req.library_remote.filter(|s| !s.trim().is_empty());
|
||||||
|
|
||||||
|
// Update the setting
|
||||||
|
let previous = state
|
||||||
|
.settings
|
||||||
|
.set_library_remote(new_remote.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
// If the value actually changed, reinitialize the library
|
||||||
|
let (library_reinitialized, library_error) = if previous.is_some() {
|
||||||
|
if let Some(ref remote) = new_remote {
|
||||||
|
// Reinitialize with new remote
|
||||||
|
match reinitialize_library(&state, remote).await {
|
||||||
|
Ok(()) => (true, None),
|
||||||
|
Err(e) => (false, Some(e)),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Clear the library
|
||||||
|
*state.library.write().await = None;
|
||||||
|
tracing::info!("Library cleared (remote set to None)");
|
||||||
|
(true, None)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No change in value
|
||||||
|
(false, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(UpdateLibraryRemoteResponse {
|
||||||
|
library_remote: new_remote,
|
||||||
|
library_reinitialized,
|
||||||
|
library_error,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reinitialize the library with a new remote URL.
|
||||||
|
async fn reinitialize_library(state: &Arc<AppState>, remote: &str) -> Result<(), String> {
|
||||||
|
let library_path = state.config.library_path.clone();
|
||||||
|
|
||||||
|
match crate::library::LibraryStore::new(library_path, remote).await {
|
||||||
|
Ok(store) => {
|
||||||
|
// Sync OpenCode plugins
|
||||||
|
if let Ok(plugins) = store.get_plugins().await {
|
||||||
|
if let Err(e) = crate::opencode_config::sync_global_plugins(&plugins).await {
|
||||||
|
tracing::warn!("Failed to sync OpenCode plugins: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Configuration library reinitialized from {}", remote);
|
||||||
|
let library = Arc::new(store);
|
||||||
|
*state.library.write().await = Some(Arc::clone(&library));
|
||||||
|
|
||||||
|
// Sync skills/tools to all workspaces
|
||||||
|
let workspaces = state.workspaces.list().await;
|
||||||
|
for ws in workspaces {
|
||||||
|
let is_default_host = ws.id == workspace::DEFAULT_WORKSPACE_ID
|
||||||
|
&& ws.workspace_type == workspace::WorkspaceType::Host;
|
||||||
|
|
||||||
|
if is_default_host || !ws.skills.is_empty() {
|
||||||
|
if let Err(e) = workspace::sync_workspace_skills(&ws, &library).await {
|
||||||
|
tracing::warn!(
|
||||||
|
workspace = %ws.name,
|
||||||
|
error = %e,
|
||||||
|
"Failed to sync skills after library reinit"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_default_host || !ws.tools.is_empty() {
|
||||||
|
if let Err(e) = workspace::sync_workspace_tools(&ws, &library).await {
|
||||||
|
tracing::warn!(
|
||||||
|
workspace = %ws.name,
|
||||||
|
error = %e,
|
||||||
|
"Failed to sync tools after library reinit"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to reinitialize library from {}: {}", remote, e);
|
||||||
|
Err(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@
|
|||||||
//! - `LIBRARY_GIT_SSH_KEY` - Optional. SSH key path for library git operations. If set to a path, uses that key.
|
//! - `LIBRARY_GIT_SSH_KEY` - Optional. SSH key path for library git operations. If set to a path, uses that key.
|
||||||
//! If set to empty string, ignores ~/.ssh/config (useful when the config specifies a non-existent key).
|
//! If set to empty string, ignores ~/.ssh/config (useful when the config specifies a non-existent key).
|
||||||
//! If unset, uses default SSH behavior.
|
//! If unset, uses default SSH behavior.
|
||||||
|
//! - `LIBRARY_REMOTE` - Optional. Initial library remote URL (can be changed via Settings in the dashboard).
|
||||||
|
//! This environment variable is used as the initial default when no settings file exists.
|
||||||
//!
|
//!
|
||||||
//! Note: The agent has **full system access**. It can read/write any file, execute any command,
|
//! Note: The agent has **full system access**. It can read/write any file, execute any command,
|
||||||
//! and search anywhere on the machine. The `WORKING_DIR` is just the default for relative paths.
|
//! and search anywhere on the machine. The `WORKING_DIR` is just the default for relative paths.
|
||||||
@@ -221,10 +223,6 @@ pub struct Config {
|
|||||||
/// Path to the configuration library git repo.
|
/// Path to the configuration library git repo.
|
||||||
/// Default: {working_dir}/.openagent/library
|
/// Default: {working_dir}/.openagent/library
|
||||||
pub library_path: PathBuf,
|
pub library_path: PathBuf,
|
||||||
|
|
||||||
/// Git remote URL for the configuration library.
|
|
||||||
/// Set via LIBRARY_REMOTE env var. Runtime settings can override this.
|
|
||||||
pub library_remote: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// API auth configuration.
|
/// API auth configuration.
|
||||||
@@ -446,12 +444,11 @@ impl Config {
|
|||||||
let context = ContextConfig::from_env();
|
let context = ContextConfig::from_env();
|
||||||
|
|
||||||
// Library configuration
|
// Library configuration
|
||||||
|
// Note: library_remote is now managed via the settings module (persisted to disk)
|
||||||
let library_path = std::env::var("LIBRARY_PATH")
|
let library_path = std::env::var("LIBRARY_PATH")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.unwrap_or_else(|_| working_dir.join(".openagent/library"));
|
.unwrap_or_else(|_| working_dir.join(".openagent/library"));
|
||||||
|
|
||||||
let library_remote = std::env::var("LIBRARY_REMOTE").ok();
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
default_model,
|
default_model,
|
||||||
working_dir,
|
working_dir,
|
||||||
@@ -467,7 +464,6 @@ impl Config {
|
|||||||
opencode_agent,
|
opencode_agent,
|
||||||
opencode_permissive,
|
opencode_permissive,
|
||||||
library_path,
|
library_path,
|
||||||
library_remote,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,7 +485,6 @@ impl Config {
|
|||||||
opencode_agent: None,
|
opencode_agent: None,
|
||||||
opencode_permissive: true,
|
opencode_permissive: true,
|
||||||
library_path,
|
library_path,
|
||||||
library_remote: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ pub mod nspawn;
|
|||||||
pub mod opencode;
|
pub mod opencode;
|
||||||
pub mod opencode_config;
|
pub mod opencode_config;
|
||||||
pub mod secrets;
|
pub mod secrets;
|
||||||
|
pub mod settings;
|
||||||
pub mod task;
|
pub mod task;
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
@@ -50,3 +51,4 @@ pub mod workspace;
|
|||||||
pub use ai_providers::{AIProvider, AIProviderStore, ProviderType};
|
pub use ai_providers::{AIProvider, AIProviderStore, ProviderType};
|
||||||
pub use config::Config;
|
pub use config::Config;
|
||||||
pub use opencode_config::{OpenCodeConnection, OpenCodeStore};
|
pub use opencode_config::{OpenCodeConnection, OpenCodeStore};
|
||||||
|
pub use settings::{Settings, SettingsStore};
|
||||||
|
|||||||
134
src/settings.rs
Normal file
134
src/settings.rs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
//! Global settings storage.
|
||||||
|
//!
|
||||||
|
//! Persists user-configurable settings to disk at `{working_dir}/.openagent/settings.json`.
|
||||||
|
//! Environment variables are used as initial defaults when no settings file exists.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
/// Global application settings.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Settings {
|
||||||
|
/// Git remote URL for the configuration library.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub library_remote: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory store for global settings with disk persistence.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SettingsStore {
|
||||||
|
settings: RwLock<Settings>,
|
||||||
|
storage_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SettingsStore {
|
||||||
|
/// Create a new settings store, loading from disk if available.
|
||||||
|
///
|
||||||
|
/// If no settings file exists, uses environment variables as defaults:
|
||||||
|
/// - `LIBRARY_REMOTE` - Git remote URL for the configuration library
|
||||||
|
pub async fn new(working_dir: &PathBuf) -> Self {
|
||||||
|
let storage_path = working_dir.join(".openagent/settings.json");
|
||||||
|
|
||||||
|
let settings = if storage_path.exists() {
|
||||||
|
match Self::load_from_path(&storage_path) {
|
||||||
|
Ok(s) => {
|
||||||
|
tracing::info!("Loaded settings from {}", storage_path.display());
|
||||||
|
s
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Failed to load settings from {}: {}, using defaults",
|
||||||
|
storage_path.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
Self::defaults_from_env()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
"No settings file found at {}, using environment defaults",
|
||||||
|
storage_path.display()
|
||||||
|
);
|
||||||
|
Self::defaults_from_env()
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
settings: RwLock::new(settings),
|
||||||
|
storage_path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load settings from environment variables as initial defaults.
|
||||||
|
fn defaults_from_env() -> Settings {
|
||||||
|
Settings {
|
||||||
|
library_remote: std::env::var("LIBRARY_REMOTE").ok(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load settings from a file path.
|
||||||
|
fn load_from_path(path: &PathBuf) -> Result<Settings, std::io::Error> {
|
||||||
|
let contents = std::fs::read_to_string(path)?;
|
||||||
|
serde_json::from_str(&contents)
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save current settings to disk.
|
||||||
|
async fn save_to_disk(&self) -> Result<(), std::io::Error> {
|
||||||
|
let settings = self.settings.read().await;
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
if let Some(parent) = self.storage_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let contents = serde_json::to_string_pretty(&*settings)
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||||
|
|
||||||
|
std::fs::write(&self.storage_path, contents)?;
|
||||||
|
tracing::debug!("Saved settings to {}", self.storage_path.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a clone of the current settings.
|
||||||
|
pub async fn get(&self) -> Settings {
|
||||||
|
self.settings.read().await.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the library remote URL.
|
||||||
|
pub async fn get_library_remote(&self) -> Option<String> {
|
||||||
|
self.settings.read().await.library_remote.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the library remote URL.
|
||||||
|
///
|
||||||
|
/// Returns the previous value if it changed, or None if unchanged.
|
||||||
|
pub async fn set_library_remote(
|
||||||
|
&self,
|
||||||
|
remote: Option<String>,
|
||||||
|
) -> Result<Option<String>, std::io::Error> {
|
||||||
|
let mut settings = self.settings.write().await;
|
||||||
|
let previous = settings.library_remote.clone();
|
||||||
|
|
||||||
|
if previous != remote {
|
||||||
|
settings.library_remote = remote;
|
||||||
|
drop(settings); // Release lock before saving
|
||||||
|
self.save_to_disk().await?;
|
||||||
|
Ok(previous)
|
||||||
|
} else {
|
||||||
|
Ok(None) // No change
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update multiple settings at once.
|
||||||
|
pub async fn update(&self, new_settings: Settings) -> Result<(), std::io::Error> {
|
||||||
|
let mut settings = self.settings.write().await;
|
||||||
|
*settings = new_settings;
|
||||||
|
drop(settings);
|
||||||
|
self.save_to_disk().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared settings store wrapped in Arc for concurrent access.
|
||||||
|
pub type SharedSettingsStore = Arc<SettingsStore>;
|
||||||
Reference in New Issue
Block a user