Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 12 additions & 1 deletion crates/botloftd/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
11 changes: 9 additions & 2 deletions crates/botloftd/src/autostart/binary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ mod tests {
names
}

/// The old copy and the installed binary, in the order `names` gives.
fn old_and_installed() -> Vec<String> {
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");
Expand All @@ -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"
);

Expand Down
24 changes: 17 additions & 7 deletions crates/botloftd/src/browser/sites.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Place, String> {
Expand Down Expand Up @@ -164,13 +170,17 @@ fn inside(path: &Path, folders: &[PathBuf]) -> Option<PathBuf> {
.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 {
Expand Down
2 changes: 1 addition & 1 deletion crates/botloftd/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
17 changes: 13 additions & 4 deletions crates/botloftd/src/platform/unix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,17 @@ pub fn user_environment() -> io::Result<Vec<(OsString, OsString)>> {
.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.
Expand All @@ -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<Option<TaskInfo>> {
Err(no_tasks())
Ok(None)
}

pub fn run_task(_name: &str) -> io::Result<()> {
Expand All @@ -100,7 +109,7 @@ pub fn stop_task(_name: &str) -> io::Result<()> {
}

pub fn delete_task(_name: &str) -> io::Result<bool> {
Err(no_tasks())
Ok(false)
}

/// There is no Recycle Bin off Windows: the folder stays.
Expand Down
6 changes: 5 additions & 1 deletion crates/botloftd/tests/courier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
4 changes: 2 additions & 2 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
Loading