- remote_machines table in sessions.db (id, label, url, token, auto_connect, spki_pins as JSON array, created_at, updated_at) - session/machines.rs: save/load/delete/update_pins CRUD operations - RemoteManager: set_session_db() + load_from_db() for startup restoration - All mutations persist: add_machine, remove_machine, add_spki_pin, remove_spki_pin, TOFU auto-store — pins survive restart - 197 cargo tests passing, 0 warnings
438 lines
18 KiB
Rust
438 lines
18 KiB
Rust
mod btmsg;
|
|
mod bttask;
|
|
mod commands;
|
|
pub mod error;
|
|
mod ctx;
|
|
mod event_sink;
|
|
mod fs_watcher;
|
|
mod groups;
|
|
mod memora;
|
|
mod notifications;
|
|
mod plugins;
|
|
mod pty;
|
|
mod secrets;
|
|
mod remote;
|
|
mod search;
|
|
mod sidecar;
|
|
mod session;
|
|
mod telemetry;
|
|
mod watcher;
|
|
|
|
use agor_core::config::AppConfig;
|
|
use event_sink::TauriEventSink;
|
|
use pty::PtyManager;
|
|
use remote::RemoteManager;
|
|
use session::SessionDb;
|
|
use sidecar::{SidecarConfig, SidecarManager};
|
|
use fs_watcher::ProjectFsWatcher;
|
|
use watcher::FileWatcherManager;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use tauri::Manager;
|
|
|
|
/// Pre-dispatch hook for agent task middleware (budget enforcement, branch policy, etc.)
|
|
/// Returns Ok(()) to allow dispatch, Err(reason) to block it.
|
|
pub type PreDispatchHook = Box<dyn Fn(&agor_core::sidecar::AgentQueryOptions) -> Result<(), String> + Send + Sync>;
|
|
|
|
pub(crate) struct AppState {
|
|
pub pty_manager: Arc<PtyManager>,
|
|
pub sidecar_manager: Arc<SidecarManager>,
|
|
pub session_db: Arc<SessionDb>,
|
|
pub file_watcher: Arc<FileWatcherManager>,
|
|
pub fs_watcher: Arc<ProjectFsWatcher>,
|
|
pub ctx_db: Arc<ctx::CtxDb>,
|
|
pub memora_db: Arc<memora::MemoraDb>,
|
|
pub remote_manager: Arc<RemoteManager>,
|
|
pub search_db: Arc<search::SearchDb>,
|
|
pub app_config: Arc<AppConfig>,
|
|
pub pre_dispatch_hooks: Vec<PreDispatchHook>,
|
|
_telemetry: telemetry::TelemetryGuard,
|
|
}
|
|
|
|
/// Install btmsg/bttask CLI tools to ~/.local/bin/ so agent subprocesses can find them.
|
|
/// Sources: bundled resources (production) or repo root (development).
|
|
/// Only overwrites if the source is newer or the destination doesn't exist.
|
|
fn install_cli_tools(resource_dir: &Path, dev_root: &Path) {
|
|
let bin_dir = dirs::home_dir()
|
|
.unwrap_or_default()
|
|
.join(".local")
|
|
.join("bin");
|
|
if let Err(e) = std::fs::create_dir_all(&bin_dir) {
|
|
log::warn!("Failed to create ~/.local/bin: {e}");
|
|
return;
|
|
}
|
|
|
|
for tool_name in &["btmsg", "bttask"] {
|
|
// Try resource dir first (production bundle), then dev repo root
|
|
let source = [
|
|
resource_dir.join(tool_name),
|
|
dev_root.join(tool_name),
|
|
]
|
|
.into_iter()
|
|
.find(|p| p.is_file());
|
|
|
|
let source = match source {
|
|
Some(p) => p,
|
|
None => {
|
|
log::warn!("CLI tool '{tool_name}' not found in resources or dev root");
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let dest = bin_dir.join(tool_name);
|
|
let should_install = if dest.exists() {
|
|
// Compare modification times — install if source is newer
|
|
match (source.metadata(), dest.metadata()) {
|
|
(Ok(sm), Ok(dm)) => {
|
|
sm.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH)
|
|
> dm.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH)
|
|
}
|
|
_ => true,
|
|
}
|
|
} else {
|
|
true
|
|
};
|
|
|
|
if should_install {
|
|
match std::fs::copy(&source, &dest) {
|
|
Ok(_) => {
|
|
// Ensure executable permission on Unix
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let _ = std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755));
|
|
}
|
|
log::info!("Installed {tool_name} to {}", dest.display());
|
|
}
|
|
Err(e) => log::warn!("Failed to install {tool_name}: {e}"),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Run `PRAGMA wal_checkpoint(TRUNCATE)` on a SQLite database to reclaim WAL file space.
|
|
/// Returns Ok(()) on success or Err with a diagnostic message.
|
|
pub(crate) fn checkpoint_wal(path: &Path) -> Result<(), String> {
|
|
use rusqlite::{Connection, OpenFlags};
|
|
|
|
if !path.exists() {
|
|
return Ok(()); // DB doesn't exist yet — nothing to checkpoint
|
|
}
|
|
|
|
let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)
|
|
.map_err(|e| format!("WAL checkpoint: failed to open {}: {e}", path.display()))?;
|
|
conn.query_row("PRAGMA busy_timeout = 5000", [], |_| Ok(()))
|
|
.map_err(|e| format!("WAL checkpoint: failed to set busy_timeout: {e}"))?;
|
|
conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
|
|
.map_err(|e| format!("WAL checkpoint failed on {}: {e}", path.display()))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Spawn a background task that checkpoints WAL on both databases every 5 minutes.
|
|
/// Uses `tauri::async_runtime::spawn` instead of `tokio::spawn` because this runs
|
|
/// during Tauri setup where the Tokio runtime may not be directly accessible.
|
|
fn spawn_wal_checkpoint_task(sessions_db_path: PathBuf, btmsg_db_path: PathBuf) {
|
|
tauri::async_runtime::spawn(async move {
|
|
let interval = std::time::Duration::from_secs(300);
|
|
loop {
|
|
tokio::time::sleep(interval).await;
|
|
for (label, path) in [("sessions.db", &sessions_db_path), ("btmsg.db", &btmsg_db_path)] {
|
|
match checkpoint_wal(path) {
|
|
Ok(()) => tracing::info!("WAL checkpoint completed for {label}"),
|
|
Err(e) => tracing::warn!("WAL checkpoint error for {label}: {e}"),
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
|
pub fn run() {
|
|
// Force dark GTK theme for native dialogs (file chooser, etc.)
|
|
std::env::set_var("GTK_THEME", "Adwaita:dark");
|
|
|
|
// Resolve all paths via AppConfig (respects AGOR_TEST_* env vars)
|
|
let app_config = AppConfig::from_env();
|
|
if app_config.is_test_mode() {
|
|
log::info!(
|
|
"Test mode enabled: data_dir={}, config_dir={}",
|
|
app_config.data_dir.display(),
|
|
app_config.config_dir.display()
|
|
);
|
|
}
|
|
|
|
// Initialize subsystem paths from AppConfig (before any db access)
|
|
btmsg::init(app_config.btmsg_db_path());
|
|
bttask::init(app_config.btmsg_db_path());
|
|
groups::init(app_config.groups_json_path());
|
|
|
|
// Initialize tracing + optional OTLP export (before any tracing macros)
|
|
let telemetry_guard = telemetry::init();
|
|
|
|
let app_config_arc = Arc::new(app_config);
|
|
|
|
tauri::Builder::default()
|
|
.invoke_handler(tauri::generate_handler![
|
|
// PTY
|
|
commands::pty::pty_spawn,
|
|
commands::pty::pty_write,
|
|
commands::pty::pty_resize,
|
|
commands::pty::pty_kill,
|
|
// Agent/sidecar
|
|
commands::agent::agent_query,
|
|
commands::agent::agent_stop,
|
|
commands::agent::agent_ready,
|
|
commands::agent::agent_restart,
|
|
commands::agent::agent_set_sandbox,
|
|
// File watcher
|
|
commands::watcher::file_watch,
|
|
commands::watcher::file_unwatch,
|
|
commands::watcher::file_read,
|
|
commands::watcher::fs_watch_project,
|
|
commands::watcher::fs_unwatch_project,
|
|
commands::watcher::fs_watcher_status,
|
|
// Session/layout/settings/SSH
|
|
commands::session::session_list,
|
|
commands::session::session_save,
|
|
commands::session::session_delete,
|
|
commands::session::session_update_title,
|
|
commands::session::session_touch,
|
|
commands::session::session_update_group,
|
|
commands::session::layout_save,
|
|
commands::session::layout_load,
|
|
commands::session::settings_get,
|
|
commands::session::settings_set,
|
|
commands::session::settings_list,
|
|
commands::session::ssh_session_list,
|
|
commands::session::ssh_session_save,
|
|
commands::session::ssh_session_delete,
|
|
// Agent persistence (messages, state, metrics, anchors)
|
|
commands::persistence::agent_messages_save,
|
|
commands::persistence::agent_messages_load,
|
|
commands::persistence::project_agent_state_save,
|
|
commands::persistence::project_agent_state_load,
|
|
commands::persistence::session_metric_save,
|
|
commands::persistence::session_metrics_load,
|
|
commands::persistence::session_anchors_save,
|
|
commands::persistence::session_anchors_load,
|
|
commands::persistence::session_anchor_delete,
|
|
commands::persistence::session_anchors_clear,
|
|
commands::persistence::session_anchor_update_type,
|
|
// ctx + Memora
|
|
commands::knowledge::ctx_init_db,
|
|
commands::knowledge::ctx_register_project,
|
|
commands::knowledge::ctx_get_context,
|
|
commands::knowledge::ctx_get_shared,
|
|
commands::knowledge::ctx_get_summaries,
|
|
commands::knowledge::ctx_search,
|
|
commands::knowledge::memora_available,
|
|
commands::knowledge::memora_list,
|
|
commands::knowledge::memora_search,
|
|
commands::knowledge::memora_get,
|
|
// Claude profiles/skills
|
|
commands::claude::claude_list_profiles,
|
|
commands::claude::claude_list_skills,
|
|
commands::claude::claude_read_skill,
|
|
// Groups
|
|
commands::groups::groups_load,
|
|
commands::groups::groups_save,
|
|
commands::groups::discover_markdown_files,
|
|
// File browser
|
|
commands::files::list_directory_children,
|
|
commands::files::read_file_content,
|
|
commands::files::write_file_content,
|
|
commands::files::pick_directory,
|
|
// Remote machines
|
|
commands::remote::remote_list,
|
|
commands::remote::remote_add,
|
|
commands::remote::remote_remove,
|
|
commands::remote::remote_connect,
|
|
commands::remote::remote_disconnect,
|
|
commands::remote::remote_agent_query,
|
|
commands::remote::remote_agent_stop,
|
|
commands::remote::remote_pty_spawn,
|
|
commands::remote::remote_pty_write,
|
|
commands::remote::remote_pty_resize,
|
|
commands::remote::remote_pty_kill,
|
|
commands::remote::remote_probe_spki,
|
|
commands::remote::remote_add_pin,
|
|
commands::remote::remote_remove_pin,
|
|
// btmsg (agent messenger)
|
|
commands::btmsg::btmsg_get_agents,
|
|
commands::btmsg::btmsg_unread_count,
|
|
commands::btmsg::btmsg_unread_messages,
|
|
commands::btmsg::btmsg_history,
|
|
commands::btmsg::btmsg_send,
|
|
commands::btmsg::btmsg_set_status,
|
|
commands::btmsg::btmsg_ensure_admin,
|
|
commands::btmsg::btmsg_all_feed,
|
|
commands::btmsg::btmsg_mark_read,
|
|
commands::btmsg::btmsg_get_channels,
|
|
commands::btmsg::btmsg_channel_messages,
|
|
commands::btmsg::btmsg_channel_send,
|
|
commands::btmsg::btmsg_create_channel,
|
|
commands::btmsg::btmsg_add_channel_member,
|
|
commands::btmsg::btmsg_register_agents,
|
|
// btmsg per-message acknowledgment
|
|
commands::btmsg::btmsg_unseen_messages,
|
|
commands::btmsg::btmsg_mark_seen,
|
|
commands::btmsg::btmsg_prune_seen,
|
|
// btmsg health monitoring
|
|
commands::btmsg::btmsg_record_heartbeat,
|
|
commands::btmsg::btmsg_get_stale_agents,
|
|
commands::btmsg::btmsg_get_agent_heartbeats,
|
|
commands::btmsg::btmsg_get_dead_letters,
|
|
commands::btmsg::btmsg_clear_dead_letters,
|
|
commands::btmsg::btmsg_clear_all_comms,
|
|
commands::btmsg::btmsg_queue_dead_letter,
|
|
// Audit log
|
|
commands::btmsg::audit_log_event,
|
|
commands::btmsg::audit_log_list,
|
|
commands::btmsg::audit_log_for_agent,
|
|
// bttask (task board)
|
|
commands::bttask::bttask_list,
|
|
commands::bttask::bttask_comments,
|
|
commands::bttask::bttask_update_status,
|
|
commands::bttask::bttask_add_comment,
|
|
commands::bttask::bttask_create,
|
|
commands::bttask::bttask_delete,
|
|
commands::bttask::bttask_review_queue_count,
|
|
// Search (FTS5)
|
|
commands::search::search_init,
|
|
commands::search::search_query,
|
|
commands::search::search_rebuild,
|
|
commands::search::search_index_message,
|
|
commands::search::search_index_task,
|
|
commands::search::search_index_btmsg,
|
|
// Notifications
|
|
commands::notifications::notify_desktop,
|
|
// Secrets (system keyring)
|
|
commands::secrets::secrets_store,
|
|
commands::secrets::secrets_get,
|
|
commands::secrets::secrets_delete,
|
|
commands::secrets::secrets_list,
|
|
commands::secrets::secrets_has_keyring,
|
|
commands::secrets::secrets_known_keys,
|
|
// Plugins
|
|
commands::plugins::plugins_discover,
|
|
commands::plugins::plugin_read_file,
|
|
// Misc
|
|
commands::misc::cli_get_group,
|
|
commands::misc::open_url,
|
|
commands::misc::is_test_mode,
|
|
commands::misc::frontend_log,
|
|
])
|
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
|
.plugin(tauri_plugin_dialog::init())
|
|
.setup(move |app| {
|
|
#[cfg(feature = "pro")]
|
|
app.handle().plugin(agor_pro::init())?;
|
|
// Note: tauri-plugin-log is NOT initialized here because telemetry::init()
|
|
// already sets up tracing-subscriber (which bridges the `log` crate via
|
|
// tracing's compatibility layer). Adding plugin-log would panic with
|
|
// "attempted to set a logger after the logging system was already initialized".
|
|
|
|
let config = app_config_arc.clone();
|
|
|
|
// Create TauriEventSink for core managers
|
|
let sink: Arc<dyn agor_core::event::EventSink> =
|
|
Arc::new(TauriEventSink(app.handle().clone()));
|
|
|
|
// Build sidecar config from Tauri paths
|
|
let resource_dir = app
|
|
.handle()
|
|
.path()
|
|
.resource_dir()
|
|
.unwrap_or_else(|e| {
|
|
log::warn!("Failed to resolve resource_dir: {e}");
|
|
std::path::PathBuf::new()
|
|
});
|
|
let dev_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.parent()
|
|
.unwrap()
|
|
.to_path_buf();
|
|
// Install btmsg/bttask CLI tools to ~/.local/bin/
|
|
if !config.is_test_mode() {
|
|
install_cli_tools(&resource_dir, &dev_root);
|
|
}
|
|
|
|
// Forward test mode env vars to sidecar processes
|
|
let mut env_overrides = std::collections::HashMap::new();
|
|
if config.is_test_mode() {
|
|
env_overrides.insert("AGOR_TEST".into(), "1".into());
|
|
if let Ok(v) = std::env::var("AGOR_TEST_DATA_DIR") {
|
|
env_overrides.insert("AGOR_TEST_DATA_DIR".into(), v);
|
|
}
|
|
if let Ok(v) = std::env::var("AGOR_TEST_CONFIG_DIR") {
|
|
env_overrides.insert("AGOR_TEST_CONFIG_DIR".into(), v);
|
|
}
|
|
}
|
|
|
|
let sidecar_config = SidecarConfig {
|
|
search_paths: vec![
|
|
resource_dir.join("sidecar"),
|
|
dev_root.join("sidecar"),
|
|
],
|
|
env_overrides,
|
|
sandbox: agor_core::sandbox::SandboxConfig::default(),
|
|
};
|
|
|
|
let pty_manager = Arc::new(PtyManager::new(sink.clone()));
|
|
let sidecar_manager = Arc::new(SidecarManager::new(sink, sidecar_config));
|
|
|
|
// Initialize session database using AppConfig data_dir
|
|
let session_db = Arc::new(
|
|
SessionDb::open(config.sessions_db_dir()).expect("Failed to open session database"),
|
|
);
|
|
|
|
let file_watcher = Arc::new(FileWatcherManager::new());
|
|
let fs_watcher = Arc::new(ProjectFsWatcher::new());
|
|
let ctx_db = Arc::new(ctx::CtxDb::new_with_path(config.ctx_db_path.clone()));
|
|
let memora_db = Arc::new(memora::MemoraDb::new_with_path(config.memora_db_path.clone()));
|
|
let mut remote_mgr = RemoteManager::new();
|
|
remote_mgr.set_session_db(session_db.clone());
|
|
let remote_manager = Arc::new(remote_mgr);
|
|
|
|
// Initialize FTS5 search database
|
|
let search_db_path = config.data_dir.join("agor").join("search.db");
|
|
let search_db = Arc::new(
|
|
search::SearchDb::open(&search_db_path).expect("Failed to open search database"),
|
|
);
|
|
|
|
// Start local sidecar
|
|
match sidecar_manager.start() {
|
|
Ok(()) => log::info!("Sidecar startup initiated"),
|
|
Err(e) => log::warn!("Sidecar startup failed (agent features unavailable): {e}"),
|
|
}
|
|
|
|
// Start periodic WAL checkpoint task (every 5 minutes)
|
|
let sessions_db_path = config.data_dir.join("sessions.db");
|
|
let btmsg_db_path = config.btmsg_db_path();
|
|
spawn_wal_checkpoint_task(sessions_db_path, btmsg_db_path);
|
|
|
|
// Load saved remote machines from database before managing state
|
|
let rm_clone = remote_manager.clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
rm_clone.load_from_db().await;
|
|
});
|
|
|
|
app.manage(AppState {
|
|
pty_manager,
|
|
sidecar_manager,
|
|
session_db,
|
|
file_watcher,
|
|
fs_watcher,
|
|
ctx_db,
|
|
memora_db,
|
|
remote_manager,
|
|
search_db,
|
|
app_config: config,
|
|
pre_dispatch_hooks: Vec::new(),
|
|
_telemetry: telemetry_guard,
|
|
});
|
|
|
|
Ok(())
|
|
})
|
|
.run(tauri::generate_context!())
|
|
.expect("error while running tauri application");
|
|
}
|