diff --git a/.gitignore b/.gitignore index f0d8e21a..873940ff 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ # App node_modules/ /app/dist/ +# Sidecar copied in by app/scripts/sidecar.mjs before `pnpm bundle` +/app/src-tauri/binaries/ *.local # Local dev state (BOTLOFT_HOME, dev config) diff --git a/CLAUDE.md b/CLAUDE.md index 99a3b200..39ae825a 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 @@ -38,9 +39,10 @@ cargo test -p botloft-core export_bindings # App cd app pnpm install -pnpm tauri dev # o app acha o daemon pelo BOTLOFT_HOME e o inicia (target\debug\botloftd.exe) +pnpm tauri dev # o app acha o daemon pelo BOTLOFT_HOME; "Start the daemon" instala target\debug\botloftd.exe como tarefa da pasta de dev pnpm dev # só a UI num navegador comum, com o FakeBotloft (src/dev/preview.ts) pnpm check # tsc --noEmit + biome check + vitest run +pnpm bundle # instalador NSIS com o sidecar (spec 15.4), em target\release\bundle\nsis ``` Em dev, use `$env:BOTLOFT_HOME = "$PWD\.dev\home"` (caminho absoluto) no daemon e no app, para não tocar na instalação real em `%LOCALAPPDATA%\Botloft`. O `config.toml` de dev fica dentro dessa pasta: o app lê a porta de lá. @@ -68,6 +70,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/README.md b/README.md index 2dcca86f..fae20cfa 100644 --- a/README.md +++ b/README.md @@ -3,20 +3,26 @@ Always-on Claude Code crews for Windows. Botloft keeps groups of persistent Claude Code bots ("crews") running on your machine and lets -them hand work to each other. A Rust daemon, `botloftd`, runs each bot in its own ConPTY, stores -everything in SQLite and delivers messages through Claude Code's native inbox. A Tauri + React -desktop app shows the live terminals and controls the daemon over JSON-RPC. +them hand work to each other. A Rust daemon, `botloftd`, runs each bot as headless Claude Code, +stores everything in SQLite and delivers messages between bots durably. A Tauri + React desktop +app shows each bot as a chat: you talk to it, send it images and files, and approve what it asks +to do. -> **Status: pre-alpha.** The repository is at milestone M0 (foundation): the workspace, an empty -> app and CI. Nothing runs bots yet. The design lives in [`docs/spec.md`](docs/spec.md). +> **Status: pre-alpha.** Milestones M0 to M4.1 are done: the daemon, the chat, messages and +> tasks between bots. M5 (distribution) is in progress. The design lives in +> [`docs/spec.md`](docs/spec.md). ## How it works -- **The daemon is the source of truth.** Closing the app never stops a bot. -- **Claude Code is the runtime.** Each bot is a real `claude` session with its own workspace, - memory (`CLAUDE.md`) and permission prompts. There is no custom agent SDK. -- **Terminal and messages are separate channels.** What you type goes through the PTY; messages - from other bots arrive through Claude Code's inbox and are never injected into the terminal. +- **The daemon is the source of truth.** Closing the app never stops a bot. The daemon runs as a + scheduled task of your Windows user: it starts when you sign in and comes back within a minute + if it dies. +- **Claude Code is the runtime.** Each bot is a real `claude -p` session with its own workspace, + memory (`CLAUDE.md`) and conversation, talking `stream-json` over stdin and stdout. There is no + custom agent SDK. +- **One conversation per bot.** Your messages, messages from other bots and notices from the + daemon all reach the bot in order. What it does comes back as a chat: replies, tool use and + permission requests you allow or deny. - **Durable before delivered.** Every message is written to SQLite before it is sent and is retried until it is delivered or marked dead. - **Local only.** The daemon listens on `127.0.0.1`. No cloud services, no telemetry. @@ -34,7 +40,7 @@ docs/ specification and ADRs ## Development Requirements: Windows 10 or 11, Rust stable (MSVC toolchain), Node.js 24 and pnpm. Running real -bots (milestone M2 onwards) also needs Claude Code installed and signed in. +bots also needs Claude Code (the native `claude.exe`) installed and signed in. ```powershell # Daemon and libraries @@ -48,16 +54,31 @@ cd app pnpm install pnpm tauri dev pnpm check # typecheck + Biome + Vitest +pnpm bundle # NSIS installer with the daemon inside ``` -Set `BOTLOFT_HOME=.dev\home` during development so the daemon never touches a real installation -in `%LOCALAPPDATA%\Botloft`. +Set `BOTLOFT_HOME` to an absolute path such as `$PWD\.dev\home` during development so the daemon +never touches a real installation in `%LOCALAPPDATA%\Botloft`. + +The daemon manages its own scheduled task: + +```powershell +botloftd service install # copy botloftd into the data folder, start it now and at every logon +botloftd service status +botloftd service restart +botloftd service uninstall # stop it and remove the task; bots and data stay +``` + +Each data folder gets its own task, so a dev daemon installed with `BOTLOFT_HOME` (or `--home`) +never replaces the real one. ## Security model - The daemon binds to `127.0.0.1` only. The app authenticates with an owner token stored under `%LOCALAPPDATA%\Botloft\secrets`, readable only by your Windows user. -- Each bot gets its own token per process start; the database keeps only its SHA-256 hash. +- Each bot gets its own token per process start; the daemon keeps only its SHA-256 hash. +- Bots ask in their chat before they run commands or edit files, and wait for you to allow or + deny each request. - **Botloft is not a sandbox.** Every bot runs as your Windows user, so isolation between bots is cooperative: a bot can read other bots' workspaces if it decides to. Give bots only the permissions you would give Claude Code directly. @@ -68,10 +89,11 @@ in `%LOCALAPPDATA%\Botloft`. |---|---| | M0 | Workspace, empty app, CI | | M1 | Daemon base: config, storage, RPC, crews and bots | -| M2 | Runtime: ConPTY, supervisor, hooks, terminal replay | +| M2 | Runtime: supervisor, restarts with backoff, Job Objects | | M3 | Messaging: durable delivery, bot tools, tasks between bots | -| M4 | Desktop app: onboarding, crews, bots, terminals, timeline | -| M5 | Distribution: start with Windows, installer, updater | +| M4 | Desktop app: onboarding, crews, bots, timeline | +| M4.1 | Headless bots with a chat, approvals, attachments | +| M5 | Distribution: start with Windows, keep awake, installer, updater | ## License diff --git a/app/package.json b/app/package.json index 0f1c2141..0fdb9f03 100644 --- a/app/package.json +++ b/app/package.json @@ -10,6 +10,7 @@ "build": "pnpm typecheck && vite build", "preview": "vite preview", "tauri": "tauri", + "bundle": "node scripts/sidecar.mjs && tauri build --config src-tauri/tauri.bundle.conf.json", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json", "lint": "biome check .", "format": "biome check --write .", diff --git a/app/scripts/sidecar.mjs b/app/scripts/sidecar.mjs new file mode 100644 index 00000000..e345f7aa --- /dev/null +++ b/app/scripts/sidecar.mjs @@ -0,0 +1,26 @@ +// Builds botloftd for release and puts it where `tauri build` looks for the +// sidecar: src-tauri/binaries/botloftd-.exe (spec 15.4). +// The installer then ships it next to the app as botloftd.exe. + +import { execFileSync } from "node:child_process"; +import { copyFileSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const app = join(dirname(fileURLToPath(import.meta.url)), ".."); +const root = join(app, ".."); +const target = process.env.CARGO_TARGET_DIR ?? join(root, "target"); +const exe = process.platform === "win32" ? ".exe" : ""; + +const rustc = execFileSync("rustc", ["-vV"], { encoding: "utf8" }); +const triple = rustc.match(/^host: (\S+)$/m)?.[1]; +if (!triple) { + throw new Error("rustc -vV did not print the host triple"); +} + +execFileSync("cargo", ["build", "--release", "-p", "botloftd"], { cwd: root, stdio: "inherit" }); +const binaries = join(app, "src-tauri", "binaries"); +mkdirSync(binaries, { recursive: true }); +const sidecar = join(binaries, `botloftd-${triple}${exe}`); +copyFileSync(join(target, "release", `botloftd${exe}`), sidecar); +console.log(`sidecar: ${sidecar}`); diff --git a/app/src-tauri/build.rs b/app/src-tauri/build.rs index d860e1e6..134c72a5 100644 --- a/app/src-tauri/build.rs +++ b/app/src-tauri/build.rs @@ -1,3 +1,7 @@ fn main() { - tauri_build::build() + println!("cargo:rerun-if-changed=windows/app.manifest"); + let windows = + tauri_build::WindowsAttributes::new().app_manifest(include_str!("windows/app.manifest")); + tauri_build::try_build(tauri_build::Attributes::new().windows_attributes(windows)) + .expect("failed to run tauri-build"); } diff --git a/app/src-tauri/src/daemon.rs b/app/src-tauri/src/daemon.rs index faa6ba42..8a8de91d 100644 --- a/app/src-tauri/src/daemon.rs +++ b/app/src-tauri/src/daemon.rs @@ -1,15 +1,19 @@ -//! Finding, checking and starting the local daemon (spec 15.2). +//! Finding, checking and installing the local daemon (spec 15.2). //! //! The app resolves the daemon's data folder the way the daemon does //! (spec 5): `BOTLOFT_HOME`, else `%LOCALAPPDATA%\Botloft`. The port comes //! from `config.toml` in that folder, so a dev daemon with its own //! `BOTLOFT_HOME` is found without extra setup. +//! +//! The app ships `botloftd.exe` next to itself (the sidecar) and installs +//! it with `botloftd service install`, which copies it into the data +//! folder and runs it as a scheduled task (spec 14). use std::io::{Read as _, Write as _}; use std::net::{Ipv4Addr, SocketAddr, TcpStream}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -use std::time::{Duration, Instant}; +use std::time::Duration; use serde::{Deserialize, Serialize}; @@ -17,8 +21,8 @@ const HOME_ENV: &str = "BOTLOFT_HOME"; const DEFAULT_PORT: u16 = 45710; const CONNECT_TIMEOUT: Duration = Duration::from_millis(500); const READ_TIMEOUT: Duration = Duration::from_secs(2); -/// How long a freshly started daemon may take to answer `/health`. -const START_TIMEOUT: Duration = Duration::from_secs(15); +/// The daemon this app ships has the app's version (one Cargo workspace). +const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); /// Where the daemon lives. #[derive(Debug, Clone)] @@ -36,6 +40,8 @@ pub enum DaemonStatus { port: u16, version: String, protocol: u32, + /// Older than the daemon this app ships: installing replaces it. + outdated: bool, }, /// Nothing listens on the port. #[serde(rename_all = "camelCase")] @@ -83,6 +89,7 @@ pub fn status(endpoint: &Endpoint) -> DaemonStatus { match health(endpoint.port) { Probe::Healthy(health) => DaemonStatus::Running { port: endpoint.port, + outdated: is_older(&health.version, APP_VERSION), version: health.version, protocol: health.protocol, }, @@ -127,26 +134,65 @@ fn health(port: u16) -> Probe { } } -/// Starts `botloftd.exe serve` from the app's folder, detached so it keeps -/// running after the app closes (spec 1), and waits for it to answer. -pub fn start(endpoint: &Endpoint) -> Result { - if let running @ DaemonStatus::Running { .. } = status(endpoint) { - return Ok(running); +/// Whether version `a` comes before `b` (`major.minor.patch`; a +/// pre-release suffix is ignored). Unreadable versions are never older. +fn is_older(a: &str, b: &str) -> bool { + fn parse(version: &str) -> Option<(u64, u64, u64)> { + let core = version.split(['-', '+']).next()?; + let mut parts = core.split('.').map(|part| part.parse::().ok()); + Some((parts.next()??, parts.next()??, parts.next()??)) } + matches!((parse(a), parse(b)), (Some(a), Some(b)) if a < b) +} + +/// Installs the daemon this app ships and starts it: `botloftd service +/// install` copies it into the data folder, registers the scheduled task +/// and waits for the daemon to answer (spec 14). +pub fn install(endpoint: &Endpoint) -> Result { + run_service(endpoint, "install")?; + Ok(status(endpoint)) +} + +/// Stops the daemon and starts it again from its scheduled task. +pub fn restart(endpoint: &Endpoint) -> Result { + run_service(endpoint, "restart")?; + Ok(status(endpoint)) +} + +/// Runs `botloftd --home service ` without a window and +/// returns its error message if it fails. +fn run_service(endpoint: &Endpoint, command: &str) -> Result<(), String> { let binary = daemon_binary()?; - spawn_detached(&binary).map_err(|err| format!("cannot start {}: {err}", binary.display()))?; - let started = Instant::now(); - loop { - let current = status(endpoint); - if matches!(current, DaemonStatus::Running { .. }) || started.elapsed() > START_TIMEOUT { - return Ok(current); - } - std::thread::sleep(Duration::from_millis(200)); + let mut process = Command::new(&binary); + process + .arg("--home") + .arg(&endpoint.home) + .args(["service", command]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + process.creation_flags(CREATE_NO_WINDOW); + } + let output = process + .output() + .map_err(|err| format!("cannot run {}: {err}", binary.display()))?; + if output.status.success() { + return Ok(()); } + let message = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + Err(if message.is_empty() { + format!("botloftd service {command} failed ({})", output.status) + } else { + message + }) } -/// `botloftd.exe` next to the app: the installer puts it there (M5), and -/// in dev both land in `target\debug`. +/// `botloftd.exe` next to the app: the installer puts it there, and in dev +/// both land in `target\debug`. fn daemon_binary() -> Result { let app = std::env::current_exe().map_err(|err| err.to_string())?; let binary = app.with_file_name(if cfg!(windows) { @@ -164,31 +210,6 @@ fn daemon_binary() -> Result { } } -fn spawn_detached(binary: &Path) -> std::io::Result<()> { - let mut command = Command::new(binary); - command - .arg("serve") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt as _; - const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; - const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x0100_0000; - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - let flags = CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP; - // Leave the app's job, if it runs in one that would kill children - // on close. Jobs that forbid breaking away refuse the flag. - command.creation_flags(flags | CREATE_BREAKAWAY_FROM_JOB); - if command.spawn().is_ok() { - return Ok(()); - } - command.creation_flags(flags); - } - command.spawn().map(drop) -} - /// The owner token the daemon wrote on its first start (spec 13). pub fn owner_token(endpoint: &Endpoint) -> Result { let path = endpoint.home.join("secrets").join("owner.token"); @@ -226,6 +247,17 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn versions_compare_by_number() { + assert!(is_older("0.1.0", "0.2.0")); + assert!(is_older("0.9.3", "0.10.0")); + assert!(is_older("1.2.3-beta.1", "1.2.4")); + assert!(!is_older("0.2.0", "0.2.0")); + assert!(!is_older("0.3.0", "0.2.9")); + assert!(!is_older("dev", "0.2.0")); + assert!(!is_older("0.1", "0.2.0")); + } + #[test] fn a_closed_port_reads_as_stopped() { // Bind and drop to find a port nothing listens on. diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 449c1646..64f0c0cf 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -22,11 +22,17 @@ async fn daemon_status() -> Result { blocking(|| daemon::endpoint().map(|endpoint| daemon::status(&endpoint))).await } -/// Starts the daemon next to the app when it is not running (M4; M5 -/// installs it as a scheduled task instead). +/// Installs the daemon this app ships as a scheduled task and starts it, +/// replacing an older one (spec 14). #[tauri::command] -async fn daemon_start() -> Result { - blocking(|| daemon::start(&daemon::endpoint()?)).await +async fn daemon_install() -> Result { + blocking(|| daemon::install(&daemon::endpoint()?)).await +} + +/// Stops the daemon and starts it again from its scheduled task. +#[tauri::command] +async fn daemon_restart() -> Result { + blocking(|| daemon::restart(&daemon::endpoint()?)).await } #[tauri::command] @@ -85,7 +91,8 @@ pub fn run() { tauri::Builder::default() .invoke_handler(tauri::generate_handler![ daemon_status, - daemon_start, + daemon_install, + daemon_restart, read_owner_token, open_path, open_url diff --git a/app/src-tauri/tauri.bundle.conf.json b/app/src-tauri/tauri.bundle.conf.json new file mode 100644 index 00000000..775c0d51 --- /dev/null +++ b/app/src-tauri/tauri.bundle.conf.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "externalBin": ["binaries/botloftd"], + "windows": { + "nsis": { + "installMode": "currentUser", + "installerHooks": "./windows/hooks.nsh" + } + } + } +} diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index a9a5512c..ea2262f4 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -1,6 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Botloft", + "mainBinaryName": "Botloft", "version": "0.1.0", "identifier": "io.github.httpsphl.botloft", "build": { diff --git a/app/src-tauri/windows/app.manifest b/app/src-tauri/windows/app.manifest new file mode 100644 index 00000000..0f129031 --- /dev/null +++ b/app/src-tauri/windows/app.manifest @@ -0,0 +1,21 @@ + + + + + + + + + + + true + + + diff --git a/app/src-tauri/windows/hooks.nsh b/app/src-tauri/windows/hooks.nsh new file mode 100644 index 00000000..16c1e0d3 --- /dev/null +++ b/app/src-tauri/windows/hooks.nsh @@ -0,0 +1,13 @@ +; Installer hooks (spec 15.4). The daemon runs from its own copy in the +; data folder as a scheduled task, so installing or updating the app never +; touches it; the app updates the daemon when it next opens. + +!macro NSIS_HOOK_PREUNINSTALL + ; A real uninstall stops the daemon and removes its scheduled task and + ; binary. Bots and data stay in %LOCALAPPDATA%\Botloft. An update runs + ; this uninstaller too, with /UPDATE: then the daemon keeps running. + ${If} $UpdateMode <> 1 + nsExec::Exec '"$INSTDIR\botloftd.exe" service uninstall' + Pop $0 + ${EndIf} +!macroend diff --git a/app/src/App.test.tsx b/app/src/App.test.tsx index e8aff173..97df36f6 100644 --- a/app/src/App.test.tsx +++ b/app/src/App.test.tsx @@ -1,4 +1,4 @@ -import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { afterEach, describe, expect, test } from "vitest"; import { App } from "./App"; import type { Client } from "./lib/client"; @@ -69,13 +69,26 @@ describe("app", () => { expect(screen.getByRole("dialog", { name: "New bot" })).toBeDefined(); }); - test("the owner starts the daemon from the app", async () => { + test("the app sets itself up on the first run, without asking", async () => { const host = new FakeHost(); host.status = { state: "stopped", port: 45710, home: "C:\\data\\Botloft" }; renderApp(new FakeBotloft(), host); - fireEvent.click(await screen.findByRole("button", { name: "Start the daemon" })); expect(await screen.findByRole("heading", { name: "Welcome to Botloft" })).toBeDefined(); - expect(host.starts).toBe(1); + expect(host.installs).toEqual(["install"]); + expect(screen.queryByText(/daemon/i)).toBeNull(); + }); + + test("a failed setup explains itself in plain words", async () => { + const host = new FakeHost(); + host.status = { state: "stopped", port: 45710, home: "C:\\data\\Botloft" }; + host.afterInstall = new Error("cannot register the scheduled task Botloft: access denied"); + renderApp(new FakeBotloft(), host); + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("Botloft couldn't start"); + expect(screen.getByText("Details")).toBeDefined(); + host.afterInstall = host.status = { ...host.status }; + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(host.installs).toEqual(["install", "install"]); }); test("archiving a bot asks first", async () => { @@ -105,17 +118,21 @@ describe("app", () => { expect(within(sidebar()).getAllByText("Paused")).toHaveLength(2); }); - test("a missing Claude Code says why bots cannot start", async () => { + test("a missing Claude Code says why bots cannot start and where to get it", async () => { const fake = new FakeBotloft(); + const host = new FakeHost(); fake.addCrew("Ops"); fake.system = { ...fake.system, claudeVersion: null, runtimeError: "claude.exe was not found on PATH.", }; - renderApp(fake); - const banner = await screen.findByText("Bots cannot start"); - expect(banner.closest("[role=alert]")?.textContent).toContain("claude.exe was not found"); + renderApp(fake, host); + const banner = await screen.findByText("Bots can't start"); + const alert = banner.closest("[role=alert]") as HTMLElement; + expect(alert.textContent).toContain("claude.exe was not found"); + fireEvent.click(within(alert).getByRole("button", { name: "How to install Claude Code" })); + await waitFor(() => expect(host.opened).toEqual(["https://code.claude.com/docs/en/setup"])); }); test("a lost connection shows in the title bar", async () => { diff --git a/app/src/dev/preview.ts b/app/src/dev/preview.ts index 99a897e9..dc13ffb4 100644 --- a/app/src/dev/preview.ts +++ b/app/src/dev/preview.ts @@ -35,7 +35,13 @@ export function previewProps(search: string): { host: Host; connect: Connect } { function liveProps(port: number): { host: Host; connect: Connect } { const host = new FakeHost(); - host.status = { state: "running", port, version: "dev", protocol: PROTOCOL_VERSION }; + host.status = { + state: "running", + port, + version: "dev", + protocol: PROTOCOL_VERSION, + outdated: false, + }; host.token = sessionStorage.getItem("botloft.devToken") ?? new Error('Set sessionStorage "botloft.devToken" to the dev daemon\'s owner token.'); diff --git a/app/src/features/bots/BotStateBadge.tsx b/app/src/features/bots/BotStateBadge.tsx index 95b7c066..4d4c2711 100644 --- a/app/src/features/bots/BotStateBadge.tsx +++ b/app/src/features/bots/BotStateBadge.tsx @@ -34,7 +34,7 @@ const views: Record = { hint: "Claude Code is starting.", }, idle: { label: "Idle", tone: "ok", icon: CircleDot, hint: "Ready for work." }, - busy: { label: "Working", tone: "work", icon: Activity, hint: "In the middle of a turn." }, + busy: { label: "Working", tone: "work", icon: Activity, hint: "Working on something." }, needs_approval: { label: "Needs approval", tone: "warn", @@ -42,22 +42,22 @@ const views: Record = { hint: "Waiting for you to allow or deny a tool in its chat.", }, rate_limited: { - label: "Rate limited", + label: "Usage limit", tone: "warn", icon: Hourglass, - hint: "Hit the usage limit; messages wait until it clears.", + hint: "Your Claude plan hit its usage limit; messages wait until it resets.", }, auth_error: { label: "Sign-in needed", tone: "danger", icon: KeyRound, - hint: "Claude Code is not signed in. Sign in, then restart the bot.", + hint: "Claude Code is not signed in. Open Claude Code and sign in, then restart the bot.", }, backoff: { label: "Restarting", tone: "warn", icon: RotateCw, - hint: "The process stopped; it starts again shortly.", + hint: "It stopped unexpectedly; Botloft starts it again shortly.", }, archived: { label: "Archived", tone: "quiet", icon: Archive, hint: "Archived." }, }; diff --git a/app/src/features/messages/FailedDeliveries.tsx b/app/src/features/messages/FailedDeliveries.tsx index 29126f62..e78c9997 100644 --- a/app/src/features/messages/FailedDeliveries.tsx +++ b/app/src/features/messages/FailedDeliveries.tsx @@ -95,8 +95,8 @@ function FailedDialog({ dead, onClose }: { dead: Delivery[]; onClose(): void }) } >

- The daemon stopped trying after several attempts. Retrying puts a message back in line; it - goes out when the bot is ready. + Botloft stopped trying after several attempts. Retrying puts a message back in line; it goes + out when the bot is ready.

    {dead.map((delivery) => { diff --git a/app/src/features/onboarding/ClaudeCodeHelp.tsx b/app/src/features/onboarding/ClaudeCodeHelp.tsx new file mode 100644 index 00000000..a0e4f210 --- /dev/null +++ b/app/src/features/onboarding/ClaudeCodeHelp.tsx @@ -0,0 +1,30 @@ +import { ExternalLink } from "lucide-react"; +import { useHost } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { Details } from "../../ui/Details"; +import { attempt } from "../../ui/toast"; + +/** Claude Code's official install page. */ +export const CLAUDE_CODE_SETUP = "https://code.claude.com/docs/en/setup"; + +/** What to do when bots cannot start because Claude Code is missing or unusable. */ +export function ClaudeCodeHelp({ error }: { error: string }) { + const host = useHost(); + return ( +
    +

    + Botloft runs your bots with Claude Code. Install it or update it, open it once to sign in, + and Botloft picks it up within 30 seconds. +

    + +
    {error}
    +
    + ); +} diff --git a/app/src/features/onboarding/Onboarding.tsx b/app/src/features/onboarding/Onboarding.tsx index c62b5d53..bd172525 100644 --- a/app/src/features/onboarding/Onboarding.tsx +++ b/app/src/features/onboarding/Onboarding.tsx @@ -1,92 +1,124 @@ -import { LoaderCircle, Play, RefreshCw } from "lucide-react"; +import { LoaderCircle, RefreshCw, RotateCw } from "lucide-react"; import type { ReactNode } from "react"; import { type StoreApi, useStore } from "zustand"; import { PROTOCOL_VERSION } from "../../lib/protocol.gen"; import { Button } from "../../ui/Button"; import { Callout } from "../../ui/Callout"; +import { Details } from "../../ui/Details"; import type { Link } from "./link"; -/** Shown until the app is connected to the daemon. */ +// Plain words on purpose: the owner never has to know that a daemon, a +// port or a scheduled task exists. Technical text goes under "Details". + +/** Shown until the app is connected to the part that runs the bots. */ export function Onboarding({ link }: { link: StoreApi }) { const current = useStore(link, (state) => state.current); - const { check, start } = link.getState(); - const again = ( - ); let body: ReactNode; switch (current.step) { case "checking": - body = ; + body = ; + break; + case "installing": + body = ( +
    + + {current.action === "install" && ( +

    + Botloft keeps your bots running in the background, even after you close this window, + and starts with Windows. +

    + )} +
    + ); break; case "connecting": body = (
    - - {again} + +
    + + +
    ); break; - case "starting": - body = ; - break; case "connected": body = null; break; case "stopped": body = ( -
    -

    - The daemon runs your bots and keeps them running after this window closes. It is not - running yet. -

    - {current.error && ( - - {current.error} - - {current.home} - - - )} -
    - - {again} -
    -
    + + Botloft runs your bots in the background, and that part didn't start. +
    + {current.error ?? "It is not running."} + {"\n"} + {current.home} +
    +
    + ); + break; + case "outdated": + body = ( + + The part that runs your bots in the background is still on the previous version. +
    + Running version {current.daemonVersion}. {current.error} +
    +
    ); break; case "foreign": body = ( - - Another program listens on 127.0.0.1:{current.port}. Close it, or set another{" "} - port in the daemon's config.toml. - + + Botloft needs port {current.port} on this computer, and another program is using it. Close + that program and try again. +
    + 127.0.0.1:{current.port} answers but is not Botloft. To use another port, set{" "} + port in Botloft's config.toml. +
    +
    ); break; case "mismatch": body = ( - - The running daemon ({current.daemonVersion}) speaks protocol {current.daemonProtocol}, and - this app speaks protocol {PROTOCOL_VERSION}. Install matching versions of both. - + + The Botloft running in the background is newer than this app. Install the latest version + of Botloft. +
    + Running version {current.daemonVersion}, protocol {current.daemonProtocol}. This app + speaks protocol {PROTOCOL_VERSION}. +
    +
    ); break; case "refused": body = ( - - {current.reason} - + + Try again. If this keeps happening, reinstall Botloft. +
    {current.reason}
    +
    ); break; case "error": body = ( - - {current.message} - + + Try again. If this keeps happening, reinstall Botloft. +
    {current.message}
    +
    ); break; } @@ -102,6 +134,33 @@ export function Onboarding({ link }: { link: StoreApi }) { ); } +const INSTALLING = { + install: "Getting Botloft ready…", + update: "Updating Botloft…", + restart: "Restarting Botloft…", +} as const; + +function Problem({ + title, + action, + children, +}: { + title: string; + action: ReactNode; + children: ReactNode; +}) { + return ( +
    +
    + + {children} + +
    + {action} +
    + ); +} + function Waiting({ text }: { text: string }) { return (

    diff --git a/app/src/features/onboarding/Welcome.tsx b/app/src/features/onboarding/Welcome.tsx index daf6c862..4094cc4a 100644 --- a/app/src/features/onboarding/Welcome.tsx +++ b/app/src/features/onboarding/Welcome.tsx @@ -3,13 +3,12 @@ import { type ReactNode, useState } from "react"; import { useApp } from "../../store/context"; import { Button } from "../../ui/Button"; import { CrewDialog } from "../crews/CrewDialog"; +import { ClaudeCodeHelp } from "./ClaudeCodeHelp"; /** First run: what is ready, what is not, and the first crew. */ export function Welcome() { const system = useApp((state) => state.system); - const connection = useApp((state) => state.connection); const [creating, setCreating] = useState(false); - const daemonVersion = connection.kind === "open" ? connection.daemonVersion : null; let claude: ReactNode; if (!system || (system.claudeVersion === null && system.runtimeError === null)) { @@ -19,7 +18,7 @@ export function Welcome() { } /> ); } else { @@ -35,7 +34,11 @@ export function Welcome() { folder. Start with one crew and a bot or two.

      - + {claude}
    @@ -61,7 +64,7 @@ function Check({ }: { state: "ok" | "bad" | "pending"; title: string; - detail: string; + detail: ReactNode; }) { const Icon = state === "ok" ? CircleCheck : state === "bad" ? CircleX : LoaderCircle; const tone = state === "ok" ? "text-ok" : state === "bad" ? "text-danger" : "text-muted"; @@ -79,9 +82,9 @@ function Check({ : {state === "ok" ? "ready" : state === "bad" ? "not ready" : "checking"}

    -

    +

    {detail} -

    +
    ); diff --git a/app/src/features/onboarding/link.test.ts b/app/src/features/onboarding/link.test.ts index 9a8d6272..21f5a27c 100644 --- a/app/src/features/onboarding/link.test.ts +++ b/app/src/features/onboarding/link.test.ts @@ -1,8 +1,7 @@ import { describe, expect, test } from "vitest"; import type { Client } from "../../lib/client"; import { FakeBotloft } from "../../lib/fake"; -import { FakeHost } from "../../lib/fakeHost"; -import { PROTOCOL_VERSION } from "../../lib/protocol.gen"; +import { FakeHost, running } from "../../lib/fakeHost"; import { type Connect, createLink } from "./link"; function setup() { @@ -20,40 +19,86 @@ function setup() { describe("link to the daemon", () => { test("a running daemon connects with the owner token", async () => { const { host, link, connections } = setup(); - host.status = { state: "running", port: 45799, version: "0.1.0", protocol: PROTOCOL_VERSION }; + host.status = running({ port: 45799 }); await link.getState().check(); expect(link.getState().current.step).toBe("connected"); expect(connections).toEqual([{ port: 45799, token: "a".repeat(64) }]); }); - test("a stopped daemon waits for the owner to start it", async () => { + test("a stopped daemon is installed without asking", async () => { const { host, link } = setup(); host.status = { state: "stopped", port: 45710, home: "C:\\data\\Botloft" }; await link.getState().check(); - expect(link.getState().current).toEqual({ - step: "stopped", - port: 45710, - home: "C:\\data\\Botloft", - error: null, - }); - await link.getState().start(); - expect(host.starts).toBe(1); + expect(host.installs).toEqual(["install"]); expect(link.getState().current.step).toBe("connected"); }); test("a daemon that does not come up says so", async () => { const { host, link } = setup(); host.status = { state: "stopped", port: 45710, home: "C:\\data" }; - host.afterStart = host.status; - await link.getState().start(); + host.afterInstall = host.status; + await link.getState().check(); + expect(host.installs).toEqual(["install"]); const current = link.getState().current; expect(current.step).toBe("stopped"); expect(current.step === "stopped" && current.error).toMatch(/did not answer/); }); + test("a failed install shows its error once, with a way to try again", async () => { + const { host, link } = setup(); + host.status = { state: "stopped", port: 45710, home: "C:\\data" }; + host.afterInstall = new Error("cannot register the scheduled task Botloft: access denied"); + await link.getState().check(); + expect(link.getState().current).toEqual({ + step: "stopped", + port: 45710, + home: "C:\\data", + error: "cannot register the scheduled task Botloft: access denied", + }); + }); + + test("an older daemon is updated before connecting", async () => { + const { host, link, connections } = setup(); + host.status = running({ version: "0.0.9", protocol: 1, outdated: true }); + await link.getState().check(); + expect(host.installs).toEqual(["install"]); + expect(link.getState().current.step).toBe("connected"); + expect(connections).toHaveLength(1); + }); + + test("an update that fails is shown once, without a loop", async () => { + const { host, link, connections } = setup(); + host.status = running({ version: "0.0.9", outdated: true }); + host.afterInstall = new Error("the daemon did not stop"); + await link.getState().check(); + expect(link.getState().current).toEqual({ + step: "outdated", + daemonVersion: "0.0.9", + error: "the daemon did not stop", + }); + host.afterInstall = running({ version: "0.0.9", outdated: true }); + await link.getState().install(); + expect(host.installs).toEqual(["install", "install"]); + expect(link.getState().current).toEqual({ + step: "outdated", + daemonVersion: "0.0.9", + error: "The daemon still reports version 0.0.9.", + }); + expect(connections).toHaveLength(0); + }); + + test("restarting the daemon reconnects", async () => { + const { host, link, connections } = setup(); + await link.getState().check(); + await link.getState().restart(); + expect(host.installs).toEqual(["restart"]); + expect(link.getState().current.step).toBe("connected"); + expect(connections).toHaveLength(2); + }); + test("another protocol version is not spoken to", async () => { const { host, link, connections } = setup(); - host.status = { state: "running", port: 45710, version: "9.0.0", protocol: 9 }; + host.status = running({ version: "9.0.0", protocol: 9 }); await link.getState().check(); expect(link.getState().current).toEqual({ step: "mismatch", diff --git a/app/src/features/onboarding/link.ts b/app/src/features/onboarding/link.ts index a1f4528e..08f1563f 100644 --- a/app/src/features/onboarding/link.ts +++ b/app/src/features/onboarding/link.ts @@ -1,5 +1,8 @@ -// Getting from "app opened" to "connected to the daemon": check that it -// runs, offer to start it, read the owner token and say hello (spec 15.1). +// Getting from "app opened" to "connected to the daemon" without asking +// the owner anything: install it when it does not run, keep it at the +// version this app ships, read the owner token and say hello (spec 15.1). +// Each run installs or updates at most once, so a failure shows instead +// of looping. import { createStore, type StoreApi } from "zustand/vanilla"; import { errorText } from "../../lib/api"; @@ -9,12 +12,16 @@ import { PROTOCOL_VERSION } from "../../lib/protocol.gen"; export type LinkStep = | { step: "checking" } + /** Not running, and installing it did not help. */ | { step: "stopped"; port: number; home: string; error: string | null } - | { step: "starting" } + /** Installing, updating to this app's version, or restarting the daemon. */ + | { step: "installing"; action: "install" | "update" | "restart" } /** Another program holds the daemon's port. */ | { step: "foreign"; port: number } - /** The daemon speaks another protocol version. */ + /** The daemon speaks another protocol version and is not older than the app. */ | { step: "mismatch"; daemonProtocol: number; daemonVersion: string } + /** The daemon is older than this app and updating it failed. */ + | { step: "outdated"; daemonVersion: string; error: string } | { step: "error"; message: string } | { step: "connecting"; api: Client } | { step: "connected"; api: Client } @@ -23,14 +30,18 @@ export type LinkStep = export interface Link { current: LinkStep; - /** Checks the daemon again from the start. */ + /** Checks the daemon again from the start, installing it if needed. */ check(): Promise; - /** Starts the daemon, then connects. */ - start(): Promise; + /** Installs the daemon as a scheduled task and starts it, then connects. */ + install(): Promise; + /** Restarts the daemon, then connects. */ + restart(): Promise; } export type Connect = (port: number, token: string) => Client; +const LATE = "It started but did not answer in time. Its log is in the logs folder."; + export function createLink(host: Host, connect: Connect): StoreApi { let api: Client | null = null; let generation = 0; @@ -73,18 +84,41 @@ export function createLink(host: Host, connect: Connect): StoreApi { follow(); }; - const settle = async (status: DaemonStatus, run: number, error: string | null = null) => { + /** Moves on from a status: installs a stopped daemon and updates an older one. */ + const settle = async ( + status: DaemonStatus, + run: number, + { error = null, mayChange = true }: { error?: string | null; mayChange?: boolean } = {}, + ): Promise => { if (run !== generation) { return; } switch (status.state) { case "stopped": + if (mayChange) { + await change(run, "install", () => host.installDaemon()); + return; + } set({ current: { step: "stopped", port: status.port, home: status.home, error } }); return; case "foreign": set({ current: { step: "foreign", port: status.port } }); return; case "running": + if (status.outdated) { + if (!mayChange) { + set({ + current: { + step: "outdated", + daemonVersion: status.version, + error: error ?? `The daemon still reports version ${status.version}.`, + }, + }); + return; + } + await change(run, "update", () => host.installDaemon()); + return; + } if (status.protocol !== PROTOCOL_VERSION) { set({ current: { @@ -99,12 +133,44 @@ export function createLink(host: Host, connect: Connect): StoreApi { } }; + /** Installs, updates or restarts, then settles on what runs afterwards. */ + const change = async ( + run: number, + action: "install" | "update" | "restart", + work: () => Promise, + ) => { + set({ current: { step: "installing", action } }); + let status: DaemonStatus; + let error: string | null = null; + try { + status = await work(); + if (status.state === "stopped") { + error = LATE; + } + } catch (failure) { + error = errorText(failure); + try { + status = await host.daemonStatus(); + } catch (again) { + if (run === generation) { + set({ current: { step: "error", message: errorText(again) } }); + } + return; + } + } + await settle(status, run, { error, mayChange: false }); + }; + + const begin = () => { + generation += 1; + drop(); + return generation; + }; + return { current: { step: "checking" }, check: async () => { - generation += 1; - const run = generation; - drop(); + const run = begin(); set({ current: { step: "checking" } }); try { await settle(await host.daemonStatus(), run); @@ -114,23 +180,13 @@ export function createLink(host: Host, connect: Connect): StoreApi { } } }, - start: async () => { - generation += 1; - const run = generation; - drop(); - set({ current: { step: "starting" } }); - try { - const status = await host.startDaemon(); - const late = - status.state === "stopped" - ? "The daemon did not answer in time. Its log is in the logs folder of its data folder." - : null; - await settle(status, run, late); - } catch (error) { - if (run === generation) { - set({ current: { step: "error", message: errorText(error) } }); - } - } + install: async () => { + const run = begin(); + await change(run, "install", () => host.installDaemon()); + }, + restart: async () => { + const run = begin(); + await change(run, "restart", () => host.restartDaemon()); }, }; }); diff --git a/app/src/lib/fakeHost.ts b/app/src/lib/fakeHost.ts index bccb494b..e30e068e 100644 --- a/app/src/lib/fakeHost.ts +++ b/app/src/lib/fakeHost.ts @@ -3,23 +3,27 @@ import type { DaemonStatus, Host } from "./host"; import { PROTOCOL_VERSION } from "./protocol.gen"; -export class FakeHost implements Host { - status: DaemonStatus = { - state: "running", - port: 45710, - version: "0.1.0", - protocol: PROTOCOL_VERSION, - }; - /** What `startDaemon` leaves behind. */ - afterStart: DaemonStatus = { +/** A current daemon on the default port. */ +export function running( + changes: Partial> = {}, +): DaemonStatus { + return { state: "running", port: 45710, version: "0.1.0", protocol: PROTOCOL_VERSION, + outdated: false, + ...changes, }; +} + +export class FakeHost implements Host { + status: DaemonStatus = running(); + /** What `installDaemon` and `restartDaemon` leave behind, or their error. */ + afterInstall: DaemonStatus | Error = running(); token: string | Error = "a".repeat(64); readonly opened: string[] = []; - starts = 0; + readonly installs: ("install" | "restart")[] = []; maximized = false; closed = false; attention = false; @@ -28,9 +32,20 @@ export class FakeHost implements Host { return Promise.resolve(this.status); } - startDaemon(): Promise { - this.starts += 1; - this.status = this.afterStart; + installDaemon(): Promise { + return this.settle("install"); + } + + restartDaemon(): Promise { + return this.settle("restart"); + } + + private settle(action: "install" | "restart"): Promise { + this.installs.push(action); + if (this.afterInstall instanceof Error) { + return Promise.reject(this.afterInstall); + } + this.status = this.afterInstall; return Promise.resolve(this.status); } diff --git a/app/src/lib/host.ts b/app/src/lib/host.ts index 9b26d4f5..8ec0a0da 100644 --- a/app/src/lib/host.ts +++ b/app/src/lib/host.ts @@ -1,9 +1,17 @@ // What the app needs from the native shell (spec 15.2): the daemon's -// status, starting it, the owner token, Explorer and the window. The Tauri -// implementation lives in `tauriHost.ts`; tests pass a fake. +// status, installing and restarting it, the owner token, Explorer and the +// window. The Tauri implementation lives in `tauriHost.ts`; tests pass a +// fake. export type DaemonStatus = - | { state: "running"; port: number; version: string; protocol: number } + | { + state: "running"; + port: number; + version: string; + protocol: number; + /** Older than the daemon this app ships; installing replaces it. */ + outdated: boolean; + } /** Nothing listens on the port. `home` is the daemon's data folder. */ | { state: "stopped"; port: number; home: string } /** Something that is not botloftd holds the port. */ @@ -22,8 +30,14 @@ export interface AppWindow { export interface Host { daemonStatus(): Promise; - /** Starts the daemon if needed and waits for it to answer. */ - startDaemon(): Promise; + /** + * Installs the daemon this app ships as a scheduled task that starts at + * logon, starts it and waits for it to answer (spec 14). Replaces an + * older daemon; leaves the same version running. + */ + installDaemon(): Promise; + /** Stops the daemon and starts it again from its scheduled task. */ + restartDaemon(): Promise; readOwnerToken(): Promise; /** Opens a folder in Explorer. */ openPath(path: string): Promise; diff --git a/app/src/lib/rpc.ts b/app/src/lib/rpc.ts index 5f9b7043..9ce2a14c 100644 --- a/app/src/lib/rpc.ts +++ b/app/src/lib/rpc.ts @@ -116,7 +116,7 @@ export class RpcConnection { request(method: M, ...params: ParamsArg): Promise> { return new Promise>((resolve, reject) => { if (this.state.kind === "closed" || this.state.kind === "failed") { - reject(new RpcError(ClientErrorCode.closed, "not connected to the daemon")); + reject(new RpcError(ClientErrorCode.closed, "not connected to Botloft")); return; } const pending: Pending = { @@ -251,7 +251,7 @@ export class RpcConnection { this.inflight.delete(id); clearTimeout(pending.timer); pending.reject( - new RpcError(ClientErrorCode.connectionLost, "lost the connection to the daemon"), + new RpcError(ClientErrorCode.connectionLost, "lost the connection to Botloft"), ); } if (this.state.kind === "failed" || this.state.kind === "closed") { diff --git a/app/src/lib/tauriHost.ts b/app/src/lib/tauriHost.ts index efdab5e3..c3c21b67 100644 --- a/app/src/lib/tauriHost.ts +++ b/app/src/lib/tauriHost.ts @@ -29,7 +29,8 @@ export function tauriHost(): Host { let overlay: Promise | undefined; return { daemonStatus: () => invoke("daemon_status"), - startDaemon: () => invoke("daemon_start"), + installDaemon: () => invoke("daemon_install"), + restartDaemon: () => invoke("daemon_restart"), readOwnerToken: () => invoke("read_owner_token"), openPath: (path) => invoke("open_path", { path }), openUrl: (url) => invoke("open_url", { url }), diff --git a/app/src/shell/Workspace.tsx b/app/src/shell/Workspace.tsx index 4d4e181c..ac2c1668 100644 --- a/app/src/shell/Workspace.tsx +++ b/app/src/shell/Workspace.tsx @@ -8,6 +8,7 @@ import { BotView } from "../features/bots/BotView"; import { CrewView } from "../features/crews/CrewView"; import { Sidebar } from "../features/crews/Sidebar"; import { FailedDeliveries } from "../features/messages/FailedDeliveries"; +import { ClaudeCodeHelp } from "../features/onboarding/ClaudeCodeHelp"; import { Welcome } from "../features/onboarding/Welcome"; import { useApp } from "../store/context"; import { Callout } from "../ui/Callout"; @@ -41,8 +42,8 @@ export function Workspace() {
    {runtimeError && (
    - - {runtimeError} The daemon checks again every 30 seconds. + +
    )} diff --git a/app/src/ui/Details.tsx b/app/src/ui/Details.tsx new file mode 100644 index 00000000..f11dac0e --- /dev/null +++ b/app/src/ui/Details.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from "react"; + +/** Technical text for troubleshooting, folded away under the plain message. */ +export function Details({ children }: { children: ReactNode }) { + return ( +
    + Details +
    + {children} +
    +
    + ); +} 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..f85bfa9d 100644 --- a/crates/botloftd/src/main.rs +++ b/crates/botloftd/src/main.rs @@ -2,6 +2,7 @@ use std::net::Ipv4Addr; use std::path::PathBuf; +use std::process::ExitCode; use std::sync::Arc; use anyhow::Context; @@ -15,14 +16,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 +39,49 @@ enum Command { #[arg(long)] config: Option, }, + /// Start the daemon at logon, as a scheduled task of the current user. + #[command(subcommand)] + Service(ServiceCommand), } -fn main() -> anyhow::Result<()> { - match Cli::parse().command { - Command::Serve { config } => serve(config), +#[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, +} + +/// Errors go to stderr on one line (`what failed: why`), which the app +/// shows as is when it runs `service install`. +fn main() -> ExitCode { + match dispatch(Cli::parse()) { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("{err:#}"); + ExitCode::FAILURE + } } } -fn serve(config_path: Option) -> anyhow::Result<()> { - let home = paths::resolve_home().context("cannot resolve the data folder")?; +fn dispatch(cli: Cli) -> anyhow::Result<()> { + 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(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 +90,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 +135,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..26f56774 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -102,6 +102,10 @@ Tipos do protocolo ficam em `botloft-core` e são exportados para TypeScript com | Config | `%LOCALAPPDATA%\Botloft\config.toml` | `--config` | | Workspaces | `%USERPROFILE%\Botloft\\\` | `workspaces_root` na config | | Pasta compartilhada da crew | `%USERPROFILE%\Botloft\\shared\` | | +| Binário do daemon | `%LOCALAPPDATA%\Botloft\bin\botloftd.exe` (seção 14) | `--home` | +| App instalado | `%LOCALAPPDATA%\Botloft\` (`Botloft.exe`, o sidecar `botloftd.exe`, `uninstall.exe`; seção 15.4) | | + +O instalador por usuário do Tauri põe o app em `%LOCALAPPDATA%\`, a mesma pasta dos dados. Os nomes não se cruzam, e o desinstalador só apaga os arquivos que instalou e remove a pasta se ela ficar vazia: os dados ficam. `%LOCALAPPDATA%` e não `%APPDATA%`: o perfil roaming sincroniza em rede e não deve carregar SQLite nem segredos. @@ -491,11 +495,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 | @@ -549,10 +556,9 @@ Layout, como um app de mensagens: | Comando | Função | |---|---| -| `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_restart` | `botloftd service restart` | +| `daemon_status` | GET `/health` local; diz se o daemon roda, está parado ou se outro programa ocupa a porta. Quando o daemon roda, diz também se ele é `outdated`: versão menor que a do app, que é a do sidecar (um workspace Cargo só). Pré-release é ignorado; versão ilegível nunca é antiga | +| `daemon_install` | roda `botloftd --home service install` a partir do sidecar (o `botloftd.exe` ao lado do app), sem janela, e espera: ele se copia para `\bin`, registra e inicia a tarefa e espera o `/health` (seção 14). Um erro volta na mensagem de uma linha que o daemon escreve no stderr. Substitui o `daemon_start` do M4, que iniciava o daemon destacado ao lado do app | +| `daemon_restart` | `botloftd --home service restart`, do mesmo jeito | | `read_owner_token` | lê `secrets\owner.token` (só no app local) | | `open_path` | abre uma pasta no Explorer; recusa arquivos, que o Explorer executaria | | `open_url` | abre no navegador padrão um link de uma resposta do bot; só `http` e `https`, porque qualquer outro esquema pode iniciar um programa. Seguir o link dentro do app trocaria a janela pela página | @@ -560,6 +566,18 @@ Layout, como um app de mensagens: O app acha o daemon como o daemon acha a si mesmo (seção 5): `BOTLOFT_HOME` ou `%LOCALAPPDATA%\Botloft`, com a porta lida do `config.toml` dessa pasta (45710 se ausente). Um daemon de dev com seu próprio `BOTLOFT_HOME` é encontrado sem configuração extra. A CSP libera `ws://127.0.0.1:*` pelo mesmo motivo. +Onboarding (M5): **configuração sem perguntas e sem jargão.** O dono não precisa saber que existe um daemon, uma porta ou uma tarefa agendada; a interface nunca usa essas palavras. Fala de "Botloft" e de "rodar em segundo plano", e o texto técnico (erro do daemon, caminho, porta) fica dobrado sob "Details". + +- Nada rodando: o app chama `daemon_install` sozinho ("Getting Botloft ready…", com uma linha dizendo que o Botloft segue rodando em segundo plano depois que a janela fecha e inicia com o Windows). Se falhar, "Botloft couldn't start" com "Try again". +- Daemon `outdated`: o app o atualiza sozinho ("Updating Botloft…"), porque app e daemon são distribuídos juntos e quem atualizou o app espera o daemon novo. Os bots em turno são interrompidos e retomam a sessão (7.3). Se falhar ou a versão não mudar, "Botloft couldn't finish updating" com "Try again". +- Cada verificação instala ou atualiza no máximo uma vez: uma falha aparece em vez de virar loop. +- Daemon mais novo que o app com outro protocolo: pede para instalar a versão mais recente do Botloft. +- Porta ocupada por outro programa: diz que outro programa está no caminho; porta e `config.toml` ficam em "Details". +- Conectando sem sucesso: "Try again" e "Restart Botloft in the background" (`daemon_restart`). +- Claude Code ausente ou inutilizável (`runtimeError`), na tela de boas-vindas e no aviso "Bots can't start": explica que os bots rodam no Claude Code, oferece "How to install Claude Code" (abre `https://code.claude.com/docs/en/setup`) e diz que o Botloft percebe sozinho em até 30 s; o erro vai em "Details". + +Em dev, `pnpm tauri dev` usa o `target\debug\botloftd.exe` como sidecar e instala uma tarefa própria da pasta de dev (seção 14). Quem prefere um daemon em primeiro plano roda `cargo run -p botloftd -- serve` antes de abrir o app. + ### 15.3 Direção visual Ferramenta de trabalho densa e calma: tipografia forte, grid firme, estados dos bots legíveis de longe (cor + ícone + texto, nunca só cor). Sem gradiente, sem sombra pesada, sem visual de template. Tema escuro e claro. Barra de título própria (`decorations: false`) com controles de janela do Windows. @@ -574,6 +592,13 @@ No chat: Identidade: o mascote do Botloft é uma chama com olhos, desenhada em vetor em `app/app-icon.svg`. O ícone do app é o mascote branco sobre fundo preto. Cada bot usa o mesmo personagem como avatar, com uma cor própria escolhida na criação. A cor do avatar identifica o bot e não comunica estado: estado continua sendo cor + ícone + texto, como descrito acima. +### 15.4 Instalador e sidecar + +- `pnpm bundle` (em `app/`) roda `scripts/sidecar.mjs`, que compila o `botloftd` em release e o copia para `src-tauri/binaries/botloftd-.exe`, e depois `tauri build --config src-tauri/tauri.bundle.conf.json`. Esse arquivo liga o `externalBin` e o NSIS; ele fica fora do `tauri.conf.json` porque o `tauri-build` copia o `externalBin` também em dev e no `cargo clippy`, o que exigiria o sidecar em todo build e sobrescreveria o `target\debug\botloftd.exe` com a cópia de release. +- NSIS por usuário (`installMode = currentUser`), sem pedir administrador, em `%LOCALAPPDATA%\Botloft` (seção 5). O instalador não mexe no daemon: ele roda da própria cópia em `\bin`, então o arquivo do sidecar nunca está em uso. +- Hook `NSIS_HOOK_PREUNINSTALL` (`src-tauri/windows/hooks.nsh`): numa desinstalação de verdade, `botloftd service uninstall` para o daemon e apaga a tarefa e o binário; bots e dados ficam. Uma atualização também roda o desinstalador antigo, com `/UPDATE`: aí o hook não faz nada e o daemon continua rodando até o app novo abrir e atualizá-lo (15.2). +- O manifesto do app (`src-tauri/windows/app.manifest`) é o padrão do Tauri (controles comuns v6) mais `longPathAware`. + ## 16. Qualidade - Rust: `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`.