diff --git a/CLAUDE.md b/CLAUDE.md index 99a3b200..cdf0cfc5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,7 @@ cargo test --workspace cargo clippy --workspace --all-targets -- -D warnings cargo fmt --all --check cargo run -p botloftd -- serve # com BOTLOFT_HOME de dev (abaixo) +cargo run -p botloftd -- service install # tarefa agendada própria da pasta de dev; `service uninstall` depois # Tipos TS gerados a partir do Rust cargo test -p botloft-core export_bindings @@ -68,6 +69,7 @@ Em dev, use `$env:BOTLOFT_HOME = "$PWD\.dev\home"` (caminho absoluto) no daemon - Permissão de arquivo é ACL (SID do usuário atual), não modo Unix. - Estado em `%LOCALAPPDATA%`, nunca `%APPDATA%` (roaming). - Cuidado com o limite de 260 caracteres em caminhos de workspace. +- Tarefa agendada (spec 14): `RestartOnFailure` não reinicia quando o processo sai com erro, e a repetição de um gatilho de logon só começa no próximo logon. Quem traz o daemon de volta é um gatilho de horário repetido a cada minuto com `IgnoreNew`. Use a API COM do Agendador: a saída do `schtasks.exe` é traduzida. ## Segurança e privacidade diff --git a/Cargo.lock b/Cargo.lock index 411b6a79..aef4ca2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -309,6 +309,7 @@ dependencies = [ "bytes", "clap", "dirs", + "embed-resource", "futures-util", "getrandom 0.4.3", "hex", diff --git a/crates/botloftd/Cargo.toml b/crates/botloftd/Cargo.toml index 4c48caad..7b4a6488 100644 --- a/crates/botloftd/Cargo.toml +++ b/crates/botloftd/Cargo.toml @@ -33,7 +33,10 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } workspace = true [target."cfg(windows)".dependencies] -windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading", "Win32_System_Memory", "Win32_System_JobObjects", "Win32_System_Environment"] } +windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading", "Win32_System_Memory", "Win32_System_JobObjects", "Win32_System_Environment", "Win32_System_Power", "Win32_System_Console", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Variant", "Win32_System_TaskScheduler"] } + +[target."cfg(windows)".build-dependencies] +embed-resource = "3.0.11" [dev-dependencies] base64 = "0.23.1" diff --git a/crates/botloftd/botloftd.manifest b/crates/botloftd/botloftd.manifest new file mode 100644 index 00000000..c137bdc8 --- /dev/null +++ b/crates/botloftd/botloftd.manifest @@ -0,0 +1,18 @@ + + + + + + + + + + + + true + + detached + + + diff --git a/crates/botloftd/botloftd.rc b/crates/botloftd/botloftd.rc new file mode 100644 index 00000000..fac54ca2 --- /dev/null +++ b/crates/botloftd/botloftd.rc @@ -0,0 +1,3 @@ +// The daemon's Windows resources: only its manifest (spec 14). +#define RT_MANIFEST 24 +1 RT_MANIFEST "botloftd.manifest" diff --git a/crates/botloftd/build.rs b/crates/botloftd/build.rs new file mode 100644 index 00000000..9c3a71d0 --- /dev/null +++ b/crates/botloftd/build.rs @@ -0,0 +1,14 @@ +//! Embeds `botloftd.manifest` in the Windows binary (spec 14). It goes in +//! as a resource: the linker's own manifest tool predates +//! `consoleAllocationPolicy` and warns about it on every build. + +fn main() { + println!("cargo:rerun-if-changed=botloftd.rc"); + println!("cargo:rerun-if-changed=botloftd.manifest"); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") + && let Err(err) = + embed_resource::compile("botloftd.rc", embed_resource::NONE).manifest_required() + { + panic!("cannot embed the manifest: {err}"); + } +} diff --git a/crates/botloftd/src/autostart/binary.rs b/crates/botloftd/src/autostart/binary.rs new file mode 100644 index 00000000..cb21780e --- /dev/null +++ b/crates/botloftd/src/autostart/binary.rs @@ -0,0 +1,175 @@ +//! The daemon runs from its own copy in `\bin`, never from the app's +//! folder, so the app's installer can replace its files while the daemon +//! runs (spec 14). + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +pub const EXE_NAME: &str = if cfg!(windows) { + "botloftd.exe" +} else { + "botloftd" +}; +const STAGED: &str = "botloftd.new"; +const OLD_SUFFIX: &str = ".old"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Installed { + pub path: PathBuf, + /// Whether a different binary was put in place. + pub changed: bool, +} + +/// Copies `source` to `\botloftd.exe`, unless it is that file or +/// has the same bytes. A running exe cannot be overwritten but can be +/// renamed, so the old one moves aside and is deleted on a later install. +pub fn install(source: &Path, bin_dir: &Path) -> io::Result { + fs::create_dir_all(bin_dir)?; + remove_old(bin_dir); + let target = bin_dir.join(EXE_NAME); + let unchanged = Installed { + path: target.clone(), + changed: false, + }; + if is_same_file(source, &target) { + return Ok(unchanged); + } + let bytes = fs::read(source)?; + match fs::read(&target) { + Ok(current) if current == bytes => return Ok(unchanged), + Ok(_) => {} + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => return Err(err), + } + let staged = bin_dir.join(STAGED); + fs::write(&staged, &bytes)?; + if target.exists() { + fs::rename(&target, aside(bin_dir))?; + } + fs::rename(&staged, &target)?; + Ok(Installed { + path: target, + changed: true, + }) +} + +/// Deletes the installed binary and what was moved aside, as far as +/// nothing still runs them. +pub fn remove(bin_dir: &Path) { + let _ = fs::remove_file(bin_dir.join(EXE_NAME)); + let _ = fs::remove_file(bin_dir.join(STAGED)); + remove_old(bin_dir); + let _ = fs::remove_dir(bin_dir); +} + +fn is_same_file(a: &Path, b: &Path) -> bool { + match (fs::canonicalize(a), fs::canonicalize(b)) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } +} + +/// A free `botloftd..old` name. A previous one may still be running. +fn aside(bin_dir: &Path) -> PathBuf { + (1..) + .map(|n| bin_dir.join(format!("botloftd.{n}{OLD_SUFFIX}"))) + .find(|path| !path.exists()) + .unwrap_or_else(|| bin_dir.join(format!("botloftd{OLD_SUFFIX}"))) +} + +fn remove_old(bin_dir: &Path) { + let Ok(entries) = fs::read_dir(bin_dir) else { + return; + }; + for entry in entries.flatten() { + if entry.file_name().to_string_lossy().ends_with(OLD_SUFFIX) { + // Still running: it goes next time. + let _ = fs::remove_file(entry.path()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn names(dir: &Path) -> Vec { + let mut names: Vec = fs::read_dir(dir) + .expect("read dir") + .map(|entry| { + entry + .expect("entry") + .file_name() + .to_string_lossy() + .into_owned() + }) + .collect(); + names.sort(); + names + } + + #[test] + fn copies_once_and_replaces_only_a_different_binary() { + let dir = tempfile::tempdir().expect("tempdir"); + let source = dir.path().join("sidecar.exe"); + let bin = dir.path().join("bin"); + fs::write(&source, b"v1").expect("write"); + + let first = install(&source, &bin).expect("install"); + assert!(first.changed); + assert_eq!(fs::read(&first.path).expect("read"), b"v1"); + assert!(!install(&source, &bin).expect("again").changed); + assert!( + !install(&first.path, &bin).expect("itself").changed, + "running install from the installed copy changes nothing" + ); + + fs::write(&source, b"v2").expect("write"); + let second = install(&source, &bin).expect("update"); + assert!(second.changed); + assert_eq!(fs::read(&second.path).expect("read"), b"v2"); + assert_eq!(names(&bin), ["botloftd.1.old".to_owned(), EXE_NAME.into()]); + + fs::write(&source, b"v3").expect("write"); + install(&source, &bin).expect("update"); + assert_eq!( + names(&bin), + ["botloftd.1.old".to_owned(), EXE_NAME.into()], + "the v1 copy went away and v2 moved aside" + ); + + remove(&bin); + assert!(!bin.exists()); + } + + /// A running exe is open with share-delete: it can be renamed, not + /// written. + #[cfg(windows)] + #[test] + fn replaces_a_binary_that_is_running() { + use std::os::windows::fs::OpenOptionsExt as _; + const FILE_SHARE_READ: u32 = 1; + const FILE_SHARE_DELETE: u32 = 4; + + let dir = tempfile::tempdir().expect("tempdir"); + let source = dir.path().join("sidecar.exe"); + let bin = dir.path().join("bin"); + fs::write(&source, b"v1").expect("write"); + let installed = install(&source, &bin).expect("install"); + let running = fs::OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ | FILE_SHARE_DELETE) + .open(&installed.path) + .expect("open"); + assert!( + fs::write(&installed.path, b"x").is_err(), + "cannot be written" + ); + + fs::write(&source, b"v2").expect("write"); + let updated = install(&source, &bin).expect("update"); + assert_eq!(fs::read(&updated.path).expect("read"), b"v2"); + drop(running); + } +} diff --git a/crates/botloftd/src/autostart/health.rs b/crates/botloftd/src/autostart/health.rs new file mode 100644 index 00000000..7f9e795d --- /dev/null +++ b/crates/botloftd/src/autostart/health.rs @@ -0,0 +1,50 @@ +//! Asking a daemon on 127.0.0.1 whether it is up, over plain HTTP. + +use std::io::{Read as _, Write as _}; +use std::net::{Ipv4Addr, SocketAddr, TcpStream}; +use std::time::{Duration, Instant}; + +use serde::Deserialize; + +const TIMEOUT: Duration = Duration::from_secs(2); + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct Health { + pub status: String, + pub version: String, + pub protocol: u32, +} + +/// The daemon's `/health`, or `None` if nothing Botloft answers. +pub fn probe(port: u16) -> Option { + let address = SocketAddr::from((Ipv4Addr::LOCALHOST, port)); + let mut stream = TcpStream::connect_timeout(&address, TIMEOUT).ok()?; + stream.set_read_timeout(Some(TIMEOUT)).ok()?; + stream.set_write_timeout(Some(TIMEOUT)).ok()?; + let request = "GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"; + stream.write_all(request.as_bytes()).ok()?; + let mut response = Vec::new(); + stream.read_to_end(&mut response).ok()?; + let response = String::from_utf8_lossy(&response); + let (head, body) = response.split_once("\r\n\r\n")?; + if !head.starts_with("HTTP/1.1 200") { + return None; + } + serde_json::from_str::(body) + .ok() + .filter(|health| health.status == "ok") +} + +/// Polls until `done` holds for the probe, or `limit` passes. +pub fn wait(port: u16, limit: Duration, done: impl Fn(Option<&Health>) -> bool) -> bool { + let started = Instant::now(); + loop { + if done(probe(port).as_ref()) { + return true; + } + if started.elapsed() > limit { + return false; + } + std::thread::sleep(Duration::from_millis(250)); + } +} diff --git a/crates/botloftd/src/autostart/mod.rs b/crates/botloftd/src/autostart/mod.rs new file mode 100644 index 00000000..3b92c2ad --- /dev/null +++ b/crates/botloftd/src/autostart/mod.rs @@ -0,0 +1,224 @@ +//! `botloftd service ...`: the daemon as a per-user scheduled task that +//! starts at logon and comes back within a minute if it dies (spec 14). +//! Not a Windows service: a service runs in another session, without the +//! owner's Claude Code sign-in. + +pub mod binary; +pub mod health; + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, bail}; +use sha2::{Digest, Sha256}; + +use crate::config::Config; +use crate::paths; +use crate::platform::{self, TaskDefinition, TaskState}; + +/// How long a daemon may take to stop or to answer after starting. +const STOP_TIMEOUT: Duration = Duration::from_secs(10); +const START_TIMEOUT: Duration = Duration::from_secs(20); +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// One task per data folder: `Botloft` for the default one, and a name +/// with a hash of the folder for a dev `BOTLOFT_HOME`, so installing a dev +/// daemon never replaces the real one. +pub fn task_name(home: &Path) -> String { + let key = |path: &Path| path.to_string_lossy().to_lowercase(); + match paths::default_home() { + Some(default) if key(&default) == key(home) => "Botloft".to_owned(), + _ => { + let digest = Sha256::digest(key(home).as_bytes()); + format!("Botloft-{}", hex::encode(&digest[..4])) + } + } +} + +/// Quotes one argument for the Windows command line. Backslashes before +/// the closing quote are doubled, so `C:\` does not escape it. +fn quote(arg: &str) -> String { + let trailing = arg.len() - arg.trim_end_matches('\\').len(); + format!("\"{arg}{}\"", "\\".repeat(trailing)) +} + +fn definition(home: &Path, program: &Path) -> TaskDefinition { + let home_text = home.to_string_lossy(); + TaskDefinition { + description: format!( + "Starts the Botloft daemon at logon and keeps it running. Data folder: {home_text}" + ), + program: program.to_owned(), + arguments: format!("serve --home {}", quote(&home_text)), + working_dir: home.to_owned(), + } +} + +fn port(home: &Path) -> anyhow::Result { + Ok(Config::load(&home.join("config.toml"), false)?.port) +} + +fn bin_dir(home: &Path) -> PathBuf { + home.join("bin") +} + +/// Copies this binary to `\bin`, registers the task and makes sure +/// the daemon of this version runs. A daemon that already runs this very +/// binary is left alone, so its bots are not interrupted. +pub fn install(home: &Path) -> anyhow::Result<()> { + std::fs::create_dir_all(home).with_context(|| format!("cannot create {}", home.display()))?; + let port = port(home)?; + let exe = std::env::current_exe().context("cannot find this program")?; + let installed = binary::install(&exe, &bin_dir(home)) + .with_context(|| format!("cannot copy the daemon to {}", bin_dir(home).display()))?; + let name = task_name(home); + platform::register_task(&name, &definition(home, &installed.path)) + .with_context(|| format!("cannot register the scheduled task {name}"))?; + println!( + "Scheduled task {name} starts {} at logon.", + installed.path.display() + ); + + let running = platform::find_task(&name)?.is_some_and(|task| task.state == TaskState::Running); + let current = health::probe(port).is_some_and(|health| health.version == VERSION); + if running && current && !installed.changed { + println!("The daemon {VERSION} is already running on 127.0.0.1:{port}."); + return Ok(()); + } + restart_task(&name, port) +} + +/// Stops the daemon and starts it again from the task. +pub fn restart(home: &Path) -> anyhow::Result<()> { + let name = task_name(home); + if platform::find_task(&name)?.is_none() { + bail!("the scheduled task {name} is not installed; run `botloftd service install`"); + } + restart_task(&name, port(home)?) +} + +fn restart_task(name: &str, port: u16) -> anyhow::Result<()> { + stop_task(name, port)?; + platform::run_task(name).with_context(|| format!("cannot start the scheduled task {name}"))?; + let up = health::wait(port, START_TIMEOUT, |health| { + health.is_some_and(|health| health.version == VERSION) + }); + if !up { + bail!( + "the daemon did not answer on 127.0.0.1:{port} within {} s. Another program may \ + use the port, or another botloftd may run with this data folder; its log is in \ + the logs folder", + START_TIMEOUT.as_secs() + ); + } + println!("The daemon {VERSION} is running on 127.0.0.1:{port}."); + Ok(()) +} + +/// Ends the task's daemon and waits until it no longer answers. Killing +/// it is safe: every message is stored before it is delivered, and the +/// bots die with it (Job Objects). +fn stop_task(name: &str, port: u16) -> anyhow::Result<()> { + let running = platform::find_task(name)?.is_some_and(|task| task.state == TaskState::Running); + if !running { + return Ok(()); + } + platform::stop_task(name).with_context(|| format!("cannot stop the scheduled task {name}"))?; + let stopped = health::wait(port, STOP_TIMEOUT, |health| health.is_none()) + && wait_until(STOP_TIMEOUT, || { + platform::find_task(name) + .is_ok_and(|task| task.is_none_or(|task| task.state != TaskState::Running)) + }); + if !stopped { + bail!("the daemon of the scheduled task {name} did not stop"); + } + Ok(()) +} + +/// Stops the daemon, deletes the task and the installed binary. The data +/// folder and the bots' workspaces stay. +pub fn uninstall(home: &Path) -> anyhow::Result<()> { + let name = task_name(home); + let port = port(home).unwrap_or(Config::default().port); + stop_task(&name, port)?; + if platform::delete_task(&name)? { + println!("Deleted the scheduled task {name}."); + } else { + println!("The scheduled task {name} was not installed."); + } + binary::remove(&bin_dir(home)); + println!("Your bots and data stay in {}.", home.display()); + Ok(()) +} + +/// Prints whether the task is installed and whether the daemon answers. +pub fn status(home: &Path) -> anyhow::Result<()> { + let name = task_name(home); + match platform::find_task(&name)? { + Some(task) => { + let state = match task.state { + TaskState::Running => "running", + TaskState::Ready => "waiting for the next logon", + TaskState::Disabled => "disabled", + TaskState::Unknown => "in an unknown state", + }; + println!("Scheduled task {name}: installed, {state}."); + if let Some(command) = task.command { + println!(" {command}"); + } + } + None => println!("Scheduled task {name}: not installed."), + } + let port = port(home)?; + match health::probe(port) { + Some(health) => println!( + "Daemon: {} (protocol {}) answers on 127.0.0.1:{port}.", + health.version, health.protocol + ), + None => println!("Daemon: nothing answers on 127.0.0.1:{port}."), + } + println!("Data folder: {}", home.display()); + Ok(()) +} + +fn wait_until(limit: Duration, done: impl Fn() -> bool) -> bool { + let started = std::time::Instant::now(); + while !done() { + if started.elapsed() > limit { + return false; + } + std::thread::sleep(Duration::from_millis(250)); + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn arguments_are_quoted_for_the_command_line() { + assert_eq!( + quote(r"C:\Users\Ana Lima\Botloft"), + r#""C:\Users\Ana Lima\Botloft""# + ); + assert_eq!(quote(r"D:\"), r#""D:\\""#); + let task = definition( + Path::new(r"C:\data"), + Path::new(r"C:\data\bin\botloftd.exe"), + ); + assert_eq!(task.arguments, r#"serve --home "C:\data""#); + assert!(task.description.ends_with(r"Data folder: C:\data")); + } + + #[test] + fn a_dev_home_gets_its_own_task() { + let default = paths::default_home().expect("default home"); + assert_eq!(task_name(&default), "Botloft"); + let upper = PathBuf::from(default.to_string_lossy().to_uppercase()); + assert_eq!(task_name(&upper), "Botloft", "Windows paths ignore case"); + let dev = task_name(Path::new(r"C:\src\botloft\.dev\home")); + assert!(dev.starts_with("Botloft-") && dev.len() == "Botloft-".len() + 8); + assert_ne!(dev, task_name(Path::new(r"C:\other\home"))); + } +} diff --git a/crates/botloftd/src/keep_awake.rs b/crates/botloftd/src/keep_awake.rs new file mode 100644 index 00000000..3084a468 --- /dev/null +++ b/crates/botloftd/src/keep_awake.rs @@ -0,0 +1,77 @@ +//! Keeps the computer awake while any bot is `busy`, when `keep_awake` is +//! on (spec 14). A bot waiting for the owner or idle lets it sleep. + +use tokio::sync::watch; +use tracing::{debug, warn}; + +use crate::platform::KeepAwake; + +/// Follows the supervisor's count of busy bots until the daemon stops. +pub async fn run(busy: watch::Receiver) { + let mut request = match KeepAwake::new() { + Ok(request) => request, + Err(err) => { + warn!("cannot keep the computer awake: {err}"); + return; + } + }; + follow(busy, |on| { + debug!(on, "keep awake"); + if let Err(err) = request.set(on) { + warn!("cannot change the keep-awake request: {err}"); + } + }) + .await; +} + +/// Calls `hold` with `true` when the first bot starts working and with +/// `false` when the last one stops. +async fn follow(mut busy: watch::Receiver, mut hold: impl FnMut(bool)) { + let mut held = false; + loop { + let working = *busy.borrow_and_update() > 0; + if working != held { + hold(working); + held = working; + } + if busy.changed().await.is_err() { + break; + } + } + if held { + hold(false); + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use super::*; + + #[tokio::test] + async fn holds_only_while_some_bot_is_busy() { + let (count, busy) = watch::channel(0usize); + let calls = Arc::new(Mutex::new(Vec::new())); + let seen = Arc::clone(&calls); + let task = tokio::spawn(follow(busy, move |on| { + seen.lock().expect("calls").push(on); + })); + for n in [1, 2, 1, 0, 0, 3] { + count.send_replace(n); + tokio::task::yield_now().await; + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + drop(count); + task.await.expect("follow ends with the daemon"); + assert_eq!(*calls.lock().expect("calls"), [true, false, true, false]); + } + + #[test] + fn a_keep_awake_request_can_be_set_and_cleared() { + let mut request = KeepAwake::new().expect("request"); + request.set(true).expect("set"); + request.set(true).expect("set twice"); + request.set(false).expect("clear"); + } +} diff --git a/crates/botloftd/src/lib.rs b/crates/botloftd/src/lib.rs index 8ab4a722..521f03ec 100644 --- a/crates/botloftd/src/lib.rs +++ b/crates/botloftd/src/lib.rs @@ -3,10 +3,12 @@ //! parses the command line and wires these modules together. pub mod approvals; +pub mod autostart; pub mod chat; pub mod clock; pub mod config; pub mod courier; +pub mod keep_awake; pub mod logging; pub mod paths; pub mod platform; diff --git a/crates/botloftd/src/main.rs b/crates/botloftd/src/main.rs index 2b4aff96..3313214c 100644 --- a/crates/botloftd/src/main.rs +++ b/crates/botloftd/src/main.rs @@ -15,14 +15,17 @@ use botloftd::runtime::PipeRuntime; use botloftd::service::tasks::TaskSettings; use botloftd::state::{BotSettings, Daemon, DaemonOptions}; use botloftd::supervisor::{self, SupervisorSettings}; -use botloftd::{approvals, logging, secrets, server}; +use botloftd::{approvals, autostart, keep_awake, logging, secrets, server}; use clap::{Parser, Subcommand}; use tokio::net::TcpListener; -use tracing::info; +use tracing::{error, info}; #[derive(Parser)] #[command(name = "botloftd", version, about = "Botloft daemon")] struct Cli { + /// Data folder. Defaults to BOTLOFT_HOME, then %LOCALAPPDATA%\Botloft. + #[arg(long, global = true)] + home: Option, #[command(subcommand)] command: Command, } @@ -35,16 +38,38 @@ enum Command { #[arg(long)] config: Option, }, + /// Start the daemon at logon, as a scheduled task of the current user. + #[command(subcommand)] + Service(ServiceCommand), +} + +#[derive(Subcommand)] +enum ServiceCommand { + /// Copy this binary to the data folder, register the task and start it. + Install, + /// Show whether the task is installed and the daemon answers. + Status, + /// Stop the daemon and start it again. + Restart, + /// Stop the daemon and remove the task. Bots and data stay. + Uninstall, } fn main() -> anyhow::Result<()> { - match Cli::parse().command { - Command::Serve { config } => serve(config), + let cli = Cli::parse(); + let home = + paths::resolve_home(cli.home.as_deref()).context("cannot resolve the data folder")?; + match cli.command { + Command::Serve { config } => serve(home, config), + Command::Service(ServiceCommand::Install) => autostart::install(&home), + Command::Service(ServiceCommand::Status) => autostart::status(&home), + Command::Service(ServiceCommand::Restart) => autostart::restart(&home), + Command::Service(ServiceCommand::Uninstall) => autostart::uninstall(&home), } } -fn serve(config_path: Option) -> anyhow::Result<()> { - let home = paths::resolve_home().context("cannot resolve the data folder")?; +fn serve(home: PathBuf, config_path: Option) -> anyhow::Result<()> { + platform::leave_own_console(); std::fs::create_dir_all(&home).with_context(|| format!("cannot create {}", home.display()))?; let config = match &config_path { Some(path) => Config::load(path, true)?, @@ -53,6 +78,12 @@ fn serve(config_path: Option) -> anyhow::Result<()> { let paths = Paths::new(home, paths::resolve_workspaces_root(&config)?); std::fs::create_dir_all(paths.logs())?; let _log_guard = logging::init(&paths.logs(), &config.log_level)?; + // Started by the scheduled task, nobody sees stderr: the log has to + // say why the daemon stopped. + run(paths, config).inspect_err(|err| error!("{err:#}")) +} + +fn run(paths: Paths, config: Config) -> anyhow::Result<()> { let _lock = InstanceLock::acquire(&paths.lock_file())?; let owner_token = secrets::load_or_create_owner_token(&paths.secrets()) @@ -92,6 +123,9 @@ fn serve(config_path: Option) -> anyhow::Result<()> { info!(port = config.port, "listening on 127.0.0.1"); tokio::spawn(supervisor::run(Arc::clone(&daemon))); tokio::spawn(courier::run(Arc::clone(&daemon))); + if config.keep_awake { + tokio::spawn(keep_awake::run(daemon.supervisor.busy_bots())); + } server::serve(Arc::clone(&daemon), listener, platform::shutdown_signal()).await?; daemon.supervisor.shutdown(); info!("stopped"); diff --git a/crates/botloftd/src/paths.rs b/crates/botloftd/src/paths.rs index 3c9d2140..60286db9 100644 --- a/crates/botloftd/src/paths.rs +++ b/crates/botloftd/src/paths.rs @@ -62,17 +62,26 @@ impl Paths { } } +/// `--home` when given (the scheduled task passes it, spec 14), else /// `BOTLOFT_HOME` when set, otherwise `%LOCALAPPDATA%\Botloft` (never the -/// roaming profile). Relative overrides resolve against the current directory. -pub fn resolve_home() -> io::Result { - match std::env::var_os(HOME_ENV).filter(|v| !v.is_empty()) { +/// roaming profile). Relative paths resolve against the current directory. +pub fn resolve_home(flag: Option<&Path>) -> io::Result { + let chosen = flag + .map(|path| path.as_os_str().to_owned()) + .or_else(|| std::env::var_os(HOME_ENV)) + .filter(|value| !value.is_empty()); + match chosen { Some(home) => std::path::absolute(home), - None => dirs::data_local_dir() - .map(|dir| dir.join("Botloft")) + None => default_home() .ok_or_else(|| io::Error::other("cannot find the local application data folder")), } } +/// `%LOCALAPPDATA%\Botloft`, the data folder of an installed daemon. +pub fn default_home() -> Option { + dirs::data_local_dir().map(|dir| dir.join("Botloft")) +} + /// `workspaces_root` from the config, otherwise `%USERPROFILE%\Botloft`. pub fn resolve_workspaces_root(config: &Config) -> io::Result { match config.workspaces_root() { diff --git a/crates/botloftd/src/platform/mod.rs b/crates/botloftd/src/platform/mod.rs index 085e696e..1440b7d4 100644 --- a/crates/botloftd/src/platform/mod.rs +++ b/crates/botloftd/src/platform/mod.rs @@ -8,12 +8,43 @@ mod windows; use std::fs::{File, OpenOptions, TryLockError}; use std::io; -use std::path::Path; +use std::path::{Path, PathBuf}; #[cfg(unix)] -pub use unix::{ProcessJob, restrict_to_current_user, user_environment}; +pub use unix::{ + KeepAwake, ProcessJob, delete_task, find_task, leave_own_console, register_task, + restrict_to_current_user, run_task, stop_task, user_environment, +}; #[cfg(windows)] -pub use windows::{ProcessJob, restrict_to_current_user, user_environment}; +pub use windows::{ + KeepAwake, ProcessJob, delete_task, find_task, leave_own_console, register_task, + restrict_to_current_user, run_task, stop_task, user_environment, +}; + +/// A program the system starts when the owner logs on (spec 14). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TaskDefinition { + pub description: String, + pub program: PathBuf, + /// Already quoted for the command line. + pub arguments: String, + pub working_dir: PathBuf, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TaskState { + Running, + /// Installed and waiting for its trigger. + Ready, + Disabled, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TaskInfo { + pub state: TaskState, + pub command: Option, +} /// Exclusive lock on `\botloftd.lock`, held for the daemon's lifetime. /// The OS releases it when the process exits, even after a crash. diff --git a/crates/botloftd/src/platform/unix.rs b/crates/botloftd/src/platform/unix.rs index bf65cfca..de9772d8 100644 --- a/crates/botloftd/src/platform/unix.rs +++ b/crates/botloftd/src/platform/unix.rs @@ -5,6 +5,8 @@ use std::io; use std::os::unix::fs::PermissionsExt; use std::path::Path; +use super::{TaskDefinition, TaskInfo}; + /// `0700` for folders, `0600` for files. pub fn restrict_to_current_user(path: &Path) -> io::Result<()> { let mode = if path.is_dir() { 0o700 } else { 0o600 }; @@ -42,6 +44,20 @@ impl ProcessJob { } } +/// Nothing keeps a Unix machine awake yet. +#[derive(Debug)] +pub struct KeepAwake; + +impl KeepAwake { + pub fn new() -> io::Result { + Ok(Self) + } + + pub fn set(&mut self, _on: bool) -> io::Result<()> { + Ok(()) + } +} + /// The daemon's environment without Claude Code session variables. pub fn user_environment() -> io::Result> { Ok(std::env::vars_os() @@ -51,3 +67,33 @@ pub fn user_environment() -> io::Result> { }) .collect()) } + +/// Nothing to leave: Unix never opens a console for the daemon. +pub fn leave_own_console() {} + +fn no_tasks() -> io::Error { + io::Error::new( + io::ErrorKind::Unsupported, + "starting at logon is only supported on Windows", + ) +} + +pub fn register_task(_name: &str, _task: &TaskDefinition) -> io::Result<()> { + Err(no_tasks()) +} + +pub fn find_task(_name: &str) -> io::Result> { + Err(no_tasks()) +} + +pub fn run_task(_name: &str) -> io::Result<()> { + Err(no_tasks()) +} + +pub fn stop_task(_name: &str) -> io::Result<()> { + Err(no_tasks()) +} + +pub fn delete_task(_name: &str) -> io::Result { + Err(no_tasks()) +} diff --git a/crates/botloftd/src/platform/windows.rs b/crates/botloftd/src/platform/windows.rs index ade501df..fce4924d 100644 --- a/crates/botloftd/src/platform/windows.rs +++ b/crates/botloftd/src/platform/windows.rs @@ -1,10 +1,18 @@ -//! Windows ACLs, Job Objects, the user environment and console signals. +//! Windows ACLs, Job Objects, the user environment, power requests and +//! console signals. +mod console; mod env; mod job; +mod power; +mod task; +mod task_xml; +pub use console::leave_own_console; pub use env::user_environment; pub use job::ProcessJob; +pub use power::KeepAwake; +pub use task::{delete_task, find_task, register_task, run_task, stop_task}; use std::io; use std::os::windows::ffi::OsStrExt; @@ -14,8 +22,8 @@ use windows::Win32::Foundation::{ CloseHandle, ERROR_SUCCESS, GENERIC_ALL, HANDLE, HLOCAL, LocalFree, WIN32_ERROR, }; use windows::Win32::Security::Authorization::{ - EXPLICIT_ACCESS_W, NO_MULTIPLE_TRUSTEE, SE_FILE_OBJECT, SET_ACCESS, SetEntriesInAclW, - SetNamedSecurityInfoW, TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W, + ConvertSidToStringSidW, EXPLICIT_ACCESS_W, NO_MULTIPLE_TRUSTEE, SE_FILE_OBJECT, SET_ACCESS, + SetEntriesInAclW, SetNamedSecurityInfoW, TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W, }; use windows::Win32::Security::{ ACL, DACL_SECURITY_INFORMATION, GetTokenInformation, NO_INHERITANCE, @@ -122,6 +130,17 @@ impl CurrentUser { // SAFETY: `buf` was filled by GetTokenInformation(TokenUser). unsafe { (*self.buf.as_ptr().cast::()).User.Sid } } + + /// The SID as text (`S-1-5-21-...`). + pub(crate) fn sid_string(&self) -> io::Result { + let mut text = PWSTR::null(); + // SAFETY: `sid()` is valid while `self` lives; the string is freed + // by LocalBox. + unsafe { ConvertSidToStringSidW(self.sid(), &mut text) }?; + let _text = LocalBox(text.0.cast()); + // SAFETY: a NUL-terminated string allocated by the call above. + unsafe { text.to_string() }.map_err(io::Error::other) + } } struct OwnedHandle(HANDLE); diff --git a/crates/botloftd/src/platform/windows/console.rs b/crates/botloftd/src/platform/windows/console.rs new file mode 100644 index 00000000..7d5cddca --- /dev/null +++ b/crates/botloftd/src/platform/windows/console.rs @@ -0,0 +1,18 @@ +//! The daemon runs from the scheduled task with no window. Its manifest +//! asks for no console (`consoleAllocationPolicy`, Windows 11 24H2 and +//! later); older Windows still opens one, which the daemon leaves here. + +use windows::Win32::System::Console::{FreeConsole, GetConsoleProcessList}; + +/// Detaches from the console when no other process uses it: Windows made +/// it just for the daemon, so nobody reads it and closing it would kill +/// the daemon. A console shared with a shell (run from a terminal) stays. +pub fn leave_own_console() { + let mut processes = [0u32; 2]; + // SAFETY: the buffer is valid for its length; the call only fills it. + let attached = unsafe { GetConsoleProcessList(&mut processes) }; + if attached == 1 { + // SAFETY: detaching from a console this process alone uses. + let _ = unsafe { FreeConsole() }; + } +} diff --git a/crates/botloftd/src/platform/windows/power.rs b/crates/botloftd/src/platform/windows/power.rs new file mode 100644 index 00000000..c20bbbbc --- /dev/null +++ b/crates/botloftd/src/platform/windows/power.rs @@ -0,0 +1,70 @@ +//! A power request that keeps Windows from sleeping while bots work +//! (spec 14). Unlike `SetThreadExecutionState`, it belongs to a handle and +//! not to a thread, so any Tokio worker can set or clear it, and +//! `powercfg /requests` lists it with the reason below. + +use std::io; + +use windows::Win32::Foundation::{CloseHandle, HANDLE}; +use windows::Win32::System::Power::{ + PowerClearRequest, PowerCreateRequest, PowerRequestSystemRequired, PowerSetRequest, +}; +use windows::Win32::System::Threading::{ + POWER_REQUEST_CONTEXT_SIMPLE_STRING, REASON_CONTEXT, REASON_CONTEXT_0, +}; +use windows::core::PWSTR; + +/// `POWER_REQUEST_CONTEXT_VERSION`. +const CONTEXT_VERSION: u32 = 0; +const REASON: &str = "Botloft bots are working"; + +#[derive(Debug)] +pub struct KeepAwake { + handle: HANDLE, + on: bool, +} + +// SAFETY: a power request handle can be used and closed from any thread. +unsafe impl Send for KeepAwake {} + +impl KeepAwake { + pub fn new() -> io::Result { + let mut reason: Vec = REASON.encode_utf16().chain(Some(0)).collect(); + let context = REASON_CONTEXT { + Version: CONTEXT_VERSION, + Flags: POWER_REQUEST_CONTEXT_SIMPLE_STRING, + Reason: REASON_CONTEXT_0 { + SimpleReasonString: PWSTR(reason.as_mut_ptr()), + }, + }; + // SAFETY: `context` and the string it points at outlive the call, + // and the request keeps its own copy of the reason. + let handle = unsafe { PowerCreateRequest(&context) }?; + Ok(Self { handle, on: false }) + } + + /// Holds the system awake, or lets it sleep again. + pub fn set(&mut self, on: bool) -> io::Result<()> { + if on == self.on { + return Ok(()); + } + // SAFETY: `handle` is a live power request owned by `self`. + unsafe { + if on { + PowerSetRequest(self.handle, PowerRequestSystemRequired) + } else { + PowerClearRequest(self.handle, PowerRequestSystemRequired) + } + }?; + self.on = on; + Ok(()) + } +} + +impl Drop for KeepAwake { + fn drop(&mut self) { + let _ = self.set(false); + // SAFETY: closed exactly once. + let _ = unsafe { CloseHandle(self.handle) }; + } +} diff --git a/crates/botloftd/src/platform/windows/task.rs b/crates/botloftd/src/platform/windows/task.rs new file mode 100644 index 00000000..dd89a366 --- /dev/null +++ b/crates/botloftd/src/platform/windows/task.rs @@ -0,0 +1,159 @@ +//! The per-user scheduled task that starts the daemon at logon (spec 14), +//! through the Task Scheduler COM API. `schtasks.exe` would do the same, +//! but its messages are localized and cannot be parsed. + +use std::io; + +use windows::Win32::Foundation::{ERROR_FILE_NOT_FOUND, RPC_E_CHANGED_MODE}; +use windows::Win32::System::Com::{ + CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize, +}; +use windows::Win32::System::TaskScheduler::{ + IRegisteredTask, ITaskFolder, ITaskService, TASK_CREATE_OR_UPDATE, + TASK_LOGON_INTERACTIVE_TOKEN, TASK_STATE_DISABLED, TASK_STATE_QUEUED, TASK_STATE_READY, + TASK_STATE_RUNNING, TaskScheduler, +}; +use windows::Win32::System::Variant::VARIANT; +use windows::core::BSTR; + +use super::CurrentUser; +use super::task_xml; +use crate::platform::{TaskDefinition, TaskInfo, TaskState}; + +/// COM for the current thread, released on drop. +struct Com { + uninit: bool, +} + +impl Com { + fn init() -> io::Result { + // SAFETY: plain COM initialization for this thread. + let status = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }; + if status == RPC_E_CHANGED_MODE { + // Already initialized with another model; that works too. + return Ok(Self { uninit: false }); + } + status.ok()?; + Ok(Self { uninit: true }) + } +} + +impl Drop for Com { + fn drop(&mut self) { + if self.uninit { + // SAFETY: pairs with the successful CoInitializeEx above. + unsafe { CoUninitialize() }; + } + } +} + +/// The root folder of the current user's Task Scheduler. +struct Scheduler { + // Declared first so the folder is released before COM goes away. + folder: ITaskFolder, + _com: Com, +} + +impl Scheduler { + fn connect() -> io::Result { + let com = Com::init()?; + // SAFETY: COM is initialized on this thread; empty VARIANTs mean the + // local machine and the current user. + let folder = unsafe { + let service: ITaskService = + CoCreateInstance(&TaskScheduler, None, CLSCTX_INPROC_SERVER)?; + let empty = VARIANT::default(); + service.Connect(&empty, &empty, &empty, &empty)?; + service.GetFolder(&BSTR::from("\\"))? + }; + Ok(Self { folder, _com: com }) + } + + fn task(&self, name: &str) -> io::Result> { + // SAFETY: `folder` is a live COM object. + match unsafe { self.folder.GetTask(&BSTR::from(name)) } { + Ok(task) => Ok(Some(task)), + Err(err) if err.code() == ERROR_FILE_NOT_FOUND.to_hresult() => Ok(None), + Err(err) => Err(err.into()), + } + } + + fn existing(&self, name: &str) -> io::Result { + self.task(name)?.ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!("the scheduled task {name} does not exist"), + ) + }) + } +} + +/// Creates the task, or replaces its definition if it exists. It runs as +/// the current user, only while they are logged on. +pub fn register_task(name: &str, task: &TaskDefinition) -> io::Result<()> { + let xml = task_xml::render(task, &CurrentUser::query()?.sid_string()?); + let scheduler = Scheduler::connect()?; + let empty = VARIANT::default(); + // SAFETY: `folder` is a live COM object and every argument outlives the call. + unsafe { + scheduler.folder.RegisterTask( + &BSTR::from(name), + &BSTR::from(xml.as_str()), + TASK_CREATE_OR_UPDATE.0, + &empty, + &empty, + TASK_LOGON_INTERACTIVE_TOKEN, + &empty, + ) + }?; + Ok(()) +} + +/// The task's state and command line, or `None` if it does not exist. +pub fn find_task(name: &str) -> io::Result> { + let scheduler = Scheduler::connect()?; + let Some(task) = scheduler.task(name)? else { + return Ok(None); + }; + // SAFETY: `task` is a live COM object. + let (state, xml) = unsafe { (task.State()?, task.Xml()?) }; + let state = match state { + TASK_STATE_RUNNING => TaskState::Running, + TASK_STATE_READY | TASK_STATE_QUEUED => TaskState::Ready, + TASK_STATE_DISABLED => TaskState::Disabled, + _ => TaskState::Unknown, + }; + Ok(Some(TaskInfo { + state, + command: task_xml::command_line(&xml.to_string()), + })) +} + +/// Starts the task now. A running task ignores it. +pub fn run_task(name: &str) -> io::Result<()> { + let scheduler = Scheduler::connect()?; + let task = scheduler.existing(name)?; + // SAFETY: `task` is a live COM object. + unsafe { task.Run(&VARIANT::default()) }?; + Ok(()) +} + +/// Ends every running instance of the task, which kills its process. +pub fn stop_task(name: &str) -> io::Result<()> { + let scheduler = Scheduler::connect()?; + let task = scheduler.existing(name)?; + // SAFETY: `task` is a live COM object. + unsafe { task.Stop(0) }?; + Ok(()) +} + +/// Deletes the task. Returns whether it existed. +pub fn delete_task(name: &str) -> io::Result { + let scheduler = Scheduler::connect()?; + // SAFETY: `folder` is a live COM object. + match unsafe { scheduler.folder.DeleteTask(&BSTR::from(name), 0) } { + Ok(()) => Ok(true), + Err(err) if err.code() == ERROR_FILE_NOT_FOUND.to_hresult() => Ok(false), + Err(err) => Err(err.into()), + } +} diff --git a/crates/botloftd/src/platform/windows/task_xml.rs b/crates/botloftd/src/platform/windows/task_xml.rs new file mode 100644 index 00000000..1966eb1b --- /dev/null +++ b/crates/botloftd/src/platform/windows/task_xml.rs @@ -0,0 +1,171 @@ +//! The definition of the daemon's scheduled task, in Task Scheduler XML. +//! +//! - A logon trigger for the current user starts the daemon at logon. +//! - A time trigger that began in the past and repeats every minute keeps +//! it running: with `IgnoreNew`, a repetition does nothing while the +//! daemon runs and starts it again within a minute after it dies. The +//! logon trigger cannot repeat for this: its repetition only begins at +//! the next logon, not when the task is installed. `RestartOnFailure` +//! is not used either: it does not fire when the process exits with an +//! error (both tested on Windows 11 25H2, spec 14). +//! - Interactive token, least privilege: only while the user is logged on, +//! in their session, with their Claude Code sign-in. +//! - No time limit, runs on battery, normal priority. The default priority +//! (7) would pass "below normal" on to every bot. + +use crate::platform::TaskDefinition; + +pub(super) fn render(task: &TaskDefinition, user_sid: &str) -> String { + let sid = escape(user_sid); + let description = escape(&task.description); + let command = escape(&task.program.to_string_lossy()); + let arguments = escape(&task.arguments); + let working_dir = escape(&task.working_dir.to_string_lossy()); + format!( + r#" + + + Botloft + {description} + + + + true + {sid} + + + true + 2026-01-01T00:00:00 + + PT1M + false + + + + + + {sid} + InteractiveToken + LeastPrivilege + + + + IgnoreNew + false + false + true + true + false + + false + false + + true + true + false + false + false + PT0S + 5 + + + + {command} + {arguments} + {working_dir} + + + +"# + ) +} + +/// The command line of the task's first action, as the owner would type it. +pub(super) fn command_line(xml: &str) -> Option { + let command = unescape(element(xml, "Command")?); + let quoted = if command.contains(' ') { + format!("\"{command}\"") + } else { + command + }; + Some(match element(xml, "Arguments") { + Some(arguments) => format!("{quoted} {}", unescape(arguments)), + None => quoted, + }) +} + +fn element<'a>(xml: &'a str, name: &str) -> Option<&'a str> { + let open = format!("<{name}>"); + let start = xml.find(&open)? + open.len(); + let end = start + xml[start..].find(&format!(""))?; + Some(&xml[start..end]) +} + +fn escape(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for ch in text.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(ch), + } + } + out +} + +fn unescape(text: &str) -> String { + text.replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&") +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + fn task() -> TaskDefinition { + TaskDefinition { + description: "Botloft & friends".into(), + program: PathBuf::from(r"C:\Users\Ana Lima\AppData\Local\Botloft\bin\botloftd.exe"), + arguments: r#"serve --home "C:\Users\Ana Lima\AppData\Local\Botloft""#.into(), + working_dir: PathBuf::from(r"C:\Users\Ana Lima\AppData\Local\Botloft"), + } + } + + #[test] + fn the_definition_escapes_text_and_runs_for_the_user_at_logon() { + let xml = render(&task(), "S-1-5-21-1-2-3-1001"); + assert!(xml.contains("Botloft <daemon> & friends")); + assert!(xml.contains( + "serve --home "C:\\Users\\Ana Lima\\AppData\\Local\\Botloft"" + )); + assert_eq!( + xml.matches("S-1-5-21-1-2-3-1001").count(), + 2 + ); + assert!(xml.contains("")); + assert!(xml.contains("PT1M")); + assert!(xml.contains("InteractiveToken")); + assert!(xml.contains("IgnoreNew")); + assert!(xml.contains("PT0S")); + } + + #[test] + fn the_command_line_comes_back_out_of_the_definition() { + let xml = render(&task(), "S-1-5-21-1-2-3-1001"); + assert_eq!( + command_line(&xml).as_deref(), + Some( + r#""C:\Users\Ana Lima\AppData\Local\Botloft\bin\botloftd.exe" serve --home "C:\Users\Ana Lima\AppData\Local\Botloft""# + ) + ); + assert_eq!(command_line(""), None); + } +} diff --git a/crates/botloftd/src/supervisor/mod.rs b/crates/botloftd/src/supervisor/mod.rs index cc3358de..fe0674b6 100644 --- a/crates/botloftd/src/supervisor/mod.rs +++ b/crates/botloftd/src/supervisor/mod.rs @@ -20,7 +20,7 @@ use botloft_core::ids::BotId; use botloft_core::now_ms; use botloft_core::protocol::{BotState, BotStateChanged}; use bytes::Bytes; -use tokio::sync::{Notify, broadcast}; +use tokio::sync::{Notify, broadcast, watch}; use tokio::time::Instant; use tracing::warn; @@ -50,6 +50,8 @@ pub struct Supervisor { next_generation: AtomicU64, reconcile_lock: tokio::sync::Mutex<()>, wake: Notify, + /// How many bots are `busy`, for keeping the computer awake (spec 14). + busy: watch::Sender, } #[derive(Default)] @@ -90,6 +92,7 @@ impl Supervisor { next_generation: AtomicU64::new(u64::try_from(now_ms()).unwrap_or(1)), reconcile_lock: tokio::sync::Mutex::new(()), wake: Notify::new(), + busy: watch::Sender::new(0), } } @@ -113,6 +116,11 @@ impl Supervisor { .map(|slot| (slot.state, slot.generation)) } + /// Follows how many bots are `busy`. + pub fn busy_bots(&self) -> watch::Receiver { + self.busy.subscribe() + } + pub fn claude_version(&self) -> Option { match &self.lock().claude { ClaudeStatus::Ready(claude) => Some(claude.version.clone()), @@ -194,11 +202,27 @@ impl Supervisor { fn set_state(&self, bot: &BotId, slot: &mut Slot, state: BotState) { if slot.state != state { + self.count_busy(slot.state, state); slot.state = state; self.announce(bot, slot); } } + /// Keeps the busy count in step with a state change. Every change of + /// `slot.state` goes through here. + fn count_busy(&self, from: BotState, to: BotState) { + let (was, is) = (from == BotState::Busy, to == BotState::Busy); + if was != is { + self.busy.send_modify(|count| { + *count = if is { + *count + 1 + } else { + count.saturating_sub(1) + }; + }); + } + } + /// Emits `bot.state`. A new process always announces itself, even when /// the state name did not change, because its generation did. fn announce(&self, bot: &BotId, slot: &Slot) { diff --git a/crates/botloftd/src/supervisor/spawn.rs b/crates/botloftd/src/supervisor/spawn.rs index 9b2ff1bd..a6dc92e9 100644 --- a/crates/botloftd/src/supervisor/spawn.rs +++ b/crates/botloftd/src/supervisor/spawn.rs @@ -73,6 +73,7 @@ impl Supervisor { resumed: launch.resumed, token_hash: launch.token_hash, }); + self.count_busy(slot.state, BotState::Launching); slot.state = BotState::Launching; self.announce(&bot.id, slot); debug!(bot = %bot.id, generation, pid = ?process.pid, resumed = launch.resumed, "bot started"); diff --git a/crates/botloftd/tests/supervisor_turns.rs b/crates/botloftd/tests/supervisor_turns.rs index b8f69308..3077a48c 100644 --- a/crates/botloftd/tests/supervisor_turns.rs +++ b/crates/botloftd/tests/supervisor_turns.rs @@ -40,6 +40,35 @@ async fn turns_and_approvals_drive_the_state() { assert_eq!(s.state(), BotState::Idle); } +/// What keeps the computer awake (spec 14): only `busy` counts. +#[tokio::test(start_paused = true)] +async fn the_busy_count_follows_turns_approvals_and_crashes() { + let s = setup().await; + let busy = s.daemon.supervisor.busy_bots(); + let process = s.runtime.process(1).await; + s.until(BotState::Idle).await; + assert_eq!(*busy.borrow(), 0); + let generation = s.message("work"); + assert_eq!(*busy.borrow(), 1); + + s.daemon.supervisor.approval_opened(&s.bot, generation); + assert_eq!(*busy.borrow(), 0, "waiting for the owner is not working"); + s.daemon.supervisor.approval_closed(&s.bot, generation); + assert_eq!(*busy.borrow(), 1); + process.emit(stream::result(false)).await; + s.until(BotState::Idle).await; + assert_eq!(*busy.borrow(), 0); + + s.message("again"); + assert_eq!(*busy.borrow(), 1); + process.exit(1).await; + s.until(BotState::Backoff).await; + assert_eq!(*busy.borrow(), 0, "a dead process works no more"); + s.runtime.process(2).await; + s.until(BotState::Idle).await; + assert_eq!(*busy.borrow(), 0); +} + #[tokio::test(start_paused = true)] async fn sign_in_errors_stop_the_bot_until_the_owner_restarts_it() { let s = setup().await; diff --git a/docs/spec.md b/docs/spec.md index 22da01ca..ff0960e1 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -491,11 +491,14 @@ Pragmas: `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout=5000`. Migrations | Tema | Solução | |---|---| -| Iniciar com o Windows | `botloftd service install` cria **Tarefa Agendada por usuário** ("ao fazer logon", reiniciar em falha a cada 1 min, sem limite de execução, só com o usuário logado). Não usar Windows Service: roda em outra sessão e sem acesso à autenticação do Claude Code do usuário. Subcomandos `service status`, `service restart`, `service uninstall`. | -| Binário instalado | `%LOCALAPPDATA%\Botloft\bin\botloftd.exe` (copiado do sidecar do app) | -| Não suspender | `SetThreadExecutionState(ES_CONTINUOUS \| ES_SYSTEM_REQUIRED)` enquanto houver bot `busy`, se `keep_awake = true` | +| Iniciar com o Windows | `botloftd service install` registra uma **Tarefa Agendada por usuário**, pela API COM do Agendador (as mensagens do `schtasks.exe` são traduzidas e não dá para lê-las). Dois gatilhos: "ao fazer logon" do usuário, que sobe o daemon na hora, e um gatilho de horário com início no passado repetido **a cada 1 min**, que o traz de volta se ele morrer: com `MultipleInstancesPolicy = IgnoreNew`, a repetição não faz nada enquanto o daemon roda. Token interativo e privilégio mínimo (só com o usuário logado, na sessão dele), sem limite de execução, roda na bateria, prioridade 5 (a padrão, 7, passaria "abaixo do normal" para todos os bots). A ação é `\bin\botloftd.exe serve --home `. Não usar Windows Service: roda em outra sessão e sem acesso à autenticação do Claude Code do usuário. Subcomandos `service status`, `service restart`, `service uninstall` (os dados ficam). | +| Reinício da tarefa (testado no Windows 11 25H2) | `RestartOnFailure` **não** reinicia a tarefa quando o processo sai com código de erro, nem numa execução por gatilho de horário nem numa sob demanda. A repetição de um gatilho de logon só começa no próximo logon, não quando a tarefa é registrada. Por isso o gatilho de horário faz o papel de vigia: morto o daemon, ele voltou em 43 s | +| Uma tarefa por pasta de dados | `Botloft` para `%LOCALAPPDATA%\Botloft`; `Botloft-<8 hex do SHA-256 do caminho>` para outra pasta (`--home` ou `BOTLOFT_HOME` de dev), para instalar um daemon de dev sem tocar no real. `--home` vale para todos os subcomandos e vem antes de `BOTLOFT_HOME` | +| Binário instalado | `service install` copia o próprio executável para `\bin\botloftd.exe` e a tarefa roda essa cópia, nunca a do app: assim o instalador do app troca os arquivos dele com o daemon rodando. Um exe em uso não pode ser sobrescrito mas pode ser renomeado, então o antigo vai para `botloftd..old` e é apagado numa instalação seguinte. Se o binário não mudou e o daemon dessa versão já roda pela tarefa, `install` não o reinicia; senão para a tarefa, espera o `/health` sumir, inicia de novo e espera o `/health` com a nova versão (20 s) | +| Janela de console | O manifesto do daemon pede `consoleAllocationPolicy = detached` (Windows 11 24H2 e depois): iniciado pela tarefa, ele não ganha console nem janela; num terminal, continua usando o console do terminal. Em Windows mais antigo, `serve` larga o console se for o único processo nele (`FreeConsole`). O manifesto entra como recurso (`embed-resource`), porque a ferramenta de manifesto do linker não conhece o elemento e avisa a cada build | +| Não suspender | Um power request (`PowerCreateRequest` + `PowerSetRequest(PowerRequestSystemRequired)`, motivo "Botloft bots are working") enquanto houver bot `busy`, se `keep_awake = true`. Bot esperando aprovação não conta. Diferente de `SetThreadExecutionState`, o pedido é de um handle e não de uma thread, então qualquer worker do Tokio o liga e desliga, e `powercfg /requests` o mostra. Não impede a suspensão pedida pelo usuário (tampa, menu Iniciar) | | Processos órfãos | Job Object por bot (seção 7.3) | -| Encerramento | `ctrl_c`, `ctrl_close`, `ctrl_shutdown`, `ctrl_logoff` do Tokio: parar courier, sinalizar bots, flush do banco | +| Encerramento | `ctrl_c`, `ctrl_close`, `ctrl_shutdown`, `ctrl_logoff` do Tokio: parar courier, sinalizar bots, flush do banco. Esses sinais são de console: iniciado pela tarefa, sem console, o daemon não tem garantia de recebê-los, e o Windows encerra o processo no logoff e no desligamento. Isso equivale a um crash, e o sistema já é feito para ele (mensagens gravadas antes de sair, bots mortos pelo Job Object, aprovações abertas expiram no próximo start). Um erro fatal na subida vai para o log, porque ninguém vê o stderr da tarefa | | Caminhos longos | manifesto `longPathAware` no daemon e no app; usar APIs com `\\?\` ao apagar árvores de workspace | | Arquivo em uso | arquivar bot só apaga workspace depois do processo encerrado; retentar exclusão se bloqueado | | Instância única | lock exclusivo em `\botloftd.lock` (`File::try_lock`), solto pelo sistema quando o processo termina, mesmo em crash; se já estiver preso, sair com erro claro. É um lock por pasta de dados, e não um mutex de nome fixo, para o daemon de dev (`BOTLOFT_HOME`) rodar ao lado do instalado | @@ -551,7 +554,7 @@ Layout, como um app de mensagens: |---|---| | `daemon_status` | GET `/health` local; diz se o daemon roda, está parado ou se outro programa ocupa a porta | | `daemon_start` | (M4) inicia o `botloftd.exe` ao lado do executável do app, destacado (sem console, fora do job do app) para seguir vivo quando o app fecha, e espera o `/health` por até 15 s. No M5 o `daemon_install` o substitui | -| `daemon_install` | copia sidecar e roda `botloftd service install` | +| `daemon_install` | roda `botloftd service install` a partir do sidecar (o `botloftd.exe` ao lado do app), que se copia para `\bin` e registra e inicia a tarefa (seção 14) | | `daemon_restart` | `botloftd service restart` | | `read_owner_token` | lê `secrets\owner.token` (só no app local) | | `open_path` | abre uma pasta no Explorer; recusa arquivos, que o Explorer executaria |