diff --git a/Cargo.lock b/Cargo.lock index 1f7b6cbf..152ccb47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,6 +39,7 @@ dependencies = [ name = "agentd-cli" version = "0.8.3" dependencies = [ + "agentd", "agentd-client", "agentd-protocol", "anyhow", diff --git a/README.md b/README.md index b38656d0..24eb8064 100644 --- a/README.md +++ b/README.md @@ -65,11 +65,12 @@ Pin a version or change the directory with `CONSTRUCT_VERSION=v0.2.0` / ### 3. Start the daemon ```sh -constructd +construct daemon run ``` Leave this running. It owns sessions, persists state, and exposes the local IPC -socket used by clients. +socket used by clients. (`constructd` is a back-compat alias for the same +daemon — `constructd run` and `construct daemon run` are equivalent.) ### 4. Open the fleet TUI @@ -114,8 +115,8 @@ cargo build --workspace Debug binaries land in `target/debug/`: -- `target/debug/constructd` — daemon / session supervisor -- `target/debug/construct` — TUI and control CLI +- `target/debug/construct` — TUI, control CLI, **and the daemon** (`construct daemon run`) +- `target/debug/constructd` — standalone daemon binary; a thin back-compat alias for `construct daemon run` - `target/debug/construct-mcp` — MCP bridge for agents - `target/debug/construct-adapter-*` — harness adapters diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 2170f5dd..51938536 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -16,6 +16,10 @@ path = "src/main.rs" tempfile = "3" agentd-protocol = { workspace = true } agentd-client = { workspace = true } +# The daemon, as a library, so the unified `construct` binary can run it +# directly via the `daemon` subcommand (the standalone `constructd` binary is +# a thin shim over the same crate, kept as a back-compat alias). +agentd = { path = "../daemon" } tokio = { workspace = true, features = ["full"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index e52880aa..463f9c4d 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -17,7 +17,7 @@ use agentd_protocol::paths::Paths; #[derive(Debug, Parser)] #[command( name = "construct", - about = "construct: TUI client for constructd", + about = "construct: TUI client and daemon for the agent fleet", version )] struct Cli { @@ -32,6 +32,16 @@ struct Cli { enum Command { /// Launch the TUI (default). Tui, + /// Run the construct daemon (session supervisor + IPC server). + /// + /// This is the same daemon shipped as the standalone `constructd` + /// binary — `construct daemon run` and `constructd run` are + /// equivalent. One installed `construct` binary can run both the + /// client and the daemon. + Daemon { + #[command(subcommand)] + command: Option, + }, /// Print resolved paths. Paths, /// Ping the daemon. @@ -113,6 +123,18 @@ enum Command { }, } +/// Subcommands of `construct daemon`. Mirrors the standalone `constructd` +/// binary's CLI so the two entry points behave identically. +#[derive(Debug, Subcommand)] +enum DaemonCommand { + /// Run the daemon in the foreground (default). + Run, + /// Print resolved paths and exit. + Paths, + /// Print the embedded default config and exit. + DefaultConfig, +} + fn init_tracing() { use tracing_subscriber::{fmt, EnvFilter}; let filter = EnvFilter::try_from_default_env() @@ -123,12 +145,38 @@ fn init_tracing() { #[tokio::main] async fn main() -> Result<()> { - init_tracing(); let cli = Cli::parse(); + let command = cli.command.unwrap_or(Command::Tui); + + // Daemon mode runs the supervisor in-process via the shared `agentd` + // library — the same code path as the standalone `constructd` binary. + // Handled before the client tracing init so the daemon's verbose filter + // applies, and before socket discovery (the daemon *owns* the socket + // rather than connecting to it). The daemon's restart self-`exec()` + // replays this argv (`construct daemon run …`) verbatim, so picking up an + // upgraded binary keeps working. + if let Command::Daemon { command: daemon_cmd } = command { + agentd::init_tracing(); + return match daemon_cmd.unwrap_or(DaemonCommand::Run) { + DaemonCommand::Run => agentd::run(cli.socket).await, + DaemonCommand::Paths => { + agentd::print_paths(); + Ok(()) + } + DaemonCommand::DefaultConfig => { + println!("{}", agentd::DEFAULT_CONFIG_TOML); + Ok(()) + } + }; + } + + init_tracing(); let socket = cli.socket.unwrap_or_else(|| Paths::discover().socket()); - match cli.command.unwrap_or(Command::Tui) { + match command { Command::Tui => run_tui(socket).await, + // Handled above (early return); listed for match exhaustiveness. + Command::Daemon { .. } => unreachable!("daemon mode handled before this match"), Command::Paths => { let p = Paths::discover(); println!("config: {}", p.config_dir.display()); diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs new file mode 100644 index 00000000..019f08da --- /dev/null +++ b/crates/daemon/src/lib.rs @@ -0,0 +1,485 @@ +//! construct daemon (`constructd` / `construct daemon`): session supervisor and +//! IPC server. +//! +//! The daemon's entire runtime lives here as a library so it can be driven from +//! two binaries that share one code path: +//! +//! - `constructd` — the standalone daemon binary (thin shim in `main.rs`), +//! kept as a back-compat alias. +//! - `construct daemon …` — the unified `construct` binary's daemon subcommand +//! (see `crates/cli`), so a single installed binary can run both the TUI +//! client and the daemon. +//! +//! Both entry points call [`run`] after [`init_tracing`]. They each replay +//! their own argv on `daemon.restart` (see `session::request_daemon_restart`), +//! so the self-`exec()` restart path stays correct regardless of which name +//! launched the daemon. + +use anyhow::{Context, Result}; +use std::path::PathBuf; +use std::sync::Arc; + +mod adapter; +mod config; +mod loops; +mod remote; +mod remote_supervisor; +mod server; +mod session; +mod storage; +mod tunnel; +mod worktree; + +use agentd_protocol::paths::Paths; + +/// The embedded default config TOML, surfaced by the `default-config` +/// subcommand on both daemon entry points. +pub use config::DEFAULT_CONFIG_TOML; + +/// Install the daemon's tracing subscriber. Defaults to a verbose +/// daemon-oriented filter (`info,agentd=debug,agentd_protocol=info`) so daemon +/// logs are useful out of the box; `RUST_LOG` overrides it. Idempotent — +/// safe to call once from whichever binary owns the process. +pub fn init_tracing() { + use tracing_subscriber::{fmt, EnvFilter}; + let filter = EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_new("info,agentd=debug,agentd_protocol=info")) + .unwrap(); + let _ = fmt().with_env_filter(filter).with_target(false).try_init(); +} + +/// Print the resolved config/state/data/runtime paths plus the local web UI +/// URL. Shared by both daemon entry points' `paths` subcommand. +pub fn print_paths() { + let p = Paths::discover(); + println!("config: {}", p.config_dir.display()); + println!("state: {}", p.state_dir.display()); + println!("data: {}", p.data_dir.display()); + println!("runtime: {}", p.runtime_dir.display()); + println!("socket: {}", p.socket().display()); + println!("webui: {}", agentd_protocol::paths::local_webui_url()); +} + +/// Run the daemon in the foreground until a shutdown signal, a fatal server +/// error, or a `daemon.restart` request. `socket_override` replaces the +/// discovered IPC socket path when set. +pub async fn run(socket_override: Option) -> Result<()> { + // Capture the executable path now, before anything can replace + // the binary on disk. `/construct restart` re-`exec()`s this path; + // resolving it lazily at restart time is unreliable once an + // upgrade has rename-replaced the file (Linux's + // `current_exe()` then reads as "… (deleted)"). See + // `session::capture_startup_exe`. + session::capture_startup_exe(); + + let paths = Paths::discover(); + warn_legacy_paths(&paths); + std::fs::create_dir_all(&paths.state_dir).ok(); + std::fs::create_dir_all(&paths.data_dir).ok(); + std::fs::create_dir_all(&paths.runtime_dir).ok(); + std::fs::create_dir_all(&paths.config_dir).ok(); + + let config = config::Config::load_or_default(&paths)?; + tracing::info!( + adapters = config.adapters.len(), + config_dir = %paths.config_dir.display(), + "loaded config" + ); + + let storage = Arc::new(storage::Storage::new(paths.data_dir.clone())?); + let (manager, remote_rx, mut restart_rx) = + session::SessionManager::new(storage.clone(), Arc::new(config), paths.runtime_dir.clone()) + .await + .context("init session manager")?; + let manager = Arc::new(manager); + // Spawn the remote supervisor first so any subsequent + // `start_remote` call (boot-time env-var path or in-flight + // `remote.start` IPC) has a live receiver to send to. + { + let mgr = manager.clone(); + tokio::spawn(async move { + remote_supervisor::run(mgr, remote_rx).await; + }); + } + // Best-effort resume: re-spawn adapters for sessions that were alive at + // the previous shutdown. Sessions whose adapter binary is missing or + // whose start params can't be loaded get marked Errored. Logs only; + // never fatal. + manager.clone().resume_running_sessions().await; + // Best-effort: create the orchestrator session if config enables + // one and no orchestrator exists yet. Logged-only on failure (e.g. + // chosen harness missing or no API key); clients fall back to the + // static palette in that case. + manager.clone().ensure_orchestrator().await; + manager.spawn_widget_watcher(); + // Loop scheduler: wakes every second, fires due loops by + // calling `SessionManager::send_input`. Persisted per-session + // in `sessions//loops.json`; daemon restart picks them + // back up. + { + let mgr = manager.clone(); + let loops = mgr.loops.clone(); + tokio::spawn(async move { + loops::run_scheduler(mgr, loops).await; + }); + } + + let socket_path = socket_override.unwrap_or_else(|| paths.socket()); + + // Always expose the browser UI on localhost without remote-control + // credentials. This is intentionally local-only: `/remote-control` + // remains the opt-in public tunnel path and still layers token + + // Basic auth on top. + let local_webui_port = agentd_protocol::paths::local_webui_port(); + { + let mgr = manager.clone(); + tokio::spawn(async move { + let addr = format!("127.0.0.1:{local_webui_port}"); + match tokio::net::TcpListener::bind(&addr).await { + Ok(listener) => { + tracing::info!(url = %format!("http://{addr}/"), "local webui ready (localhost-only, no auth)"); + if let Err(e) = server::serve_local_webui_on(mgr, listener).await { + tracing::error!(error = %e, "local webui listener failed"); + } + } + Err(e) => { + tracing::warn!(addr = %addr, error = %e, "local webui disabled; bind failed"); + } + } + }); + } + + // Auto-start the remote WS listener at boot when + // `CONSTRUCT_REMOTE_WS_PORT` is set — the headless / scripted + // entry point. Interactive users get the same machinery via + // the TUI's `/remote-control` slash (which calls + // `remote.start` over IPC and shows a QR), so the env var is + // only needed when nobody is at the terminal to type the + // command. + if let Ok(port_raw) = std::env::var("CONSTRUCT_REMOTE_WS_PORT") { + match port_raw.parse::() { + Ok(port) => { + let mgr = manager.clone(); + tokio::spawn(async move { + // Boot path = tunnel mode. The 15s wait is + // tolerable here because the daemon is starting + // and nobody is staring at a UI. Failure + // (cloudflared missing, no public URL) is + // logged but doesn't kill the daemon — local + // WS is still up. + // Env-var boot path uses the auto-generated + // password; nobody is at the TUI to type one. + // The password lands in the info log so it's + // visible to the operator running the daemon. + let params = agentd_protocol::RemoteStartParams { + local_only: false, + password: None, + wait_for_tunnel: true, + }; + if let Err(e) = mgr.start_remote(Some(port), params).await { + tracing::error!(error = %e, "boot-time start_remote failed"); + } + }); + } + Err(_) => tracing::warn!( + value = %port_raw, + "CONSTRUCT_REMOTE_WS_PORT is not a valid u16; skipping ws listener" + ), + } + } else if paths.runtime_dir.join("remote.json").exists() { + // `/construct restart` path: the prior daemon had the remote + // listener up and persisted a snapshot. Resume ONLY if that + // snapshot is still adoptable (fresh, and its cloudflared + // tunnel PID — if any — still alive), picking the port + + // token + password back up without the user retyping + // `/remote-control`. + // + // If the tunnel can no longer be restored, switch + // remote-control OFF rather than spinning up a brand-new + // tunnel: a restart must never silently rotate the public + // URL/credentials. `snapshot_restorable` removes the dead + // snapshot as a side effect, so the next boot stays off too. + let snapshot_path = paths.runtime_dir.join("remote.json"); + if crate::remote_supervisor::snapshot_restorable(&snapshot_path) { + let mgr = manager.clone(); + tokio::spawn(async move { + let params = agentd_protocol::RemoteStartParams { + local_only: false, + password: None, + wait_for_tunnel: true, + }; + // port_hint=None — the supervisor reads the snapshot + // and uses snapshot.port instead. + if let Err(e) = mgr.start_remote(None, params).await { + tracing::warn!(error = %e, "remote snapshot resume failed"); + } + }); + } else { + tracing::info!("remote: prior tunnel no longer restorable across restart; staying off"); + } + } + + // Race the IPC accept loop against shutdown signals + the + // restart channel: + // + // - SIGTERM/SIGINT: drain adapters first (flush state), then + // exit normally. + // - SIGHUP: exit without touching adapters so reload-like + // supervisors don't kill running sessions. + // - daemon.restart RPC: skip adapter drain (the new daemon + // will resume them from on-disk state immediately) and + // `exec()` the current binary in place, picking up any + // on-disk upgrade. PID is preserved; cloudflared (a child + // subprocess) gets killed via `kill_on_drop` and a new + // one is spawned by the new daemon if `/remote-control` + // is re-issued — URL preservation across restart is + // follow-up work, see issue #90 comments. + let outcome = tokio::select! { + result = server::serve(manager.clone(), socket_path) => MainOutcome::Server(result), + signal = shutdown_signal() => MainOutcome::Signal(signal), + Some(cmd) = restart_rx.recv() => MainOutcome::Restart(cmd), + }; + match outcome { + MainOutcome::Server(r) => r, + MainOutcome::Signal(DaemonSignal::Reload) => { + tracing::info!("received SIGHUP; exiting without stopping adapters"); + Ok(()) + } + MainOutcome::Signal(DaemonSignal::Terminate) => { + tracing::info!("received termination signal; shutting down adapters"); + manager.shutdown_adapters().await; + Ok(()) + } + MainOutcome::Restart(cmd) => { + tracing::info!(exe = %cmd.exe.display(), "daemon restart requested; exec self"); + // exec() replaces the process image in place — kernel + // closes any FDs marked CLOEXEC (which tokio sockets + // are by default), so the IPC + WS listeners are + // released cleanly. cloudflared (child subprocess) + // is taken down via kill_on_drop as the tokio runtime + // tears down. The new daemon will rebind whichever + // listeners are configured. + // + // Returns only on error — successful exec doesn't + // return. Surface the error as the daemon's exit + // status so the operator sees why the restart failed. + use std::os::unix::process::CommandExt; + let err = std::process::Command::new(&cmd.exe).args(&cmd.args).exec(); + Err(anyhow::anyhow!("exec({}) failed: {err}", cmd.exe.display())) + } + } +} + +fn warn_legacy_paths(current: &Paths) { + if let Some(msg) = legacy_migration_notice(current) { + eprint!("{}", msg); + } +} + +fn shell_quote(path: &std::path::Path) -> String { + format!("'{}'", path.display().to_string().replace('\'', "'\\''")) +} + +fn legacy_migration_notice(current: &Paths) -> Option { + legacy_migration_notice_with_paths(current, &Paths::discover_legacy()) +} + +fn legacy_migration_notice_with_paths(current: &Paths, legacy: &Paths) -> Option { + let mut found = Vec::<(&str, &std::path::Path)>::new(); + let legacy_items = [ + ("config directory", legacy.config_dir.as_path()), + ("state directory", legacy.state_dir.as_path()), + ("data directory", legacy.data_dir.as_path()), + ("runtime directory", legacy.runtime_dir.as_path()), + ]; + + for (label, p) in legacy_items { + if p.exists() { + found.push((label, p)); + } + } + + let legacy_config = legacy.config_file(); + if legacy_config.exists() { + found.push(("legacy config file", legacy_config.as_path())); + } + if found.is_empty() { + return None; + } + + let mut out = String::new(); + out.push_str("\n[construct] Detected existing legacy `agentd` layout;\nthis daemon no longer reads those locations.\n"); + for (label, path) in found { + out.push_str(&format!(" - legacy {label}: {}\n", path.display())); + } + + out.push_str("\nSuggested migration:\n"); + out.push_str(&format!( + " config: {old} -> {new}\n", + old = legacy.config_dir.display(), + new = current.config_dir.display() + )); + out.push_str(&format!( + " state: {old} -> {new}\n", + old = legacy.state_dir.display(), + new = current.state_dir.display() + )); + out.push_str(&format!( + " data: {old} -> {new}\n", + old = legacy.data_dir.display(), + new = current.data_dir.display() + )); + out.push_str(&format!( + " runtime: {old} -> {new}\n", + old = legacy.runtime_dir.display(), + new = current.runtime_dir.display() + )); + + out.push_str("\nCopy/paste migration command:\n"); + out.push_str(&format!( + " mkdir -p {} {} {} {}\n", + shell_quote(¤t.config_dir), + shell_quote(¤t.state_dir), + shell_quote(¤t.data_dir), + shell_quote(¤t.runtime_dir) + )); + + let migration_dirs = [ + (legacy.config_dir.as_path(), current.config_dir.as_path()), + (legacy.state_dir.as_path(), current.state_dir.as_path()), + (legacy.data_dir.as_path(), current.data_dir.as_path()), + (legacy.runtime_dir.as_path(), current.runtime_dir.as_path()), + ]; + for (old_dir, new_dir) in migration_dirs { + if old_dir.exists() { + out.push_str(&format!( + " cp -a {old}/. {new}/\n", + old = shell_quote(old_dir), + new = shell_quote(new_dir) + )); + } + } + if legacy_config.exists() { + out.push_str(&format!( + " cp -a {old_config} {new_config}\n", + old_config = shell_quote(&legacy_config), + new_config = shell_quote(¤t.config_file()) + )); + + let new_config = shell_quote(¤t.config_file()); + out.push_str(&format!( + " tmp=$(mktemp) && perl -0pe 's/\\[adapters\\.zarvis([^\\]]*)\\]/[adapters.smith$1]/g' {new_config} > \"$tmp\" && mv \"$tmp\" {new_config}\n", + new_config = new_config + )); + } + + out.push_str("\nMove or copy what you need; restart after migration.\n"); + + Some(out) +} + +enum MainOutcome { + Server(Result<()>), + Signal(DaemonSignal), + Restart(crate::session::RestartCommand), +} + +#[derive(Debug, Clone, Copy)] +enum DaemonSignal { + Reload, + Terminate, +} + +#[cfg(unix)] +async fn shutdown_signal() -> DaemonSignal { + use tokio::signal::unix::{signal, SignalKind}; + + let mut hup = signal(SignalKind::hangup()).expect("install SIGHUP handler"); + let mut int = signal(SignalKind::interrupt()).expect("install SIGINT handler"); + let mut term = signal(SignalKind::terminate()).expect("install SIGTERM handler"); + + tokio::select! { + _ = hup.recv() => DaemonSignal::Reload, + _ = int.recv() => DaemonSignal::Terminate, + _ = term.recv() => DaemonSignal::Terminate, + } +} + +#[cfg(not(unix))] +async fn shutdown_signal() -> DaemonSignal { + let _ = tokio::signal::ctrl_c().await; + DaemonSignal::Terminate +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn path(base: &std::path::Path, tail: &str) -> std::path::PathBuf { + base.join(tail) + } + + #[test] + fn legacy_migration_notice_omits_output_when_no_legacy_layout() { + let root = tempdir().unwrap(); + let current = Paths { + config_dir: path(root.path(), "construct-config"), + state_dir: path(root.path(), "construct-state"), + data_dir: path(root.path(), "construct-data"), + runtime_dir: path(root.path(), "construct-runtime"), + }; + let legacy = Paths { + config_dir: path(root.path(), "agentd-config"), + state_dir: path(root.path(), "agentd-state"), + data_dir: path(root.path(), "agentd-data"), + runtime_dir: path(root.path(), "agentd-runtime"), + }; + assert!(super::legacy_migration_notice_with_paths(¤t, &legacy).is_none()); + } + + #[test] + fn legacy_migration_notice_includes_detected_items() { + let root = tempdir().unwrap(); + let legacy = Paths { + config_dir: path(root.path(), "agentd-config"), + state_dir: path(root.path(), "agentd-state"), + data_dir: path(root.path(), "agentd-data"), + runtime_dir: path(root.path(), "agentd-runtime"), + }; + let current = Paths { + config_dir: path(root.path(), "construct-config"), + state_dir: path(root.path(), "construct-state"), + data_dir: path(root.path(), "construct-data"), + runtime_dir: path(root.path(), "construct-runtime"), + }; + std::fs::create_dir_all(&legacy.config_dir).unwrap(); + std::fs::create_dir_all(&legacy.state_dir).unwrap(); + std::fs::write(legacy.config_file(), "configured = true").unwrap(); + + let msg = super::legacy_migration_notice_with_paths(¤t, &legacy).expect("notice"); + assert!(msg.contains("Detected existing legacy `agentd` layout")); + assert!(msg.contains(&format!( + "legacy config file: {}", + legacy.config_file().display() + ))); + assert!(msg.contains("state directory")); + assert!(msg.contains("legacy config file")); + assert!(msg.contains(&format!("config: {}", legacy.config_dir.display()))); + assert!(msg.contains(&format!( + " config: {} -> {}", + legacy.config_dir.display(), + current.config_dir.display() + ))); + assert!(msg.contains(&format!( + " state: {} -> {}", + legacy.state_dir.display(), + current.state_dir.display() + ))); + assert!(msg.contains("Copy/paste migration command:")); + assert!(msg.contains("mkdir -p")); + assert!(msg.contains("cp -a")); + assert!(msg.contains("tmp=$(mktemp) && perl")); + assert!(msg.contains("adapters.smith")); + } +} diff --git a/crates/daemon/src/main.rs b/crates/daemon/src/main.rs index e6778546..5191f456 100644 --- a/crates/daemon/src/main.rs +++ b/crates/daemon/src/main.rs @@ -1,20 +1,13 @@ -use anyhow::{Context, Result}; +//! `constructd` — standalone daemon binary. +//! +//! Thin shim over the `agentd` library crate, kept as a back-compat alias for +//! the unified `construct daemon …` entry point. Both share the same +//! [`agentd::run`] code path; this binary simply parses the legacy +//! `constructd ` CLI and dispatches. + +use anyhow::Result; use clap::{Parser, Subcommand}; use std::path::PathBuf; -use std::sync::Arc; - -mod adapter; -mod config; -mod loops; -mod remote; -mod remote_supervisor; -mod server; -mod session; -mod storage; -mod tunnel; -mod worktree; - -use agentd_protocol::paths::Paths; #[derive(Debug, Parser)] #[command(name = "constructd", about = "construct daemon", version)] @@ -37,454 +30,19 @@ enum Command { DefaultConfig, } -fn init_tracing() { - use tracing_subscriber::{fmt, EnvFilter}; - let filter = EnvFilter::try_from_default_env() - .or_else(|_| EnvFilter::try_new("info,agentd=debug,agentd_protocol=info")) - .unwrap(); - let _ = fmt().with_env_filter(filter).with_target(false).try_init(); -} - #[tokio::main] async fn main() -> Result<()> { - init_tracing(); + agentd::init_tracing(); let cli = Cli::parse(); match cli.command.unwrap_or(Command::Run { socket: None }) { - Command::Run { socket } => run(socket).await, + Command::Run { socket } => agentd::run(socket).await, Command::Paths => { - let p = Paths::discover(); - println!("config: {}", p.config_dir.display()); - println!("state: {}", p.state_dir.display()); - println!("data: {}", p.data_dir.display()); - println!("runtime: {}", p.runtime_dir.display()); - println!("socket: {}", p.socket().display()); - println!("webui: {}", agentd_protocol::paths::local_webui_url()); + agentd::print_paths(); Ok(()) } Command::DefaultConfig => { - println!("{}", config::DEFAULT_CONFIG_TOML); - Ok(()) - } - } -} - -async fn run(socket_override: Option) -> Result<()> { - // Capture the executable path now, before anything can replace - // the binary on disk. `/construct restart` re-`exec()`s this path; - // resolving it lazily at restart time is unreliable once an - // upgrade has rename-replaced the file (Linux's - // `current_exe()` then reads as "… (deleted)"). See - // `session::capture_startup_exe`. - session::capture_startup_exe(); - - let paths = Paths::discover(); - warn_legacy_paths(&paths); - std::fs::create_dir_all(&paths.state_dir).ok(); - std::fs::create_dir_all(&paths.data_dir).ok(); - std::fs::create_dir_all(&paths.runtime_dir).ok(); - std::fs::create_dir_all(&paths.config_dir).ok(); - - let config = config::Config::load_or_default(&paths)?; - tracing::info!( - adapters = config.adapters.len(), - config_dir = %paths.config_dir.display(), - "loaded config" - ); - - let storage = Arc::new(storage::Storage::new(paths.data_dir.clone())?); - let (manager, remote_rx, mut restart_rx) = - session::SessionManager::new(storage.clone(), Arc::new(config), paths.runtime_dir.clone()) - .await - .context("init session manager")?; - let manager = Arc::new(manager); - // Spawn the remote supervisor first so any subsequent - // `start_remote` call (boot-time env-var path or in-flight - // `remote.start` IPC) has a live receiver to send to. - { - let mgr = manager.clone(); - tokio::spawn(async move { - remote_supervisor::run(mgr, remote_rx).await; - }); - } - // Best-effort resume: re-spawn adapters for sessions that were alive at - // the previous shutdown. Sessions whose adapter binary is missing or - // whose start params can't be loaded get marked Errored. Logs only; - // never fatal. - manager.clone().resume_running_sessions().await; - // Best-effort: create the orchestrator session if config enables - // one and no orchestrator exists yet. Logged-only on failure (e.g. - // chosen harness missing or no API key); clients fall back to the - // static palette in that case. - manager.clone().ensure_orchestrator().await; - manager.spawn_widget_watcher(); - // Loop scheduler: wakes every second, fires due loops by - // calling `SessionManager::send_input`. Persisted per-session - // in `sessions//loops.json`; daemon restart picks them - // back up. - { - let mgr = manager.clone(); - let loops = mgr.loops.clone(); - tokio::spawn(async move { - loops::run_scheduler(mgr, loops).await; - }); - } - - let socket_path = socket_override.unwrap_or_else(|| paths.socket()); - - // Always expose the browser UI on localhost without remote-control - // credentials. This is intentionally local-only: `/remote-control` - // remains the opt-in public tunnel path and still layers token + - // Basic auth on top. - let local_webui_port = agentd_protocol::paths::local_webui_port(); - { - let mgr = manager.clone(); - tokio::spawn(async move { - let addr = format!("127.0.0.1:{local_webui_port}"); - match tokio::net::TcpListener::bind(&addr).await { - Ok(listener) => { - tracing::info!(url = %format!("http://{addr}/"), "local webui ready (localhost-only, no auth)"); - if let Err(e) = server::serve_local_webui_on(mgr, listener).await { - tracing::error!(error = %e, "local webui listener failed"); - } - } - Err(e) => { - tracing::warn!(addr = %addr, error = %e, "local webui disabled; bind failed"); - } - } - }); - } - - // Auto-start the remote WS listener at boot when - // `CONSTRUCT_REMOTE_WS_PORT` is set — the headless / scripted - // entry point. Interactive users get the same machinery via - // the TUI's `/remote-control` slash (which calls - // `remote.start` over IPC and shows a QR), so the env var is - // only needed when nobody is at the terminal to type the - // command. - if let Ok(port_raw) = std::env::var("CONSTRUCT_REMOTE_WS_PORT") { - match port_raw.parse::() { - Ok(port) => { - let mgr = manager.clone(); - tokio::spawn(async move { - // Boot path = tunnel mode. The 15s wait is - // tolerable here because the daemon is starting - // and nobody is staring at a UI. Failure - // (cloudflared missing, no public URL) is - // logged but doesn't kill the daemon — local - // WS is still up. - // Env-var boot path uses the auto-generated - // password; nobody is at the TUI to type one. - // The password lands in the info log so it's - // visible to the operator running the daemon. - let params = agentd_protocol::RemoteStartParams { - local_only: false, - password: None, - wait_for_tunnel: true, - }; - if let Err(e) = mgr.start_remote(Some(port), params).await { - tracing::error!(error = %e, "boot-time start_remote failed"); - } - }); - } - Err(_) => tracing::warn!( - value = %port_raw, - "CONSTRUCT_REMOTE_WS_PORT is not a valid u16; skipping ws listener" - ), - } - } else if paths.runtime_dir.join("remote.json").exists() { - // `/construct restart` path: the prior daemon had the remote - // listener up and persisted a snapshot. Resume ONLY if that - // snapshot is still adoptable (fresh, and its cloudflared - // tunnel PID — if any — still alive), picking the port + - // token + password back up without the user retyping - // `/remote-control`. - // - // If the tunnel can no longer be restored, switch - // remote-control OFF rather than spinning up a brand-new - // tunnel: a restart must never silently rotate the public - // URL/credentials. `snapshot_restorable` removes the dead - // snapshot as a side effect, so the next boot stays off too. - let snapshot_path = paths.runtime_dir.join("remote.json"); - if crate::remote_supervisor::snapshot_restorable(&snapshot_path) { - let mgr = manager.clone(); - tokio::spawn(async move { - let params = agentd_protocol::RemoteStartParams { - local_only: false, - password: None, - wait_for_tunnel: true, - }; - // port_hint=None — the supervisor reads the snapshot - // and uses snapshot.port instead. - if let Err(e) = mgr.start_remote(None, params).await { - tracing::warn!(error = %e, "remote snapshot resume failed"); - } - }); - } else { - tracing::info!("remote: prior tunnel no longer restorable across restart; staying off"); - } - } - - // Race the IPC accept loop against shutdown signals + the - // restart channel: - // - // - SIGTERM/SIGINT: drain adapters first (flush state), then - // exit normally. - // - SIGHUP: exit without touching adapters so reload-like - // supervisors don't kill running sessions. - // - daemon.restart RPC: skip adapter drain (the new daemon - // will resume them from on-disk state immediately) and - // `exec()` the current binary in place, picking up any - // on-disk upgrade. PID is preserved; cloudflared (a child - // subprocess) gets killed via `kill_on_drop` and a new - // one is spawned by the new daemon if `/remote-control` - // is re-issued — URL preservation across restart is - // follow-up work, see issue #90 comments. - let outcome = tokio::select! { - result = server::serve(manager.clone(), socket_path) => MainOutcome::Server(result), - signal = shutdown_signal() => MainOutcome::Signal(signal), - Some(cmd) = restart_rx.recv() => MainOutcome::Restart(cmd), - }; - match outcome { - MainOutcome::Server(r) => r, - MainOutcome::Signal(DaemonSignal::Reload) => { - tracing::info!("received SIGHUP; exiting without stopping adapters"); + println!("{}", agentd::DEFAULT_CONFIG_TOML); Ok(()) } - MainOutcome::Signal(DaemonSignal::Terminate) => { - tracing::info!("received termination signal; shutting down adapters"); - manager.shutdown_adapters().await; - Ok(()) - } - MainOutcome::Restart(cmd) => { - tracing::info!(exe = %cmd.exe.display(), "daemon restart requested; exec self"); - // exec() replaces the process image in place — kernel - // closes any FDs marked CLOEXEC (which tokio sockets - // are by default), so the IPC + WS listeners are - // released cleanly. cloudflared (child subprocess) - // is taken down via kill_on_drop as the tokio runtime - // tears down. The new daemon will rebind whichever - // listeners are configured. - // - // Returns only on error — successful exec doesn't - // return. Surface the error as the daemon's exit - // status so the operator sees why the restart failed. - use std::os::unix::process::CommandExt; - let err = std::process::Command::new(&cmd.exe).args(&cmd.args).exec(); - Err(anyhow::anyhow!("exec({}) failed: {err}", cmd.exe.display())) - } - } -} - -fn warn_legacy_paths(current: &Paths) { - if let Some(msg) = legacy_migration_notice(current) { - eprint!("{}", msg); } } - -fn shell_quote(path: &std::path::Path) -> String { - format!("'{}'", path.display().to_string().replace('\'', "'\\''")) -} - -fn legacy_migration_notice(current: &Paths) -> Option { - legacy_migration_notice_with_paths(current, &Paths::discover_legacy()) -} - -fn legacy_migration_notice_with_paths(current: &Paths, legacy: &Paths) -> Option { - let mut found = Vec::<(&str, &std::path::Path)>::new(); - let legacy_items = [ - ("config directory", legacy.config_dir.as_path()), - ("state directory", legacy.state_dir.as_path()), - ("data directory", legacy.data_dir.as_path()), - ("runtime directory", legacy.runtime_dir.as_path()), - ]; - - for (label, p) in legacy_items { - if p.exists() { - found.push((label, p)); - } - } - - let legacy_config = legacy.config_file(); - if legacy_config.exists() { - found.push(("legacy config file", legacy_config.as_path())); - } - if found.is_empty() { - return None; - } - - let mut out = String::new(); - out.push_str("\n[construct] Detected existing legacy `agentd` layout;\nthis daemon no longer reads those locations.\n"); - for (label, path) in found { - out.push_str(&format!(" - legacy {label}: {}\n", path.display())); - } - - out.push_str("\nSuggested migration:\n"); - out.push_str(&format!( - " config: {old} -> {new}\n", - old = legacy.config_dir.display(), - new = current.config_dir.display() - )); - out.push_str(&format!( - " state: {old} -> {new}\n", - old = legacy.state_dir.display(), - new = current.state_dir.display() - )); - out.push_str(&format!( - " data: {old} -> {new}\n", - old = legacy.data_dir.display(), - new = current.data_dir.display() - )); - out.push_str(&format!( - " runtime: {old} -> {new}\n", - old = legacy.runtime_dir.display(), - new = current.runtime_dir.display() - )); - - out.push_str("\nCopy/paste migration command:\n"); - out.push_str(&format!( - " mkdir -p {} {} {} {}\n", - shell_quote(¤t.config_dir), - shell_quote(¤t.state_dir), - shell_quote(¤t.data_dir), - shell_quote(¤t.runtime_dir) - )); - - let migration_dirs = [ - (legacy.config_dir.as_path(), current.config_dir.as_path()), - (legacy.state_dir.as_path(), current.state_dir.as_path()), - (legacy.data_dir.as_path(), current.data_dir.as_path()), - (legacy.runtime_dir.as_path(), current.runtime_dir.as_path()), - ]; - for (old_dir, new_dir) in migration_dirs { - if old_dir.exists() { - out.push_str(&format!( - " cp -a {old}/. {new}/\n", - old = shell_quote(old_dir), - new = shell_quote(new_dir) - )); - } - } - if legacy_config.exists() { - out.push_str(&format!( - " cp -a {old_config} {new_config}\n", - old_config = shell_quote(&legacy_config), - new_config = shell_quote(¤t.config_file()) - )); - - let new_config = shell_quote(¤t.config_file()); - out.push_str(&format!( - " tmp=$(mktemp) && perl -0pe 's/\\[adapters\\.zarvis([^\\]]*)\\]/[adapters.smith$1]/g' {new_config} > \"$tmp\" && mv \"$tmp\" {new_config}\n", - new_config = new_config - )); - } - - out.push_str("\nMove or copy what you need; restart after migration.\n"); - - Some(out) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - fn path(base: &std::path::Path, tail: &str) -> std::path::PathBuf { - base.join(tail) - } - - #[test] - fn legacy_migration_notice_omits_output_when_no_legacy_layout() { - let root = tempdir().unwrap(); - let current = Paths { - config_dir: path(root.path(), "construct-config"), - state_dir: path(root.path(), "construct-state"), - data_dir: path(root.path(), "construct-data"), - runtime_dir: path(root.path(), "construct-runtime"), - }; - let legacy = Paths { - config_dir: path(root.path(), "agentd-config"), - state_dir: path(root.path(), "agentd-state"), - data_dir: path(root.path(), "agentd-data"), - runtime_dir: path(root.path(), "agentd-runtime"), - }; - assert!(super::legacy_migration_notice_with_paths(¤t, &legacy).is_none()); - } - - #[test] - fn legacy_migration_notice_includes_detected_items() { - let root = tempdir().unwrap(); - let legacy = Paths { - config_dir: path(root.path(), "agentd-config"), - state_dir: path(root.path(), "agentd-state"), - data_dir: path(root.path(), "agentd-data"), - runtime_dir: path(root.path(), "agentd-runtime"), - }; - let current = Paths { - config_dir: path(root.path(), "construct-config"), - state_dir: path(root.path(), "construct-state"), - data_dir: path(root.path(), "construct-data"), - runtime_dir: path(root.path(), "construct-runtime"), - }; - std::fs::create_dir_all(&legacy.config_dir).unwrap(); - std::fs::create_dir_all(&legacy.state_dir).unwrap(); - std::fs::write(legacy.config_file(), "configured = true").unwrap(); - - let msg = super::legacy_migration_notice_with_paths(¤t, &legacy).expect("notice"); - assert!(msg.contains("Detected existing legacy `agentd` layout")); - assert!(msg.contains(&format!( - "legacy config file: {}", - legacy.config_file().display() - ))); - assert!(msg.contains("state directory")); - assert!(msg.contains("legacy config file")); - assert!(msg.contains(&format!("config: {}", legacy.config_dir.display()))); - assert!(msg.contains(&format!( - " config: {} -> {}", - legacy.config_dir.display(), - current.config_dir.display() - ))); - assert!(msg.contains(&format!( - " state: {} -> {}", - legacy.state_dir.display(), - current.state_dir.display() - ))); - assert!(msg.contains("Copy/paste migration command:")); - assert!(msg.contains("mkdir -p")); - assert!(msg.contains("cp -a")); - assert!(msg.contains("tmp=$(mktemp) && perl")); - assert!(msg.contains("adapters.smith")); - } -} - -enum MainOutcome { - Server(Result<()>), - Signal(DaemonSignal), - Restart(crate::session::RestartCommand), -} - -#[derive(Debug, Clone, Copy)] -enum DaemonSignal { - Reload, - Terminate, -} - -#[cfg(unix)] -async fn shutdown_signal() -> DaemonSignal { - use tokio::signal::unix::{signal, SignalKind}; - - let mut hup = signal(SignalKind::hangup()).expect("install SIGHUP handler"); - let mut int = signal(SignalKind::interrupt()).expect("install SIGINT handler"); - let mut term = signal(SignalKind::terminate()).expect("install SIGTERM handler"); - - tokio::select! { - _ = hup.recv() => DaemonSignal::Reload, - _ = int.recv() => DaemonSignal::Terminate, - _ = term.recv() => DaemonSignal::Terminate, - } -} - -#[cfg(not(unix))] -async fn shutdown_signal() -> DaemonSignal { - let _ = tokio::signal::ctrl_c().await; - DaemonSignal::Terminate -} diff --git a/docs/architecture.md b/docs/architecture.md index 117961ca..a000c646 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,17 +18,19 @@ Five layers, each replaceable: └──────────────────────────────────────────────┘ ``` -- **Daemon** (`constructd`) owns sessions, spawns adapters, persists transcripts. Speaks JSON-RPC over a Unix socket to clients. +- **Daemon** owns sessions, spawns adapters, persists transcripts. Speaks JSON-RPC over a Unix socket to clients. Run it with `construct daemon run` (or the back-compat `constructd` alias — same binary code). - **Client** (`construct`) is the TUI plus a set of one-shot subcommands. Multiple clients can attach concurrently. - **Adapter** binaries are independent processes. They implement the AHP over stdio. Anyone can ship one in any language. +The daemon and client ship as **one binary**: `construct` runs the TUI by default and the daemon under `construct daemon`. The daemon's runtime lives in the `agentd` library crate; both `construct daemon` and the standalone `constructd` binary are thin entry points over it. They are not merged into one *process* — the daemon stays a separate long-lived process that many clients attach to — only into one shipped executable. See [`specs/0026-single-binary-daemon-and-client.md`](../specs/0026-single-binary-daemon-and-client.md). + ## Crates | Crate | Binary | Purpose | |---|---|---| | `crates/protocol` | — (lib) | AHP + IPC types, transport, adapter SDK | -| `crates/daemon` | `constructd` | Session supervisor, IPC server | -| `crates/cli` | `construct` | TUI client + control subcommands | +| `crates/daemon` | `agentd` (lib) + `constructd` (bin) | Session supervisor, IPC server. Runtime lives in the lib; `constructd` is a thin alias for `construct daemon` | +| `crates/cli` | `construct` | TUI client + control subcommands + `construct daemon` (runs the daemon via the `agentd` lib) | | `crates/adapter-shell` | `construct-adapter-shell` | Generic shell command runner | | `crates/adapter-claude` | `construct-adapter-claude` | Wraps the `claude` CLI | | `crates/adapter-codex` | `construct-adapter-codex` | Wraps the `codex` CLI | diff --git a/install.sh b/install.sh index 7881262c..9cf3677f 100755 --- a/install.sh +++ b/install.sh @@ -128,4 +128,4 @@ case ":${PATH}:" in esac say "" -say "Done. Try: constructd run (in another terminal) construct" +say "Done. Try: construct daemon run (in another terminal) construct" diff --git a/specs/0026-single-binary-daemon-and-client.md b/specs/0026-single-binary-daemon-and-client.md new file mode 100644 index 00000000..8feb2100 --- /dev/null +++ b/specs/0026-single-binary-daemon-and-client.md @@ -0,0 +1,74 @@ +# 0026-single-binary-daemon-and-client + +Status: accepted +Date: 2026-06-07 +Area: architecture +Scope: The daemon and the TUI/CLI client ship as one executable; the daemon's runtime is a library with thin, interchangeable entry points. + +## Decision + +The daemon and the client are **one shipped binary**, not two. The `construct` +binary runs the TUI by default and runs the daemon under a `daemon` subcommand +(`construct daemon run`). The daemon's entire runtime lives in a library crate +so it can be driven from more than one entry point. + +A standalone `constructd` binary remains as a **back-compat alias** that is a +thin shim over the same library. `constructd run` and `construct daemon run` +are equivalent and share 100% of their code path. + +This consolidation is about the *executable*, not the *process*. The daemon is +still a single long-lived process that many clients attach to over the IPC +socket; running `construct` (the TUI) does not start an embedded daemon, and +running it multiple times yields multiple clients against one daemon — never +multiple daemons. + +## Reason + +- One installed binary can do everything (client + daemon), simplifying + install, upgrade, and the mental model — you no longer need two files on + PATH to run the system. +- The daemon's logic was bin-only and unreachable as a library; promoting it to + a library lets the unified binary call it directly with no duplication and no + subprocess hop. +- Keeping `constructd` as an alias avoids breaking existing installs, scripts, + muscle memory, and the atomic-rename upgrade layout, which replaces binaries + in place at fixed paths. + +## Consequences + +- **The daemon's runtime must stay library-shaped.** Its public entry points + (run, tracing init, paths printing, default-config) are what both binaries + call. Don't push entry-point-only logic back into a `main`. +- **Self-restart re-execs by replaying argv verbatim.** The daemon picks up an + upgraded binary by `exec()`ing its startup-captured executable path with its + original arguments. This is why a symlink/`argv[0]`-multiplex approach was + rejected: resolving a `constructd`→`construct` symlink would drop the name + while the replayed args still assume daemon dispatch, breaking restart. Each + real entry point must replay an argv that re-enters the *same* mode + (`construct daemon run …` re-execs `construct daemon run …`; `constructd run` + re-execs `constructd run`). +- **The unified binary links both dependency sets** (TUI rendering + daemon + server/tunnel). Accept the larger single binary in exchange for shipping one + file instead of two; most heavy dependencies are already shared. +- **Daemon mode owns the socket; client mode connects to it.** Mode selection + must happen before socket discovery and before tracing init, so the daemon's + verbose log filter applies in daemon mode and the client's quiet filter + applies otherwise. + +## Non-Goals + +- Not merging the adapter binaries or the MCP bridge into the unified binary. + Adapters are an independent process/plugin boundary and stay separate. +- Not adding auto-spawn of the daemon from the client. Daemon startup stays + explicit so two concurrent `construct` invocations can never race to bind the + IPC socket (the bind path unlinks any existing socket before binding, so an + accidental second daemon would steal a live socket). + +## Examples + +- Start the system: run the daemon in one place (`construct daemon run`), attach + one or more clients elsewhere (`construct`). +- Upgrade in place, then restart the running daemon: the daemon re-execs itself + at the same path and rebinds the socket without losing sessions, regardless of + whether it was launched as `construct daemon` or `constructd`. +- A legacy script or habit that runs `constructd run` keeps working unchanged.