diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc20efc1..37541fe7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,17 +31,28 @@ jobs: - name: Generated TypeScript bindings are up to date run: git diff --exit-code -- app/src/lib/protocol.gen.ts - rust-ubuntu: - name: Rust core + store (Ubuntu) - runs-on: ubuntu-latest + # The daemon and its crates off Windows (spec 18, item 8). Plain `cargo` + # builds the default members only: the app crates need the desktop + # libraries of each system and join later. + rust-unix: + name: Rust (${{ matrix.name }}) + strategy: + fail-fast: false + matrix: + include: + - name: Ubuntu + os: ubuntu-latest + - name: macOS + os: macos-latest + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable with: components: clippy - uses: Swatinem/rust-cache@v2 - - run: cargo clippy -p botloft-core -p botloft-store --all-targets -- -D warnings - - run: cargo test -p botloft-core -p botloft-store + - run: cargo clippy --all-targets -- -D warnings + - run: cargo test app: name: App (Windows) diff --git a/crates/botloftd/build.rs b/crates/botloftd/build.rs index 9c7f8d7b..e9006863 100644 --- a/crates/botloftd/build.rs +++ b/crates/botloftd/build.rs @@ -36,7 +36,18 @@ fn main() { let out = PathBuf::from(env::var_os("OUT_DIR").expect("cargo sets OUT_DIR")).join("botloftd.rc"); fs::write(&out, script).expect("cannot write the resource script"); - if let Err(err) = embed_resource::compile(&out, embed_resource::NONE).manifest_required() { + embed(&out); +} + +/// `embed-resource` is a build dependency on Windows hosts only. +#[cfg(windows)] +fn embed(script: &std::path::Path) { + if let Err(err) = embed_resource::compile(script, embed_resource::NONE).manifest_required() { panic!("cannot embed the resources: {err}"); } } + +#[cfg(not(windows))] +fn embed(_script: &std::path::Path) { + panic!("the Windows daemon is built on Windows, where its resources can be embedded"); +} diff --git a/crates/botloftd/src/autostart/binary.rs b/crates/botloftd/src/autostart/binary.rs index cb21780e..bb3e899c 100644 --- a/crates/botloftd/src/autostart/binary.rs +++ b/crates/botloftd/src/autostart/binary.rs @@ -109,6 +109,13 @@ mod tests { names } + /// The old copy and the installed binary, in the order `names` gives. + fn old_and_installed() -> Vec { + let mut names = vec!["botloftd.1.old".to_owned(), EXE_NAME.to_owned()]; + names.sort(); + names + } + #[test] fn copies_once_and_replaces_only_a_different_binary() { let dir = tempfile::tempdir().expect("tempdir"); @@ -129,13 +136,13 @@ mod tests { 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()]); + assert_eq!(names(&bin), old_and_installed()); fs::write(&source, b"v3").expect("write"); install(&source, &bin).expect("update"); assert_eq!( names(&bin), - ["botloftd.1.old".to_owned(), EXE_NAME.into()], + old_and_installed(), "the v1 copy went away and v2 moved aside" ); diff --git a/crates/botloftd/src/browser/sites.rs b/crates/botloftd/src/browser/sites.rs index c3f87f4d..c0b3d00f 100644 --- a/crates/botloftd/src/browser/sites.rs +++ b/crates/botloftd/src/browser/sites.rs @@ -126,11 +126,17 @@ pub fn file_allowed(url: &str, folders: &[PathBuf]) -> bool { is_file(url) && inside(&file_path(url), folders).is_some() } -/// The path of a `file:` URL: `file:///C:/a%20b/x.html?q` is `C:/a b/x.html`. +/// The path of a `file:` URL: `file:///C:/a%20b/x.html?q` is `C:/a b/x.html` +/// on Windows, and `file:///home/ana/x.html` is `/home/ana/x.html` elsewhere. fn file_path(url: &str) -> PathBuf { let rest = url.get(5..).unwrap_or_default(); let rest = rest.split(['?', '#']).next().unwrap_or_default(); - PathBuf::from(decode(rest.trim_start_matches('/'))) + let path = decode(rest.trim_start_matches('/')); + if cfg!(windows) { + PathBuf::from(path) + } else { + PathBuf::from(format!("/{path}")) + } } fn file(path: &Path, base: &Path, folders: &[PathBuf]) -> Result { @@ -164,13 +170,17 @@ fn inside(path: &Path, folders: &[PathBuf]) -> Option { .then_some(real) } -/// `C:\a b\x.html` as `file:///C:/a%20b/x.html`. +/// `C:\a b\x.html` as `file:///C:/a%20b/x.html`, and `/home/ana/x.html` as +/// `file:///home/ana/x.html`. fn file_url(path: &Path) -> String { let text = path.to_string_lossy(); - let text = text - .strip_prefix(r"\\?\") - .unwrap_or(&text) - .replace('\\', "/"); + let text = if cfg!(windows) { + text.strip_prefix(r"\\?\") + .unwrap_or(&text) + .replace('\\', "/") + } else { + text.trim_start_matches('/').to_owned() + }; let mut url = String::from("file:///"); for byte in text.bytes() { match byte { diff --git a/crates/botloftd/src/config.rs b/crates/botloftd/src/config.rs index e91bd931..146561d2 100644 --- a/crates/botloftd/src/config.rs +++ b/crates/botloftd/src/config.rs @@ -218,7 +218,7 @@ mod tests { #[test] fn keys_override_defaults() { let config = Config::parse( - "port = 45999\nworkspaces_root = 'D:\\\\bots'\n[courier]\nmax_attempts = 3\n", + "port = 45999\nworkspaces_root = 'D:\\bots'\n[courier]\nmax_attempts = 3\n", ) .expect("parse"); assert_eq!(config.port, 45999); diff --git a/crates/botloftd/src/platform/unix.rs b/crates/botloftd/src/platform/unix.rs index fe7bd35b..c428d4fa 100644 --- a/crates/botloftd/src/platform/unix.rs +++ b/crates/botloftd/src/platform/unix.rs @@ -68,9 +68,17 @@ pub fn user_environment() -> io::Result> { .collect()) } -/// The login name; Unix has no display name to ask for. +/// The login name; Unix has no display name to ask for. `USER` is missing +/// in some service and container environments, and the home folder is +/// named after the login there too. pub fn owner_name() -> String { - std::env::var("USER").unwrap_or_default() + ["USER", "LOGNAME"] + .iter() + .filter_map(std::env::var_os) + .chain(dirs::home_dir().and_then(|home| home.file_name().map(ToOwned::to_owned))) + .map(|name| name.to_string_lossy().trim().to_owned()) + .find(|name| !name.is_empty()) + .unwrap_or_default() } /// Nothing to leave: Unix never opens a console for the daemon. @@ -87,8 +95,9 @@ pub fn register_task(_name: &str, _task: &TaskDefinition) -> io::Result<()> { Err(no_tasks()) } +/// Nothing can be installed here yet, so there is never a task to find. pub fn find_task(_name: &str) -> io::Result> { - Err(no_tasks()) + Ok(None) } pub fn run_task(_name: &str) -> io::Result<()> { @@ -100,7 +109,7 @@ pub fn stop_task(_name: &str) -> io::Result<()> { } pub fn delete_task(_name: &str) -> io::Result { - Err(no_tasks()) + Ok(false) } /// There is no Recycle Bin off Windows: the folder stays. diff --git a/crates/botloftd/tests/courier.rs b/crates/botloftd/tests/courier.rs index 819e646d..0c33f2ce 100644 --- a/crates/botloftd/tests/courier.rs +++ b/crates/botloftd/tests/courier.rs @@ -188,15 +188,19 @@ async fn a_message_the_bot_began_is_not_sent_again() { assert!(second.input_lines().is_empty()); } -#[tokio::test] +// Two workers: the held spawn blocks the supervisor's. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn archiving_a_bot_drops_what_is_still_queued_for_it() { let t = TestDaemon::start_supervised().await; + // The bot is still starting, so nothing can take the message yet. + let (_began, release) = t.runtime.hold_next_spawn(); let mut app = t.session().await; let bot = crew_and_bot(&mut app).await; send(&mut app, &bot, "never read").await; app.call("bots.archive", json!({ "botId": bot["id"] })) .await .expect("archive"); + release.send(()).expect("release"); t.clock.advance(Duration::from_secs(5)); let dead = delivery(&mut app, "dead").await; assert_eq!(dead["lastError"], "the bot was archived"); diff --git a/docs/spec.md b/docs/spec.md index 733a0b19..7e13c3f5 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -925,7 +925,7 @@ Esta seção entra no PR do I1, junto com o código. - TS: `tsc --noEmit`, `biome check`, `vitest run`. - Limite **flexível de 300 linhas** por arquivo; passou disso, dividir por responsabilidade. - Toda lógica de supervisor, courier e chat testada com `FakeRuntime` (sem Claude real). O `FakeRuntime` fala `stream-json` de verdade: recebe as linhas do stdin e o teste escreve os eventos do stdout. -- CI: GitHub Actions em `windows-latest` (principal) e `ubuntu-latest` para `botloft-core` e `botloft-store`. +- CI: GitHub Actions em `windows-latest` (principal, workspace inteiro e app) e em `ubuntu-latest` e `macos-latest` para os membros padrão (`botloft-core`, `botloft-store`, `botloftd`), com clippy e testes. Os crates do app entram nesses runners quando o app rodar neles (18, item 8). ## 17. Marcos do MVP @@ -950,7 +950,7 @@ M2 a M4 foram entregues com ConPTY, terminal com replay, inbox por named pipe e 5. Histórico de versões das instruções do bot. 6. Acesso remoto com token por dispositivo (Tailscale). 7. Sinais entre bots disparando rotinas. -8. Suporte Linux/macOS. +8. Suporte Linux/macOS. Em fatias: (1) CI com Ubuntu e macOS para o daemon; (2) runtime do bot (grupo de processos, ambiente, `claude`, navegador); (3) iniciar com o sistema (`systemd --user`, LaunchAgent); (4) app; (5) empacotamento e release. Feita: 1. 9. Navegador dos bots, com o dono assistindo ao vivo. Desenho na seção 21. ## 19. Pontos a verificar na versão alvo do Claude Code