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
|
||||
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
|
||||
|
||||
# =============================================================================
|
||||
|
||||
10
INSTALL.md
10
INSTALL.md
@@ -391,8 +391,15 @@ git push -u origin HEAD:main
|
||||
|
||||
### 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`:
|
||||
- `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`
|
||||
|
||||
---
|
||||
@@ -424,6 +431,7 @@ PORT=3000
|
||||
# Default filesystem root for Open Agent (agent still has full system access)
|
||||
WORKING_DIR=/root
|
||||
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
|
||||
|
||||
# Auth (set DEV_MODE=false on real deployments)
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
setDefaultAIProvider,
|
||||
AIProvider,
|
||||
AIProviderTypeInfo,
|
||||
getSettings,
|
||||
updateLibraryRemote,
|
||||
} from '@/lib/api';
|
||||
import {
|
||||
Server,
|
||||
@@ -28,6 +30,8 @@ import {
|
||||
ExternalLink,
|
||||
Loader,
|
||||
Key,
|
||||
Check,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { readSavedSettings, writeSavedSettings } from '@/lib/settings';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -92,6 +96,11 @@ export default function SettingsPage() {
|
||||
enabled?: boolean;
|
||||
}>({});
|
||||
|
||||
// Library remote edit state
|
||||
const [editingLibraryRemote, setEditingLibraryRemote] = useState(false);
|
||||
const [libraryRemoteValue, setLibraryRemoteValue] = useState('');
|
||||
const [savingLibraryRemote, setSavingLibraryRemote] = useState(false);
|
||||
|
||||
// SWR: fetch health status
|
||||
const { data: health, isLoading: healthLoading, mutate: mutateHealth } = useSWR(
|
||||
'health',
|
||||
@@ -113,6 +122,13 @@ export default function SettingsPage() {
|
||||
{ revalidateOnFocus: false, fallbackData: defaultProviderTypes }
|
||||
);
|
||||
|
||||
// SWR: fetch server settings
|
||||
const { data: serverSettings, mutate: mutateSettings } = useSWR(
|
||||
'settings',
|
||||
getSettings,
|
||||
{ revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
// Check if there are unsaved changes
|
||||
const hasUnsavedChanges = apiUrl !== originalValues.apiUrl;
|
||||
|
||||
@@ -278,6 +294,49 @@ export default function SettingsPage() {
|
||||
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 (
|
||||
<div className="min-h-screen flex flex-col items-center p-6">
|
||||
{/* Add Provider Modal */}
|
||||
@@ -515,7 +574,7 @@ export default function SettingsPage() {
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-white">Library</h2>
|
||||
<p className="text-xs text-white/40">
|
||||
Configuration library (server-managed)
|
||||
Git-based configuration library for skills, tools, and agents
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -529,17 +588,59 @@ export default function SettingsPage() {
|
||||
<Loader className="h-4 w-4 animate-spin text-white/40" />
|
||||
<span className="text-sm text-white/40">Loading...</span>
|
||||
</div>
|
||||
) : health?.library_remote ? (
|
||||
<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">
|
||||
{health.library_remote}
|
||||
) : editingLibraryRemote ? (
|
||||
<div className="space-y-2">
|
||||
<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 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">
|
||||
Not configured
|
||||
<div
|
||||
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>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'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 = {
|
||||
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>
|
||||
<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>
|
||||
|
||||
<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 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>
|
||||
<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">
|
||||
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>
|
||||
<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">
|
||||
LIBRARY_REMOTE=git@github.com:your/library.git
|
||||
</div>
|
||||
<Link
|
||||
href="/settings"
|
||||
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>
|
||||
|
||||
@@ -63,10 +68,6 @@ export function LibraryUnavailable({ message }: LibraryUnavailableProps) {
|
||||
</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 && (
|
||||
<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');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -142,7 +142,11 @@ async fn ensure_library(
|
||||
state: &super::routes::AppState,
|
||||
headers: &HeaderMap,
|
||||
) -> 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(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
|
||||
@@ -31,6 +31,7 @@ pub mod opencode;
|
||||
mod providers;
|
||||
mod routes;
|
||||
pub mod secrets;
|
||||
pub mod settings;
|
||||
pub mod system;
|
||||
pub mod types;
|
||||
pub mod workspaces;
|
||||
|
||||
@@ -38,6 +38,7 @@ use super::mcp as mcp_api;
|
||||
use super::monitoring;
|
||||
use super::opencode as opencode_api;
|
||||
use super::secrets as secrets_api;
|
||||
use super::settings as settings_api;
|
||||
use super::system as system_api;
|
||||
use super::types::*;
|
||||
use super::workspaces as workspaces_api;
|
||||
@@ -69,6 +70,8 @@ pub struct AppState {
|
||||
pub secrets: Option<Arc<crate::secrets::SecretsStore>>,
|
||||
/// Console session pool for WebSocket reconnection
|
||||
pub console_pool: Arc<console::SessionPool>,
|
||||
/// Global settings store
|
||||
pub settings: Arc<crate::settings::SettingsStore>,
|
||||
}
|
||||
|
||||
/// Start the HTTP server.
|
||||
@@ -130,6 +133,9 @@ pub async fn serve(config: Config) -> anyhow::Result<()> {
|
||||
let console_pool = Arc::new(console::SessionPool::new());
|
||||
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
|
||||
{
|
||||
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)
|
||||
// Must be created before ControlHub so it can be passed to control sessions
|
||||
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_path = config.library_path.clone();
|
||||
let workspaces_clone = Arc::clone(&workspaces);
|
||||
@@ -220,6 +228,7 @@ pub async fn serve(config: Config) -> anyhow::Result<()> {
|
||||
pending_oauth,
|
||||
secrets,
|
||||
console_pool,
|
||||
settings,
|
||||
});
|
||||
|
||||
// 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())
|
||||
// Secrets management endpoints
|
||||
.nest("/api/secrets", secrets_api::routes())
|
||||
// Global settings endpoints
|
||||
.nest("/api/settings", settings_api::routes())
|
||||
// Desktop session management endpoints
|
||||
.nest("/api/desktop", desktop::routes())
|
||||
// System component management endpoints
|
||||
@@ -495,6 +506,8 @@ async fn health(State(state): State<Arc<AppState>>) -> Json<HealthResponse> {
|
||||
AuthMode::SingleTenant => "single_tenant",
|
||||
AuthMode::MultiUser => "multi_user",
|
||||
};
|
||||
// Read library_remote from settings store (persisted to disk)
|
||||
let library_remote = state.settings.get_library_remote().await;
|
||||
Json(HealthResponse {
|
||||
status: "ok".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_mode: auth_mode.to_string(),
|
||||
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.
|
||||
//! If set to empty string, ignores ~/.ssh/config (useful when the config specifies a non-existent key).
|
||||
//! 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,
|
||||
//! 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.
|
||||
/// Default: {working_dir}/.openagent/library
|
||||
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.
|
||||
@@ -446,12 +444,11 @@ impl Config {
|
||||
let context = ContextConfig::from_env();
|
||||
|
||||
// Library configuration
|
||||
// Note: library_remote is now managed via the settings module (persisted to disk)
|
||||
let library_path = std::env::var("LIBRARY_PATH")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| working_dir.join(".openagent/library"));
|
||||
|
||||
let library_remote = std::env::var("LIBRARY_REMOTE").ok();
|
||||
|
||||
Ok(Self {
|
||||
default_model,
|
||||
working_dir,
|
||||
@@ -467,7 +464,6 @@ impl Config {
|
||||
opencode_agent,
|
||||
opencode_permissive,
|
||||
library_path,
|
||||
library_remote,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -489,7 +485,6 @@ impl Config {
|
||||
opencode_agent: None,
|
||||
opencode_permissive: true,
|
||||
library_path,
|
||||
library_remote: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ pub mod nspawn;
|
||||
pub mod opencode;
|
||||
pub mod opencode_config;
|
||||
pub mod secrets;
|
||||
pub mod settings;
|
||||
pub mod task;
|
||||
pub mod tools;
|
||||
pub mod workspace;
|
||||
@@ -50,3 +51,4 @@ pub mod workspace;
|
||||
pub use ai_providers::{AIProvider, AIProviderStore, ProviderType};
|
||||
pub use config::Config;
|
||||
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