diff --git a/crates/maestro-cli/tests/against_mock_daemon.rs b/crates/maestro-cli/tests/against_mock_daemon.rs index 7b6f9f8..58066f2 100644 --- a/crates/maestro-cli/tests/against_mock_daemon.rs +++ b/crates/maestro-cli/tests/against_mock_daemon.rs @@ -29,6 +29,7 @@ async fn info_carries_version_and_presets() { label: "Claude".into(), command: "claude".into(), }], + ..Default::default() }); let info = mock.client().daemon_info().await.expect("info"); diff --git a/crates/maestro-daemon/build.rs b/crates/maestro-daemon/build.rs new file mode 100644 index 0000000..bdb1b66 --- /dev/null +++ b/crates/maestro-daemon/build.rs @@ -0,0 +1,75 @@ +//! Stamps the daemon binary with the commit it was built from, so +//! `GET /api/info` can tell clients which release they are talking to. +//! +//! Two sources, in order: +//! +//! 1. `MAESTRO_BUILD_COMMIT` / `MAESTRO_BUILD_DATE` / `MAESTRO_BUILD_DIRTY` in +//! the environment. The Nix build has no `.git` in its source tree (crane +//! filters it out), so `flake.nix` passes the flake's own revision this way. +//! 2. The git checkout the crate is being compiled in. +//! +//! Neither available leaves the values empty; clients treat that as "unknown +//! release" rather than an error. + +use std::path::PathBuf; +use std::process::Command; + +fn main() { + println!("cargo:rerun-if-env-changed=MAESTRO_BUILD_COMMIT"); + println!("cargo:rerun-if-env-changed=MAESTRO_BUILD_DATE"); + println!("cargo:rerun-if-env-changed=MAESTRO_BUILD_DIRTY"); + // Rebuild when HEAD moves, so a plain `cargo build` after a commit or a + // branch switch restamps instead of serving the previous hash. + for f in ["HEAD", "ORIG_HEAD"] { + if let Some(p) = git_dir().map(|d| d.join(f)) { + if p.exists() { + println!("cargo:rerun-if-changed={}", p.display()); + } + } + } + + let commit = env_or_git("MAESTRO_BUILD_COMMIT", &["rev-parse", "--short=12", "HEAD"]); + let date = env_or_git("MAESTRO_BUILD_DATE", &["log", "-1", "--format=%cI"]); + let dirty = match std::env::var("MAESTRO_BUILD_DIRTY") { + Ok(v) => v == "1" || v.eq_ignore_ascii_case("true"), + Err(_) => !git(&["status", "--porcelain"]).unwrap_or_default().is_empty(), + }; + + println!("cargo:rustc-env=MAESTRO_BUILD_COMMIT={commit}"); + println!("cargo:rustc-env=MAESTRO_BUILD_DATE={date}"); + println!("cargo:rustc-env=MAESTRO_BUILD_DIRTY={dirty}"); +} + +/// The environment variable if set and non-empty, else what git reports, else +/// an empty string. +fn env_or_git(var: &str, args: &[&str]) -> String { + match std::env::var(var) { + Ok(v) if !v.trim().is_empty() => v.trim().to_string(), + _ => git(args).unwrap_or_default(), + } +} + +/// Run `git` inside the crate's source tree. `None` when git is missing, the +/// tree is not a checkout, or the command fails — every one of which is a +/// legitimate way to build Maestro. +fn git(args: &[&str]) -> Option { + let out = Command::new("git") + .args(args) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .output() + .ok()?; + if !out.status.success() { + return None; + } + Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +/// Absolute path of the `.git` directory (a worktree's `.git` is a file +/// pointing elsewhere, and `--git-common-dir` resolves both). +fn git_dir() -> Option { + let dir = git(&["rev-parse", "--path-format=absolute", "--git-common-dir"])?; + if dir.is_empty() { + return None; + } + Some(PathBuf::from(dir)) +} diff --git a/crates/maestro-daemon/src/lib.rs b/crates/maestro-daemon/src/lib.rs index 0008a76..a0076ec 100644 --- a/crates/maestro-daemon/src/lib.rs +++ b/crates/maestro-daemon/src/lib.rs @@ -20,6 +20,7 @@ pub mod panes; pub mod proc; pub mod projects; pub mod pty; +pub mod release; pub mod session; pub mod sidecar; pub mod spawn_history; diff --git a/crates/maestro-daemon/src/local_api.rs b/crates/maestro-daemon/src/local_api.rs index 4071925..d570ec2 100644 --- a/crates/maestro-daemon/src/local_api.rs +++ b/crates/maestro-daemon/src/local_api.rs @@ -155,6 +155,8 @@ async fn daemon_info(State(s): State) -> Json { version: PROTOCOL_VERSION.into(), home_dir: (*s.home_dir).clone(), presets: (*s.presets).clone(), + build: crate::release::build_info(), + instance: crate::release::instance_id().to_string(), }) } diff --git a/crates/maestro-daemon/src/release.rs b/crates/maestro-daemon/src/release.rs new file mode 100644 index 0000000..41de234 --- /dev/null +++ b/crates/maestro-daemon/src/release.rs @@ -0,0 +1,31 @@ +//! What release this daemon is, and which run of it. +//! +//! Two facts ride `GET /api/info` so clients can render a status indicator and +//! survive an upgrade: +//! +//! * [`build_info`] — the commit the binary was compiled from, stamped by +//! `build.rs`. Shown in the indicator's tooltip; a change across a reconnect +//! means the operator upgraded, so a browser reloads its assets. +//! * [`instance_id`] — a fresh id per daemon process. A change with the same +//! commit means a plain restart, which clients ride out silently. + +use std::sync::OnceLock; + +use maestro_protocol::BuildInfo; + +/// The commit this binary was built from. Every field is empty when the build +/// happened outside a git checkout and no `MAESTRO_BUILD_*` override was given. +pub fn build_info() -> BuildInfo { + BuildInfo { + commit: env!("MAESTRO_BUILD_COMMIT").to_string(), + commit_date: env!("MAESTRO_BUILD_DATE").to_string(), + dirty: env!("MAESTRO_BUILD_DIRTY") == "true", + } +} + +/// Identity of this daemon process, stable for its lifetime and different on +/// every start. Clients diff it across a reconnect to detect a restart. +pub fn instance_id() -> &'static str { + static ID: OnceLock = OnceLock::new(); + ID.get_or_init(|| uuid::Uuid::new_v4().to_string()) +} diff --git a/crates/maestro-daemon/tests/local_api.rs b/crates/maestro-daemon/tests/local_api.rs index bc44136..d46622c 100644 --- a/crates/maestro-daemon/tests/local_api.rs +++ b/crates/maestro-daemon/tests/local_api.rs @@ -91,6 +91,35 @@ async fn info_returns_daemon_identity() { assert!(resp["version"].is_string()); } +#[tokio::test] +async fn info_reports_build_and_instance() { + // The status indicator needs a release to name and a process to compare + // against after a reconnect. `commit`/`commit_date` are empty when the + // binary was built outside a checkout, so only their presence is asserted; + // `instance` is a fresh uuid per daemon process. + let h = spawn("naps-laptop").await; + let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info")) + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(resp["build"]["commit"].is_string()); + assert!(resp["build"]["commit_date"].is_string()); + assert!(resp["build"]["dirty"].is_boolean()); + let instance = resp["instance"].as_str().unwrap(); + assert!(Uuid::parse_str(instance).is_ok(), "instance is a uuid"); + + // Same process, same id — a client only treats a *changed* id as a restart. + let again: serde_json::Value = reqwest::get(url(h.addr, "/api/info")) + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(again["instance"].as_str().unwrap(), instance); +} + #[tokio::test] async fn list_sessions_starts_empty_and_reflects_creates() { let h = spawn("naps-laptop").await; diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 2966d42..406b978 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -485,6 +485,40 @@ pub struct SpawnPreset { pub command: String, } +/// Build provenance of the running daemon: the commit it was compiled +/// from and when that commit was authored. +/// +/// Clients show it in the status indicator's tooltip, and compare `commit` +/// across a reconnect to tell an upgrade apart from a plain restart — an +/// upgrade means the served assets changed, so a browser reloads rather than +/// resuming against new code with an old bundle. +/// +/// Stamped at compile time by `maestro-daemon/build.rs`, which reads the git +/// checkout it is built in, or the `MAESTRO_BUILD_COMMIT` / `MAESTRO_BUILD_DATE` +/// environment variables when there is no checkout (the Nix build). Every field +/// is empty when neither is available; clients must render that gracefully. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct BuildInfo { + /// Abbreviated commit hash the binary was built from. Empty if unknown. + #[serde(default)] + pub commit: String, + /// Commit date of `commit`, RFC 3339 (`2026-09-02T14:03:11+01:00`). Empty + /// if unknown. + #[serde(default)] + pub commit_date: String, + /// Whether the working tree carried uncommitted changes at build time. + #[serde(default)] + pub dirty: bool, +} + +impl BuildInfo { + /// Whether anything is known about this build. A client with `false` here + /// shows the indicator without a release line rather than an empty one. + pub fn is_known(&self) -> bool { + !self.commit.is_empty() + } +} + /// Payload of `GET /api/info` — daemon identity, protocol version, and the /// spawn presets clients render as one-click tiles. /// @@ -503,6 +537,15 @@ pub struct DaemonInfo { pub home_dir: String, #[serde(default)] pub presets: Vec, + /// Which commit this daemon was built from. + #[serde(default)] + pub build: BuildInfo, + /// Identity of this daemon *process*, regenerated on every start. Two + /// replies carrying different ids mean the daemon restarted in between — + /// which is how a client that lost its socket tells a restart from a + /// network blip, even when the build did not change. + #[serde(default)] + pub instance: String, } /// Payload of `GET /api/names?count=N` (#53) — a batch of fresh diff --git a/crates/maestro-testing/src/lib.rs b/crates/maestro-testing/src/lib.rs index 14bc430..0a1c535 100644 --- a/crates/maestro-testing/src/lib.rs +++ b/crates/maestro-testing/src/lib.rs @@ -278,6 +278,7 @@ async fn daemon_info(State(s): State) -> Response { version: PROTOCOL_VERSION.into(), home_dir: "/home/mock".into(), presets: Vec::new(), + ..Default::default() }); Json(info).into_response() } diff --git a/flake.nix b/flake.nix index ea300d0..2964c89 100644 --- a/flake.nix +++ b/flake.nix @@ -33,6 +33,25 @@ version = (lib.importTOML ./Cargo.toml).workspace.package.version; + # Build provenance for `GET /api/info`. The Rust source tree crane + # hands to the compiler has no `.git`, so `maestro-daemon/build.rs` + # cannot ask git itself — the flake's own revision is passed instead. + # A dirty tree has no `rev`, and `dirtyRev` carries a `-dirty` suffix. + rev = inputs.self.rev or inputs.self.dirtyRev or ""; + # `lastModifiedDate` is `YYYYMMDDhhmmss` in UTC; clients want RFC 3339. + ld = inputs.self.lastModifiedDate or ""; + iso = + if ld == "" then + "" + else + "${builtins.substring 0 4 ld}-${builtins.substring 4 2 ld}-${builtins.substring 6 2 ld}" + + "T${builtins.substring 8 2 ld}:${builtins.substring 10 2 ld}:${builtins.substring 12 2 ld}Z"; + buildStamp = { + MAESTRO_BUILD_COMMIT = builtins.substring 0 12 rev; + MAESTRO_BUILD_DATE = iso; + MAESTRO_BUILD_DIRTY = if inputs.self ? rev then "0" else "1"; + }; + # Cargo source filtering keeps only *.rs, *.toml and Cargo.lock. The # SPA is built separately (below) and handed to the Rust build as a # path, so nothing under web/ needs to be in this source tree. @@ -85,6 +104,7 @@ maestro = craneLib.buildPackage ( commonArgs + // buildStamp // { inherit cargoArtifacts; pname = "maestro"; diff --git a/web/src/types.ts b/web/src/types.ts index 12ffe49..8c20eeb 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -86,6 +86,24 @@ export interface DaemonInfo { home_dir?: string; /** Command presets (claude/opencode/zsh + custom), config-resolved. */ presets: SpawnPreset[]; + /** Which commit the daemon was built from. Absent on older daemons. */ + build?: BuildInfo; + /** Identity of the daemon *process*, regenerated on every start — a changed + * value across a reconnect means the daemon restarted. Absent on older + * daemons, which the restart flow treats as "cannot tell". */ + instance?: string; +} + +/** Build provenance of the running daemon: what the status indicator names in + * its tooltip, and what tells an upgrade apart from a plain restart. Every + * field is empty when the binary was built outside a git checkout. */ +export interface BuildInfo { + /** Abbreviated commit hash. */ + commit: string; + /** Commit date, RFC 3339. */ + commit_date: string; + /** Whether the tree had uncommitted changes at build time. */ + dirty: boolean; } /** One deduplicated recent working dir for the spawn form's recents picker