Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29545d15fe | ||
|
|
4d7e368d31 | ||
|
|
eaf60d9dac |
@@ -71,6 +71,11 @@ before changing anything that creates or removes one.
|
|||||||
sidebars — line layout, activity colours, duration format, state precedence.
|
sidebars — line layout, activity colours, duration format, state precedence.
|
||||||
Read it before changing sidebar rendering.
|
Read it before changing sidebar rendering.
|
||||||
|
|
||||||
|
`docs/RESTART.md` is the contract for what clients do when the daemon restarts
|
||||||
|
or is upgraded under them — the build stamp on `/api/info`, the status
|
||||||
|
indicator's states, the blocking overlay, grace and poll timings. Read it
|
||||||
|
before changing reconnect behaviour on either client.
|
||||||
|
|
||||||
`nix run .#dev` (or `just dev`) brings up the whole local stack under
|
`nix run .#dev` (or `just dev`) brings up the whole local stack under
|
||||||
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
|
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
|
||||||
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
|
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ async fn info_carries_version_and_presets() {
|
|||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
}],
|
}],
|
||||||
|
..Default::default()
|
||||||
});
|
});
|
||||||
|
|
||||||
let info = mock.client().daemon_info().await.expect("info");
|
let info = mock.client().daemon_info().await.expect("info");
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
//! 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<String> {
|
||||||
|
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<PathBuf> {
|
||||||
|
let dir = git(&["rev-parse", "--path-format=absolute", "--git-common-dir"])?;
|
||||||
|
if dir.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(PathBuf::from(dir))
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ pub mod panes;
|
|||||||
pub mod proc;
|
pub mod proc;
|
||||||
pub mod projects;
|
pub mod projects;
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
|
pub mod release;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod sidecar;
|
pub mod sidecar;
|
||||||
pub mod spawn_history;
|
pub mod spawn_history;
|
||||||
|
|||||||
@@ -155,6 +155,8 @@ async fn daemon_info(State(s): State<LocalApiState>) -> Json<DaemonInfo> {
|
|||||||
version: PROTOCOL_VERSION.into(),
|
version: PROTOCOL_VERSION.into(),
|
||||||
home_dir: (*s.home_dir).clone(),
|
home_dir: (*s.home_dir).clone(),
|
||||||
presets: (*s.presets).clone(),
|
presets: (*s.presets).clone(),
|
||||||
|
build: crate::release::build_info(),
|
||||||
|
instance: crate::release::instance_id().to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<String> = OnceLock::new();
|
||||||
|
ID.get_or_init(|| uuid::Uuid::new_v4().to_string())
|
||||||
|
}
|
||||||
@@ -91,6 +91,35 @@ async fn info_returns_daemon_identity() {
|
|||||||
assert!(resp["version"].is_string());
|
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]
|
#[tokio::test]
|
||||||
async fn list_sessions_starts_empty_and_reflects_creates() {
|
async fn list_sessions_starts_empty_and_reflects_creates() {
|
||||||
let h = spawn("naps-laptop").await;
|
let h = spawn("naps-laptop").await;
|
||||||
|
|||||||
@@ -485,6 +485,40 @@ pub struct SpawnPreset {
|
|||||||
pub command: String,
|
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
|
/// Payload of `GET /api/info` — daemon identity, protocol version, and the
|
||||||
/// spawn presets clients render as one-click tiles.
|
/// spawn presets clients render as one-click tiles.
|
||||||
///
|
///
|
||||||
@@ -503,6 +537,15 @@ pub struct DaemonInfo {
|
|||||||
pub home_dir: String,
|
pub home_dir: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub presets: Vec<SpawnPreset>,
|
pub presets: Vec<SpawnPreset>,
|
||||||
|
/// 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
|
/// Payload of `GET /api/names?count=N` (#53) — a batch of fresh
|
||||||
|
|||||||
@@ -278,6 +278,7 @@ async fn daemon_info(State(s): State<AppState>) -> Response {
|
|||||||
version: PROTOCOL_VERSION.into(),
|
version: PROTOCOL_VERSION.into(),
|
||||||
home_dir: "/home/mock".into(),
|
home_dir: "/home/mock".into(),
|
||||||
presets: Vec::new(),
|
presets: Vec::new(),
|
||||||
|
..Default::default()
|
||||||
});
|
});
|
||||||
Json(info).into_response()
|
Json(info).into_response()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ use uuid::Uuid;
|
|||||||
use crate::action::Action;
|
use crate::action::Action;
|
||||||
use crate::catalog::CatalogStore;
|
use crate::catalog::CatalogStore;
|
||||||
use crate::config::TuiConfig;
|
use crate::config::TuiConfig;
|
||||||
|
use crate::daemon_link::DaemonLink;
|
||||||
use crate::draft::{DraftInputResult, DraftPane};
|
use crate::draft::{DraftInputResult, DraftPane};
|
||||||
use crate::emulators::SessionEmulators;
|
use crate::emulators::SessionEmulators;
|
||||||
use crate::input::{
|
use crate::input::{
|
||||||
@@ -58,6 +59,9 @@ pub struct App {
|
|||||||
help: HelpOverlay,
|
help: HelpOverlay,
|
||||||
chord: ChordState,
|
chord: ChordState,
|
||||||
daemon: DaemonClient,
|
daemon: DaemonClient,
|
||||||
|
/// Liveness + release of the daemon (`docs/RESTART.md`): drives the status
|
||||||
|
/// bar's indicator, and locks the keyboard behind the restart overlay.
|
||||||
|
link: DaemonLink,
|
||||||
/// The spawn form behind [`OverlayKind::Spawn`], kept here rather than in
|
/// The spawn form behind [`OverlayKind::Spawn`], kept here rather than in
|
||||||
/// `AppState` so the overlay enum stays comparable — the same reason the
|
/// `AppState` so the overlay enum stays comparable — the same reason the
|
||||||
/// rename buffer lives here.
|
/// rename buffer lives here.
|
||||||
@@ -127,7 +131,12 @@ pub enum Effect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub fn new(cfg: TuiConfig, catalog: CatalogStore, daemon: DaemonClient) -> Self {
|
pub fn new(
|
||||||
|
cfg: TuiConfig,
|
||||||
|
catalog: CatalogStore,
|
||||||
|
daemon: DaemonClient,
|
||||||
|
link: DaemonLink,
|
||||||
|
) -> Self {
|
||||||
let (draft_tx, draft_rx) = tokio::sync::mpsc::unbounded_channel();
|
let (draft_tx, draft_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
let chord_timeout = cfg.chord_timeout;
|
let chord_timeout = cfg.chord_timeout;
|
||||||
let help = HelpOverlay {
|
let help = HelpOverlay {
|
||||||
@@ -148,6 +157,7 @@ impl App {
|
|||||||
help,
|
help,
|
||||||
chord: ChordState::new(chord_timeout),
|
chord: ChordState::new(chord_timeout),
|
||||||
daemon,
|
daemon,
|
||||||
|
link,
|
||||||
http,
|
http,
|
||||||
rename_input: TextInput::default(),
|
rename_input: TextInput::default(),
|
||||||
draft_tx,
|
draft_tx,
|
||||||
@@ -182,6 +192,10 @@ impl App {
|
|||||||
_ = self.catalog.changed() => {
|
_ = self.catalog.changed() => {
|
||||||
// Render on next loop iteration.
|
// Render on next loop iteration.
|
||||||
}
|
}
|
||||||
|
_ = self.link.changed() => {
|
||||||
|
// The status indicator and the restart overlay both read
|
||||||
|
// the link; redraw on the next loop iteration.
|
||||||
|
}
|
||||||
_ = tokio::time::sleep(Duration::from_millis(33)) => {
|
_ = tokio::time::sleep(Duration::from_millis(33)) => {
|
||||||
let effects = self.poll_due_probes_at(std::time::Instant::now());
|
let effects = self.poll_due_probes_at(std::time::Instant::now());
|
||||||
self.perform(effects).await;
|
self.perform(effects).await;
|
||||||
@@ -198,6 +212,21 @@ impl App {
|
|||||||
};
|
};
|
||||||
let key = self.clicks.promote(key, std::time::Instant::now());
|
let key = self.clicks.promote(key, std::time::Instant::now());
|
||||||
|
|
||||||
|
// The daemon is gone: everything a key could do goes through it, so
|
||||||
|
// only the quit binding still resolves until it answers again.
|
||||||
|
if self.link.is_blocking() {
|
||||||
|
let known = self.cfg.keymap.known_bindings(self.state.mode);
|
||||||
|
if let ChordResult::Resolved(canon) = self.chord.feed(&key, &known) {
|
||||||
|
if matches!(
|
||||||
|
self.cfg.keymap.resolve(self.state.mode, &canon),
|
||||||
|
Some(Action::Quit)
|
||||||
|
) {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
// Overlay intercept first.
|
// Overlay intercept first.
|
||||||
let (decision, effects) = self.handle_overlay_input(&key);
|
let (decision, effects) = self.handle_overlay_input(&key);
|
||||||
self.perform(effects).await;
|
self.perform(effects).await;
|
||||||
@@ -919,6 +948,7 @@ impl App {
|
|||||||
None => "session".into(),
|
None => "session".into(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let link_status = self.link.status();
|
||||||
let inputs = RenderInputs {
|
let inputs = RenderInputs {
|
||||||
sidebar: &sidebar,
|
sidebar: &sidebar,
|
||||||
home_dir: &self.home_dir,
|
home_dir: &self.home_dir,
|
||||||
@@ -928,6 +958,7 @@ impl App {
|
|||||||
draft: self.draft.as_ref(),
|
draft: self.draft.as_ref(),
|
||||||
help: &self.help,
|
help: &self.help,
|
||||||
rename_input: &self.rename_input,
|
rename_input: &self.rename_input,
|
||||||
|
link: &link_status,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut new_region = RegionMap::default();
|
let mut new_region = RegionMap::default();
|
||||||
@@ -1176,7 +1207,12 @@ mod tests {
|
|||||||
|
|
||||||
fn make_app_at(base: &str) -> App {
|
fn make_app_at(base: &str) -> App {
|
||||||
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||||
App::new(cfg, CatalogStore::default(), DaemonClient::new(base))
|
App::new(
|
||||||
|
cfg,
|
||||||
|
CatalogStore::default(),
|
||||||
|
DaemonClient::new(base),
|
||||||
|
DaemonLink::default(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1772,6 +1808,7 @@ mod tests {
|
|||||||
Duration::from_millis(200),
|
Duration::from_millis(200),
|
||||||
Duration::from_millis(500),
|
Duration::from_millis(500),
|
||||||
),
|
),
|
||||||
|
DaemonLink::default(),
|
||||||
);
|
);
|
||||||
app.dispatch(Action::OpenSpawnModal);
|
app.dispatch(Action::OpenSpawnModal);
|
||||||
let req = CreateSessionRequest {
|
let req = CreateSessionRequest {
|
||||||
|
|||||||
@@ -127,11 +127,24 @@ impl CatalogStore {
|
|||||||
/// apply them. Reconnects with exponential backoff on disconnect. Never
|
/// apply them. Reconnects with exponential backoff on disconnect. Never
|
||||||
/// returns — spawn it on a Tokio task.
|
/// returns — spawn it on a Tokio task.
|
||||||
pub async fn run_events_loop(self, ws_url: String) {
|
pub async fn run_events_loop(self, ws_url: String) {
|
||||||
|
self.run_events_loop_reporting(ws_url, crate::daemon_link::DaemonLink::default())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::run_events_loop`], reporting every connect and disconnect to
|
||||||
|
/// `link` — the socket dying is the first sign the daemon went down or was
|
||||||
|
/// upgraded under the client (`docs/RESTART.md`).
|
||||||
|
pub async fn run_events_loop_reporting(
|
||||||
|
self,
|
||||||
|
ws_url: String,
|
||||||
|
link: crate::daemon_link::DaemonLink,
|
||||||
|
) {
|
||||||
let mut backoff = Duration::from_secs(1);
|
let mut backoff = Duration::from_secs(1);
|
||||||
loop {
|
loop {
|
||||||
match tokio_tungstenite::connect_async(&ws_url).await {
|
match tokio_tungstenite::connect_async(&ws_url).await {
|
||||||
Ok((ws, _resp)) => {
|
Ok((ws, _resp)) => {
|
||||||
backoff = Duration::from_secs(1);
|
backoff = Duration::from_secs(1);
|
||||||
|
link.note_connected();
|
||||||
let (_sink, mut stream) = ws.split();
|
let (_sink, mut stream) = ws.split();
|
||||||
while let Some(frame) = stream.next().await {
|
while let Some(frame) = stream.next().await {
|
||||||
match frame {
|
match frame {
|
||||||
@@ -152,6 +165,7 @@ impl CatalogStore {
|
|||||||
tracing::debug!(error = ?e, "catalog: connect failed");
|
tracing::debug!(error = ?e, "catalog: connect failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
link.note_disconnected(std::time::Instant::now());
|
||||||
tracing::debug!(?backoff, "catalog: sleeping before reconnect");
|
tracing::debug!(?backoff, "catalog: sleeping before reconnect");
|
||||||
tokio::time::sleep(jitter(backoff)).await;
|
tokio::time::sleep(jitter(backoff)).await;
|
||||||
backoff = std::cmp::min(backoff.saturating_mul(2), Duration::from_secs(30));
|
backoff = std::cmp::min(backoff.saturating_mul(2), Duration::from_secs(30));
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
//! Liveness and identity of the daemon behind this TUI.
|
||||||
|
//!
|
||||||
|
//! The status bar renders [`LinkStatus`], and [`LinkState::Down`] puts the
|
||||||
|
//! restart overlay up and locks the keyboard. `docs/RESTART.md` is the
|
||||||
|
//! contract; this is the TUI half of what `web/src/daemonLink.ts` does.
|
||||||
|
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use maestro_protocol::{BuildInfo, DaemonClient, DaemonInfo};
|
||||||
|
use tokio::sync::Notify;
|
||||||
|
|
||||||
|
/// How long a lost connection stays quiet before the overlay blocks the UI.
|
||||||
|
pub const GRACE: Duration = Duration::from_millis(1200);
|
||||||
|
/// Cadence of `GET /api/info` while the daemon is unreachable.
|
||||||
|
pub const POLL: Duration = Duration::from_millis(700);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum LinkState {
|
||||||
|
/// No probe has landed yet.
|
||||||
|
Connecting,
|
||||||
|
/// The daemon answered.
|
||||||
|
Online,
|
||||||
|
/// The events socket dropped, still inside the grace window.
|
||||||
|
Reconnecting,
|
||||||
|
/// Unreachable past the grace window: the overlay is up.
|
||||||
|
Down,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What happened between two `/api/info` replies.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum RestartKind {
|
||||||
|
Same,
|
||||||
|
Restarted,
|
||||||
|
Upgraded,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare two daemon replies. The commit decides first — a different one is
|
||||||
|
/// new code. `instance` catches a restart of the same build, and is the only
|
||||||
|
/// signal for a daemon built outside a git checkout (empty commit), which must
|
||||||
|
/// never be read as an upgrade.
|
||||||
|
pub fn compare_identity(before: &DaemonInfo, after: &DaemonInfo) -> RestartKind {
|
||||||
|
let a = before.build.commit.as_str();
|
||||||
|
let b = after.build.commit.as_str();
|
||||||
|
if !a.is_empty() && !b.is_empty() && a != b {
|
||||||
|
return RestartKind::Upgraded;
|
||||||
|
}
|
||||||
|
if !before.instance.is_empty()
|
||||||
|
&& !after.instance.is_empty()
|
||||||
|
&& before.instance != after.instance
|
||||||
|
{
|
||||||
|
return RestartKind::Restarted;
|
||||||
|
}
|
||||||
|
RestartKind::Same
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line naming a release: `f89be4a716c7 · 2026-09-02`, `(dirty)` when the
|
||||||
|
/// build carried uncommitted changes.
|
||||||
|
pub fn format_release(build: &BuildInfo) -> String {
|
||||||
|
if build.commit.is_empty() {
|
||||||
|
return "unknown release".to_string();
|
||||||
|
}
|
||||||
|
let mut out = build.commit.clone();
|
||||||
|
if build.commit_date.len() >= 10 {
|
||||||
|
out.push_str(" · ");
|
||||||
|
out.push_str(&build.commit_date[..10]);
|
||||||
|
}
|
||||||
|
if build.dirty {
|
||||||
|
out.push_str(" (dirty)");
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot of the link, taken once per frame by the renderer.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LinkStatus {
|
||||||
|
pub state: LinkState,
|
||||||
|
/// The release the daemon last reported. `None` until the first probe.
|
||||||
|
pub build: Option<BuildInfo>,
|
||||||
|
/// Failed probes in the current outage — the overlay counts them so a long
|
||||||
|
/// restart doesn't read as a hang.
|
||||||
|
pub attempts: u32,
|
||||||
|
/// Set once the daemon comes back on a different commit. The TUI is its own
|
||||||
|
/// binary, so it keeps running against the new daemon and shows this until
|
||||||
|
/// the user restarts it onto the new client.
|
||||||
|
pub upgraded_to: Option<BuildInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct DaemonLink {
|
||||||
|
inner: Arc<Inner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct Inner {
|
||||||
|
state: RwLock<State>,
|
||||||
|
notify: Notify,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct State {
|
||||||
|
state: LinkState,
|
||||||
|
identity: Option<DaemonInfo>,
|
||||||
|
attempts: u32,
|
||||||
|
/// When the events socket dropped, for the grace window.
|
||||||
|
down_since: Option<Instant>,
|
||||||
|
upgraded_to: Option<BuildInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for State {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
state: LinkState::Connecting,
|
||||||
|
identity: None,
|
||||||
|
attempts: 0,
|
||||||
|
down_since: None,
|
||||||
|
upgraded_to: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DaemonLink {
|
||||||
|
pub fn status(&self) -> LinkStatus {
|
||||||
|
let s = self.read();
|
||||||
|
LinkStatus {
|
||||||
|
state: s.state,
|
||||||
|
build: s.identity.as_ref().map(|i| i.build.clone()),
|
||||||
|
attempts: s.attempts,
|
||||||
|
upgraded_to: s.upgraded_to.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the UI is locked behind the restart overlay.
|
||||||
|
pub fn is_blocking(&self) -> bool {
|
||||||
|
self.read().state == LinkState::Down
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait until anything about the link changes, so the render loop redraws.
|
||||||
|
pub async fn changed(&self) {
|
||||||
|
self.inner.notify.notified().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The events socket dropped. Starts the grace window.
|
||||||
|
pub fn note_disconnected(&self, now: Instant) {
|
||||||
|
{
|
||||||
|
let mut s = self.write();
|
||||||
|
if s.state == LinkState::Down {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s.state = LinkState::Reconnecting;
|
||||||
|
s.attempts = 0;
|
||||||
|
s.down_since = Some(now);
|
||||||
|
}
|
||||||
|
self.wake();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The events socket came back. Does not clear the state on its own: the
|
||||||
|
/// socket carries no identity, and only a probe can say which daemon
|
||||||
|
/// answered.
|
||||||
|
pub fn note_connected(&self) {
|
||||||
|
self.wake();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold in a successful `/api/info`. A reply on a new commit is recorded as
|
||||||
|
/// an upgrade; either way the link is back online.
|
||||||
|
pub fn apply_info(&self, info: DaemonInfo) {
|
||||||
|
{
|
||||||
|
let mut s = self.write();
|
||||||
|
if let Some(before) = &s.identity {
|
||||||
|
if compare_identity(before, &info) == RestartKind::Upgraded {
|
||||||
|
s.upgraded_to = Some(info.build.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.identity = Some(info);
|
||||||
|
s.state = LinkState::Online;
|
||||||
|
s.attempts = 0;
|
||||||
|
s.down_since = None;
|
||||||
|
}
|
||||||
|
self.wake();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold in a failed probe. The overlay goes up once the grace window since
|
||||||
|
/// the disconnect has passed.
|
||||||
|
pub fn probe_failed(&self, now: Instant) {
|
||||||
|
{
|
||||||
|
let mut s = self.write();
|
||||||
|
s.attempts = s.attempts.saturating_add(1);
|
||||||
|
let since = *s.down_since.get_or_insert(now);
|
||||||
|
if now.duration_since(since) >= GRACE {
|
||||||
|
s.state = LinkState::Down;
|
||||||
|
} else if s.state == LinkState::Online {
|
||||||
|
s.state = LinkState::Reconnecting;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.wake();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Long-running task: keep [`LinkStatus`] true. Probes on a flat cadence
|
||||||
|
/// while the daemon is unreachable and idles while it is up. Never returns.
|
||||||
|
pub async fn run_probe_loop(self, daemon: DaemonClient) {
|
||||||
|
loop {
|
||||||
|
if self.read().state == LinkState::Online {
|
||||||
|
// A tick bounds the wait so a disconnect racing the notify
|
||||||
|
// costs a second, not the whole outage.
|
||||||
|
let _ = tokio::time::timeout(Duration::from_secs(1), self.changed()).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match daemon.daemon_info().await {
|
||||||
|
Ok(info) => self.apply_info(info),
|
||||||
|
Err(_) => {
|
||||||
|
self.probe_failed(Instant::now());
|
||||||
|
tokio::time::sleep(POLL).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wake(&self) {
|
||||||
|
self.inner.notify.notify_waiters();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(&self) -> std::sync::RwLockReadGuard<'_, State> {
|
||||||
|
self.inner
|
||||||
|
.state
|
||||||
|
.read()
|
||||||
|
.expect("daemon link rwlock poisoned")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(&self) -> std::sync::RwLockWriteGuard<'_, State> {
|
||||||
|
self.inner
|
||||||
|
.state
|
||||||
|
.write()
|
||||||
|
.expect("daemon link rwlock poisoned")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn info(commit: &str, instance: &str) -> DaemonInfo {
|
||||||
|
DaemonInfo {
|
||||||
|
hostname: "konishi".into(),
|
||||||
|
version: "0.1.0".into(),
|
||||||
|
home_dir: "/home/naps".into(),
|
||||||
|
presets: Vec::new(),
|
||||||
|
build: BuildInfo {
|
||||||
|
commit: commit.into(),
|
||||||
|
commit_date: "2026-09-02T14:03:11Z".into(),
|
||||||
|
dirty: false,
|
||||||
|
},
|
||||||
|
instance: instance.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identity_tells_upgrade_from_restart() {
|
||||||
|
assert_eq!(
|
||||||
|
compare_identity(&info("aaa", "i1"), &info("bbb", "i2")),
|
||||||
|
RestartKind::Upgraded
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
compare_identity(&info("aaa", "i1"), &info("aaa", "i2")),
|
||||||
|
RestartKind::Restarted
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
compare_identity(&info("aaa", "i1"), &info("aaa", "i1")),
|
||||||
|
RestartKind::Same
|
||||||
|
);
|
||||||
|
// No build stamp: the instance still reports the restart, and nothing
|
||||||
|
// ever claims a phantom upgrade.
|
||||||
|
assert_eq!(
|
||||||
|
compare_identity(&info("", "i1"), &info("", "i2")),
|
||||||
|
RestartKind::Restarted
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn release_line_pairs_hash_with_date() {
|
||||||
|
assert_eq!(
|
||||||
|
format_release(&info("f89be4a716c7", "i1").build),
|
||||||
|
"f89be4a716c7 · 2026-09-02"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format_release(&BuildInfo {
|
||||||
|
commit: "abc".into(),
|
||||||
|
commit_date: String::new(),
|
||||||
|
dirty: true,
|
||||||
|
}),
|
||||||
|
"abc (dirty)"
|
||||||
|
);
|
||||||
|
assert_eq!(format_release(&BuildInfo::default()), "unknown release");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grace_window_holds_the_overlay_back() {
|
||||||
|
let link = DaemonLink::default();
|
||||||
|
link.apply_info(info("aaa", "i1"));
|
||||||
|
assert_eq!(link.status().state, LinkState::Online);
|
||||||
|
|
||||||
|
let t0 = Instant::now();
|
||||||
|
link.note_disconnected(t0);
|
||||||
|
assert_eq!(link.status().state, LinkState::Reconnecting);
|
||||||
|
assert!(!link.is_blocking());
|
||||||
|
|
||||||
|
link.probe_failed(t0 + Duration::from_millis(400));
|
||||||
|
assert_eq!(link.status().state, LinkState::Reconnecting);
|
||||||
|
|
||||||
|
link.probe_failed(t0 + GRACE);
|
||||||
|
assert_eq!(link.status().state, LinkState::Down);
|
||||||
|
assert!(link.is_blocking());
|
||||||
|
assert_eq!(link.status().attempts, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_plain_restart_unlocks_without_an_upgrade_note() {
|
||||||
|
let link = DaemonLink::default();
|
||||||
|
link.apply_info(info("aaa", "i1"));
|
||||||
|
let t0 = Instant::now();
|
||||||
|
link.note_disconnected(t0);
|
||||||
|
link.probe_failed(t0 + GRACE);
|
||||||
|
assert!(link.is_blocking());
|
||||||
|
|
||||||
|
link.apply_info(info("aaa", "i2"));
|
||||||
|
assert_eq!(link.status().state, LinkState::Online);
|
||||||
|
assert!(link.status().upgraded_to.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_upgrade_unlocks_and_names_the_new_release() {
|
||||||
|
let link = DaemonLink::default();
|
||||||
|
link.apply_info(info("aaa", "i1"));
|
||||||
|
let t0 = Instant::now();
|
||||||
|
link.note_disconnected(t0);
|
||||||
|
link.probe_failed(t0 + GRACE);
|
||||||
|
|
||||||
|
link.apply_info(info("bbb", "i2"));
|
||||||
|
let status = link.status();
|
||||||
|
assert_eq!(status.state, LinkState::Online);
|
||||||
|
assert_eq!(status.upgraded_to.unwrap().commit, "bbb");
|
||||||
|
assert!(!link.is_blocking());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ pub mod app;
|
|||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod daemon_link;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
pub mod emulators;
|
pub mod emulators;
|
||||||
pub mod fast_forward;
|
pub mod fast_forward;
|
||||||
@@ -33,6 +34,7 @@ use ratatui::Terminal;
|
|||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::catalog::CatalogStore;
|
use crate::catalog::CatalogStore;
|
||||||
use crate::config::TuiConfig;
|
use crate::config::TuiConfig;
|
||||||
|
use crate::daemon_link::DaemonLink;
|
||||||
|
|
||||||
/// Inputs for [`run`]. Built by the umbrella binary from the parsed
|
/// Inputs for [`run`]. Built by the umbrella binary from the parsed
|
||||||
/// `TuiArgs` plus any global flags.
|
/// `TuiArgs` plus any global flags.
|
||||||
@@ -94,8 +96,15 @@ pub async fn run(cfg: RunConfig) -> Result<(), TuiError> {
|
|||||||
let ws_url = daemon
|
let ws_url = daemon
|
||||||
.events_url()
|
.events_url()
|
||||||
.map_err(|e| TuiError::Config(e.to_string()))?;
|
.map_err(|e| TuiError::Config(e.to_string()))?;
|
||||||
|
// The link watches the same daemon over `/api/info`; the events socket
|
||||||
|
// reports its own connects and disconnects into it (`docs/RESTART.md`).
|
||||||
|
let link = DaemonLink::default();
|
||||||
let bg_catalog = catalog.clone();
|
let bg_catalog = catalog.clone();
|
||||||
tokio::spawn(async move { bg_catalog.run_events_loop(ws_url).await });
|
let ws_link = link.clone();
|
||||||
|
tokio::spawn(async move { bg_catalog.run_events_loop_reporting(ws_url, ws_link).await });
|
||||||
|
let probe_link = link.clone();
|
||||||
|
let probe_daemon = daemon.clone();
|
||||||
|
tokio::spawn(async move { probe_link.run_probe_loop(probe_daemon).await });
|
||||||
|
|
||||||
// Wait briefly for the bootstrap Snapshot to land. If it doesn't, we
|
// Wait briefly for the bootstrap Snapshot to land. If it doesn't, we
|
||||||
// still enter the UI — the sidebar will populate when events flow.
|
// still enter the UI — the sidebar will populate when events flow.
|
||||||
@@ -107,7 +116,7 @@ pub async fn run(cfg: RunConfig) -> Result<(), TuiError> {
|
|||||||
let backend = CrosstermBackend::new(std::io::stdout());
|
let backend = CrosstermBackend::new(std::io::stdout());
|
||||||
let mut terminal = Terminal::new(backend).map_err(|e| TuiError::Terminal(e.to_string()))?;
|
let mut terminal = Terminal::new(backend).map_err(|e| TuiError::Terminal(e.to_string()))?;
|
||||||
|
|
||||||
let app = App::new(tui_cfg, catalog, daemon);
|
let app = App::new(tui_cfg, catalog, daemon, link);
|
||||||
app.run(&mut terminal).await
|
app.run(&mut terminal).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! Overlay widgets: spawn modal, kill-confirm prompt, rename prompt, help
|
//! Overlay widgets: spawn modal, kill-confirm prompt, rename prompt, help
|
||||||
//! overlay, transient toast.
|
//! overlay, restart overlay, transient toast.
|
||||||
//!
|
//!
|
||||||
//! All overlays are centered popups drawn over the rest of the UI; the
|
//! All overlays are centered popups drawn over the rest of the UI; the
|
||||||
//! `Clear` widget wipes the underlying buffer before drawing.
|
//! `Clear` widget wipes the underlying buffer before drawing.
|
||||||
@@ -33,6 +33,42 @@ impl SpawnModal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shown while the daemon is unreachable past the grace window, over
|
||||||
|
/// everything else, with the keyboard locked (`docs/RESTART.md`). Nothing to
|
||||||
|
/// dismiss: it clears itself when a probe succeeds.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RestartOverlay {
|
||||||
|
/// Failed probes so far, so a long restart doesn't read as a hang.
|
||||||
|
pub attempts: u32,
|
||||||
|
/// The release the daemon was running before it went away.
|
||||||
|
pub release: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RestartOverlay {
|
||||||
|
pub fn render(&self, f: &mut Frame, parent: Rect) {
|
||||||
|
let area = centered(parent, 54, 7);
|
||||||
|
f.render_widget(Clear, area);
|
||||||
|
let block = Block::default()
|
||||||
|
.title(" daemon restarting ")
|
||||||
|
.borders(Borders::ALL);
|
||||||
|
let inner = block.inner(area);
|
||||||
|
f.render_widget(block, area);
|
||||||
|
let attempts = if self.attempts <= 1 {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" ({} attempts)", self.attempts)
|
||||||
|
};
|
||||||
|
let lines = vec![
|
||||||
|
Line::from("Waiting for the daemon to come back…"),
|
||||||
|
Line::from(format!("Polling /api/info{attempts}")),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(format!("was: {}", self.release)),
|
||||||
|
Line::from("Sessions are held by tmux and survive the restart."),
|
||||||
|
];
|
||||||
|
f.render_widget(Paragraph::new(lines), inner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct KillConfirm {
|
pub struct KillConfirm {
|
||||||
pub label: String,
|
pub label: String,
|
||||||
|
|||||||
@@ -3,14 +3,17 @@
|
|||||||
//! hit-test mouse clicks.
|
//! hit-test mouse clicks.
|
||||||
|
|
||||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
use ratatui::style::Modifier;
|
use ratatui::style::{Modifier, Style};
|
||||||
use ratatui::text::Span;
|
use ratatui::text::Span;
|
||||||
use ratatui::widgets::Paragraph;
|
use ratatui::widgets::Paragraph;
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
|
use maestro_protocol::ActivityState;
|
||||||
|
|
||||||
|
use crate::daemon_link::{format_release, LinkState, LinkStatus};
|
||||||
use crate::input::Region;
|
use crate::input::Region;
|
||||||
use crate::keymap::Mode;
|
use crate::keymap::Mode;
|
||||||
use crate::overlays::{HelpOverlay, KillConfirm, RenamePrompt, SpawnModal, Toast};
|
use crate::overlays::{HelpOverlay, KillConfirm, RenamePrompt, RestartOverlay, SpawnModal, Toast};
|
||||||
use crate::palette;
|
use crate::palette;
|
||||||
use crate::pane::Pane;
|
use crate::pane::Pane;
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
@@ -83,6 +86,9 @@ pub struct RenderInputs<'a> {
|
|||||||
pub help: &'a HelpOverlay,
|
pub help: &'a HelpOverlay,
|
||||||
/// Edit buffer behind the rename prompt (#125).
|
/// Edit buffer behind the rename prompt (#125).
|
||||||
pub rename_input: &'a crate::text_input::TextInput,
|
pub rename_input: &'a crate::text_input::TextInput,
|
||||||
|
/// Liveness + release of the daemon, for the status bar's indicator and the
|
||||||
|
/// restart overlay (`docs/RESTART.md`).
|
||||||
|
pub link: &'a crate::daemon_link::LinkStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draw one frame. Returns the `RegionMap` the input router will consult for
|
/// Draw one frame. Returns the `RegionMap` the input router will consult for
|
||||||
@@ -145,7 +151,7 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
|||||||
|
|
||||||
// Status bar — always visible, never eclipsed by overlays so the user can
|
// Status bar — always visible, never eclipsed by overlays so the user can
|
||||||
// always see the current mode + key hints.
|
// always see the current mode + key hints.
|
||||||
render_status_bar(f, status_area, inputs.state);
|
render_status_bar(f, status_area, inputs.state, inputs.link);
|
||||||
|
|
||||||
match inputs.state.overlay() {
|
match inputs.state.overlay() {
|
||||||
Some(OverlayKind::Spawn) => {
|
Some(OverlayKind::Spawn) => {
|
||||||
@@ -203,6 +209,22 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
|||||||
None => {}
|
None => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Last, so it covers whatever else is up: while the daemon is gone none of
|
||||||
|
// it can act (`docs/RESTART.md`).
|
||||||
|
if inputs.link.state == LinkState::Down {
|
||||||
|
RestartOverlay {
|
||||||
|
attempts: inputs.link.attempts,
|
||||||
|
release: inputs
|
||||||
|
.link
|
||||||
|
.build
|
||||||
|
.as_ref()
|
||||||
|
.map(format_release)
|
||||||
|
.unwrap_or_else(|| "unknown release".to_string()),
|
||||||
|
}
|
||||||
|
.render(f, area);
|
||||||
|
map.modal = Some(area);
|
||||||
|
}
|
||||||
|
|
||||||
map
|
map
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +242,16 @@ fn render_title_bar(f: &mut Frame, area: Rect, title: &str, focused: bool) {
|
|||||||
/// Render the always-visible status bar at the bottom of the frame. Surfaces
|
/// Render the always-visible status bar at the bottom of the frame. Surfaces
|
||||||
/// mode + focused panel + the most useful key hints so the user is never
|
/// mode + focused panel + the most useful key hints so the user is never
|
||||||
/// guessing what state they're in.
|
/// guessing what state they're in.
|
||||||
fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) {
|
fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState, link: &LinkStatus) {
|
||||||
|
// The daemon indicator is pinned right and sized to its own text, so a
|
||||||
|
// narrow terminal truncates the key hints rather than the release.
|
||||||
|
let label = link_label(link);
|
||||||
|
let indicator_w = (label.chars().count() as u16).saturating_add(3);
|
||||||
|
let cols = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Min(0), Constraint::Length(indicator_w)])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
let mode_label = match state.mode {
|
let mode_label = match state.mode {
|
||||||
Mode::Normal => "[NORMAL]",
|
Mode::Normal => "[NORMAL]",
|
||||||
Mode::Insert => "[INSERT]",
|
Mode::Insert => "[INSERT]",
|
||||||
@@ -238,7 +269,42 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
Span::styled(format!(" {mode_label} "), mode_style),
|
Span::styled(format!(" {mode_label} "), mode_style),
|
||||||
Span::raw(format!(" {focus_label} • {hint}")),
|
Span::raw(format!(" {focus_label} • {hint}")),
|
||||||
]);
|
]);
|
||||||
f.render_widget(Paragraph::new(line), area);
|
f.render_widget(Paragraph::new(line), cols[0]);
|
||||||
|
|
||||||
|
let indicator = ratatui::text::Line::from(vec![
|
||||||
|
Span::styled(" ●", Style::default().fg(link_colour(link.state))),
|
||||||
|
Span::raw(format!(" {label} ")),
|
||||||
|
]);
|
||||||
|
f.render_widget(Paragraph::new(indicator), cols[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The status dot's colour: the same three the sidebar cards use for session
|
||||||
|
/// activity, so "green is fine, red is broken" reads the same everywhere.
|
||||||
|
fn link_colour(state: LinkState) -> ratatui::style::Color {
|
||||||
|
let card = palette::active().card;
|
||||||
|
match state {
|
||||||
|
LinkState::Online => card.activity(ActivityState::Active),
|
||||||
|
LinkState::Connecting | LinkState::Reconnecting => {
|
||||||
|
card.activity(ActivityState::AwaitingInput)
|
||||||
|
}
|
||||||
|
LinkState::Down => card.activity(ActivityState::Error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What sits next to the dot. The browser shows the release on hover; a
|
||||||
|
/// terminal has no hover, so it is always on the line — replaced by the state
|
||||||
|
/// while the daemon is not answering, which is the more urgent fact.
|
||||||
|
fn link_label(link: &LinkStatus) -> String {
|
||||||
|
match link.state {
|
||||||
|
LinkState::Connecting => "connecting…".to_string(),
|
||||||
|
LinkState::Reconnecting => "reconnecting…".to_string(),
|
||||||
|
LinkState::Down => "daemon unreachable".to_string(),
|
||||||
|
LinkState::Online => match (&link.upgraded_to, &link.build) {
|
||||||
|
(Some(build), _) => format!("updated: {} (restart tui)", format_release(build)),
|
||||||
|
(None, Some(build)) => format_release(build),
|
||||||
|
(None, None) => "unknown release".to_string(),
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -246,6 +312,16 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::input::Region;
|
use crate::input::Region;
|
||||||
|
|
||||||
|
/// A link that answered — the normal case every other render test assumes.
|
||||||
|
fn online_link() -> LinkStatus {
|
||||||
|
LinkStatus {
|
||||||
|
state: LinkState::Online,
|
||||||
|
build: None,
|
||||||
|
attempts: 0,
|
||||||
|
upgraded_to: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn region_map_resolves_known_areas() {
|
fn region_map_resolves_known_areas() {
|
||||||
let map = RegionMap {
|
let map = RegionMap {
|
||||||
@@ -331,6 +407,96 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn frame_text(link: &LinkStatus) -> String {
|
||||||
|
use crate::overlays::HelpOverlay;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
let sidebar = Sidebar {
|
||||||
|
sessions: Vec::new(),
|
||||||
|
highlight: None,
|
||||||
|
attached: HashSet::new(),
|
||||||
|
};
|
||||||
|
let help = HelpOverlay { lines: Vec::new() };
|
||||||
|
let state = AppState::new();
|
||||||
|
let inputs = RenderInputs {
|
||||||
|
sidebar: &sidebar,
|
||||||
|
home_dir: "",
|
||||||
|
pane_title: "session",
|
||||||
|
pane: None,
|
||||||
|
draft: None,
|
||||||
|
state: &state,
|
||||||
|
help: &help,
|
||||||
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
|
link,
|
||||||
|
};
|
||||||
|
let backend = ratatui::backend::TestBackend::new(120, 24);
|
||||||
|
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||||
|
terminal
|
||||||
|
.draw(|f| {
|
||||||
|
draw(f, inputs);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let buf = terminal.backend().buffer().clone();
|
||||||
|
buf.content()
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.symbol())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_status_bar_names_the_release_the_daemon_is_running() {
|
||||||
|
let text = frame_text(&LinkStatus {
|
||||||
|
state: LinkState::Online,
|
||||||
|
build: Some(maestro_protocol::BuildInfo {
|
||||||
|
commit: "f89be4a716c7".into(),
|
||||||
|
commit_date: "2026-09-02T14:03:11Z".into(),
|
||||||
|
dirty: false,
|
||||||
|
}),
|
||||||
|
attempts: 0,
|
||||||
|
upgraded_to: None,
|
||||||
|
});
|
||||||
|
assert!(text.contains("f89be4a716c7"), "{text}");
|
||||||
|
assert!(text.contains("2026-09-02"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unreachable_daemon_covers_the_frame_with_the_restart_overlay() {
|
||||||
|
let up = frame_text(&LinkStatus {
|
||||||
|
state: LinkState::Online,
|
||||||
|
build: None,
|
||||||
|
attempts: 0,
|
||||||
|
upgraded_to: None,
|
||||||
|
});
|
||||||
|
assert!(!up.contains("daemon restarting"));
|
||||||
|
|
||||||
|
let down = frame_text(&LinkStatus {
|
||||||
|
state: LinkState::Down,
|
||||||
|
build: None,
|
||||||
|
attempts: 4,
|
||||||
|
upgraded_to: None,
|
||||||
|
});
|
||||||
|
assert!(down.contains("daemon restarting"), "{down}");
|
||||||
|
assert!(down.contains("(4 attempts)"), "{down}");
|
||||||
|
assert!(down.contains("daemon unreachable"), "{down}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_upgraded_daemon_tells_the_user_to_restart_the_tui() {
|
||||||
|
let text = frame_text(&LinkStatus {
|
||||||
|
state: LinkState::Online,
|
||||||
|
build: Some(maestro_protocol::BuildInfo::default()),
|
||||||
|
attempts: 0,
|
||||||
|
upgraded_to: Some(maestro_protocol::BuildInfo {
|
||||||
|
commit: "bbb111222333".into(),
|
||||||
|
commit_date: "2026-09-03T09:00:00Z".into(),
|
||||||
|
dirty: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert!(text.contains("updated: bbb111222333"), "{text}");
|
||||||
|
assert!(text.contains("restart tui"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
/// The spawn form is a modal: the frame shows it over whatever the pane
|
/// The spawn form is a modal: the frame shows it over whatever the pane
|
||||||
/// was displaying, framed by its own border.
|
/// was displaying, framed by its own border.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -356,6 +522,7 @@ mod tests {
|
|||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
rename_input: &crate::text_input::TextInput::default(),
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
|
link: &online_link(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
@@ -408,6 +575,7 @@ mod tests {
|
|||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
rename_input: &crate::text_input::TextInput::default(),
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
|
link: &online_link(),
|
||||||
};
|
};
|
||||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||||
@@ -447,6 +615,7 @@ mod tests {
|
|||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
rename_input: &crate::text_input::TextInput::default(),
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
|
link: &online_link(),
|
||||||
};
|
};
|
||||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Restart and upgrade contract
|
||||||
|
|
||||||
|
Maestro is upgraded in place: the operator reinstalls the binary and systemd
|
||||||
|
restarts the daemon under every browser tab and TUI that is attached to it.
|
||||||
|
tmux keeps the sessions alive across that (see `docs/INSTALL.md`), but clients
|
||||||
|
lose their sockets. This document is the contract for what a client does about
|
||||||
|
it — what the daemon publishes, how a client tells a restart from a blip, and
|
||||||
|
what the user sees while it happens.
|
||||||
|
|
||||||
|
## What the daemon publishes
|
||||||
|
|
||||||
|
`GET /api/info` carries two fields beyond identity and presets:
|
||||||
|
|
||||||
|
| field | meaning |
|
||||||
|
| ---------- | -------------------------------------------------------------- |
|
||||||
|
| `build` | `{ commit, commit_date, dirty }` — the commit the binary was compiled from |
|
||||||
|
| `instance` | uuid regenerated on every daemon start |
|
||||||
|
|
||||||
|
`build` is stamped at compile time by `crates/maestro-daemon/build.rs`, which
|
||||||
|
runs `git` in the checkout it is compiled in. The Nix build has no `.git` in the
|
||||||
|
crane-filtered source tree, so `flake.nix` passes the flake's own revision as
|
||||||
|
`MAESTRO_BUILD_COMMIT` / `MAESTRO_BUILD_DATE` / `MAESTRO_BUILD_DIRTY`. A build
|
||||||
|
with neither source reports empty strings — clients render that as
|
||||||
|
"unknown release" and fall back to `instance` alone.
|
||||||
|
|
||||||
|
`instance` is the one that never goes missing. It says *this process*, so two
|
||||||
|
replies that differ on it are two runs of the daemon, even when the commit did
|
||||||
|
not change (a plain `systemctl restart`, or a crash).
|
||||||
|
|
||||||
|
## How a client reads them
|
||||||
|
|
||||||
|
Three verdicts, from comparing the reply before an outage with the one after
|
||||||
|
(`compareIdentity` in `web/src/daemonLink.ts`, mirrored by
|
||||||
|
`crates/maestro-tui/src/daemon_link.rs`):
|
||||||
|
|
||||||
|
- **upgraded** — both commits are known and differ. New server code.
|
||||||
|
- **restarted** — commits match (or are unknown) and `instance` differs.
|
||||||
|
- **same** — neither changed; the socket blipped, the daemon never moved.
|
||||||
|
|
||||||
|
Only *upgraded* is special. A browser tab is holding a bundle that the upgraded
|
||||||
|
daemon no longer serves, so it reloads the page rather than driving new server
|
||||||
|
code from an old bundle. The TUI is its own binary — a reload would not replace
|
||||||
|
it — so it stays up, reconnects, and says which release it is now talking to;
|
||||||
|
the user restarts `maestro tui` when they want the new client.
|
||||||
|
|
||||||
|
## What the user sees
|
||||||
|
|
||||||
|
The **status indicator** lives in the sidebar footer, left of the spawn button,
|
||||||
|
in both clients. Its colour is the link state, and it names the running release
|
||||||
|
— commit hash and commit date — on hover (web) or in the status bar (TUI).
|
||||||
|
|
||||||
|
| state | colour | meaning |
|
||||||
|
| -------------- | ------ | ---------------------------------------------------- |
|
||||||
|
| `connecting` | amber | first probe hasn't landed |
|
||||||
|
| `online` | green | daemon answered; same process, or a resolved new one |
|
||||||
|
| `reconnecting` | amber | socket dropped, still inside the grace window |
|
||||||
|
| `down` | red | unreachable past the grace window — the UI blocks |
|
||||||
|
| `updating` | red | answered on a new commit; the page is reloading |
|
||||||
|
|
||||||
|
`updating` is web-only, because reloading is web-only. The TUI goes straight
|
||||||
|
back to `online` after an upgrade and puts `updated: <hash> (restart tui)` on
|
||||||
|
its status bar instead, until the user restarts it.
|
||||||
|
|
||||||
|
The **restart overlay** is up for exactly `down` and `updating`. It is not
|
||||||
|
dismissable and it swallows the app's keyboard shortcuts: everything behind it
|
||||||
|
would act on a daemon that is not there. In the TUI the quit binding still
|
||||||
|
resolves, so a blocked client is never a trapped one. It clears itself the
|
||||||
|
moment a probe succeeds — there is no "retry" button, because the client is
|
||||||
|
already retrying.
|
||||||
|
|
||||||
|
## Timing
|
||||||
|
|
||||||
|
- **Grace window** — 1200 ms between the socket dropping and the overlay
|
||||||
|
appearing. A wifi hiccup or a laptop waking up should not throw a modal.
|
||||||
|
- **Poll cadence** — `GET /api/info` every 700 ms while unreachable, flat, not
|
||||||
|
backed off: a restart takes a second or two and the point is to unlock as soon
|
||||||
|
as it is over. The events socket keeps its own exponential backoff for the
|
||||||
|
session stream; the two are independent, and the probe is what decides state.
|
||||||
|
|
||||||
|
The events socket dropping is the trigger, not a timer: the daemon cannot warn
|
||||||
|
anyone it is about to die, so its socket closing is the earliest signal there
|
||||||
|
is. A reconnect of that socket does **not** by itself clear the state — only a
|
||||||
|
probe does, because the socket carries no identity and reconnecting to an
|
||||||
|
upgraded daemon must reload rather than resume.
|
||||||
@@ -33,6 +33,25 @@
|
|||||||
|
|
||||||
version = (lib.importTOML ./Cargo.toml).workspace.package.version;
|
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
|
# Cargo source filtering keeps only *.rs, *.toml and Cargo.lock. The
|
||||||
# SPA is built separately (below) and handed to the Rust build as a
|
# 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.
|
# path, so nothing under web/ needs to be in this source tree.
|
||||||
@@ -85,6 +104,7 @@
|
|||||||
|
|
||||||
maestro = craneLib.buildPackage (
|
maestro = craneLib.buildPackage (
|
||||||
commonArgs
|
commonArgs
|
||||||
|
// buildStamp
|
||||||
// {
|
// {
|
||||||
inherit cargoArtifacts;
|
inherit cargoArtifacts;
|
||||||
pname = "maestro";
|
pname = "maestro";
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import {
|
|||||||
import type { TreeNode } from "./tree.js";
|
import type { TreeNode } from "./tree.js";
|
||||||
import { buildTree } from "./tree.js";
|
import { buildTree } from "./tree.js";
|
||||||
import { createThemeToggle } from "./theme.js";
|
import { createThemeToggle } from "./theme.js";
|
||||||
|
import type { DaemonLink } from "./daemonLink.js";
|
||||||
|
import { createStatusIndicator } from "./statusIndicator.js";
|
||||||
|
|
||||||
/** Ask the shell to open the spawn modal. Handled in main.ts, where the
|
/** Ask the shell to open the spawn modal. Handled in main.ts, where the
|
||||||
* multi-view controller lives; dispatched from every Spawn affordance. */
|
* multi-view controller lives; dispatched from every Spawn affordance. */
|
||||||
@@ -336,6 +338,9 @@ export interface SidebarOptions {
|
|||||||
/** Pointerdown on a session row — the multi-view controller decides drag vs
|
/** Pointerdown on a session row — the multi-view controller decides drag vs
|
||||||
* tap (issue #32). Tap falls through to the row's normal click (open). */
|
* tap (issue #32). Tap falls through to the row's normal click (open). */
|
||||||
onLeafDrag?: (sessionId: string, e: PointerEvent) => void;
|
onLeafDrag?: (sessionId: string, e: PointerEvent) => void;
|
||||||
|
/** Daemon link behind the footer's status dot. Omitted in tests and in the
|
||||||
|
* design kit, which render the sidebar without a daemon. */
|
||||||
|
link?: DaemonLink;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
|
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
|
||||||
@@ -390,6 +395,10 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
kit.title = "Open the design kit (DEV)";
|
kit.title = "Open the design kit (DEV)";
|
||||||
footer.appendChild(kit);
|
footer.appendChild(kit);
|
||||||
}
|
}
|
||||||
|
// Daemon status sits beside the spawn button: colour for the link state,
|
||||||
|
// hover for the release the daemon is running.
|
||||||
|
const status = opts.link ? createStatusIndicator(opts.link) : null;
|
||||||
|
if (status) footer.appendChild(status.el);
|
||||||
const spawnBtn = document.createElement("button");
|
const spawnBtn = document.createElement("button");
|
||||||
spawnBtn.type = "button";
|
spawnBtn.type = "button";
|
||||||
spawnBtn.className = "btn btn-primary spawn-btn";
|
spawnBtn.className = "btn btn-primary spawn-btn";
|
||||||
@@ -617,6 +626,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
},
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
clearInterval(clock);
|
clearInterval(clock);
|
||||||
|
status?.dispose();
|
||||||
// An unfinished rename would otherwise leave every later sidebar
|
// An unfinished rename would otherwise leave every later sidebar
|
||||||
// refusing to refresh.
|
// refusing to refresh.
|
||||||
renamingId = null;
|
renamingId = null;
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { DaemonLink, compareIdentity, formatRelease } from "./daemonLink.js";
|
||||||
|
import type { Scheduler } from "./events.js";
|
||||||
|
import type { DaemonInfo } from "./types.js";
|
||||||
|
|
||||||
|
function info(commit: string, instance: string, date = "2026-09-02T14:03:11+01:00"): DaemonInfo {
|
||||||
|
return {
|
||||||
|
hostname: "konishi",
|
||||||
|
version: "0.1.0",
|
||||||
|
presets: [],
|
||||||
|
build: { commit, commit_date: date, dirty: false },
|
||||||
|
instance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scheduler whose timers only fire when the test says so. */
|
||||||
|
function manual(): Scheduler & { run(): void; pending(): number } {
|
||||||
|
let next = 1;
|
||||||
|
const timers = new Map<number, () => void>();
|
||||||
|
return {
|
||||||
|
setTimer(fn) {
|
||||||
|
const h = next++;
|
||||||
|
timers.set(h, fn);
|
||||||
|
return h;
|
||||||
|
},
|
||||||
|
clearTimer(h) {
|
||||||
|
timers.delete(h);
|
||||||
|
},
|
||||||
|
run() {
|
||||||
|
const due = [...timers.entries()];
|
||||||
|
timers.clear();
|
||||||
|
for (const [, fn] of due) fn();
|
||||||
|
},
|
||||||
|
pending: () => timers.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("compareIdentity", () => {
|
||||||
|
it("reads a changed commit as an upgrade", () => {
|
||||||
|
expect(compareIdentity(info("aaa", "i1"), info("bbb", "i2"))).toBe("upgraded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a changed instance on the same commit as a plain restart", () => {
|
||||||
|
expect(compareIdentity(info("aaa", "i1"), info("aaa", "i2"))).toBe("restarted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads an unchanged process as no event at all", () => {
|
||||||
|
expect(compareIdentity(info("aaa", "i1"), info("aaa", "i1"))).toBe("same");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the instance when the build is unknown", () => {
|
||||||
|
// A daemon built outside a checkout reports empty commits; the instance is
|
||||||
|
// still enough to see the restart, and never claims a phantom upgrade.
|
||||||
|
expect(compareIdentity(info("", "i1"), info("", "i2"))).toBe("restarted");
|
||||||
|
expect(compareIdentity(info("", "i1"), info("", "i1"))).toBe("same");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatRelease", () => {
|
||||||
|
it("pairs the short hash with a readable date", () => {
|
||||||
|
expect(formatRelease({ commit: "f89be4a716c7", commit_date: "2026-09-02T14:03:11Z", dirty: false })).toBe(
|
||||||
|
"f89be4a716c7 · 2026-09-02",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a dirty build", () => {
|
||||||
|
expect(formatRelease({ commit: "f89be4a", commit_date: "", dirty: true })).toBe("f89be4a (dirty)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says so when the release is unknown", () => {
|
||||||
|
expect(formatRelease(undefined)).toBe("unknown release");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DaemonLink", () => {
|
||||||
|
it("starts online once the first probe lands", async () => {
|
||||||
|
const link = new DaemonLink({
|
||||||
|
fetchInfo: async () => info("aaa", "i1"),
|
||||||
|
scheduler: manual(),
|
||||||
|
reload: () => {},
|
||||||
|
});
|
||||||
|
await link.start();
|
||||||
|
expect(link.status().state).toBe("online");
|
||||||
|
expect(link.status().build?.commit).toBe("aaa");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds the modal back during the grace period, then blocks", async () => {
|
||||||
|
const sched = manual();
|
||||||
|
const link = new DaemonLink({
|
||||||
|
fetchInfo: async () => {
|
||||||
|
throw new Error("down");
|
||||||
|
},
|
||||||
|
scheduler: sched,
|
||||||
|
reload: () => {},
|
||||||
|
graceMs: 1000,
|
||||||
|
});
|
||||||
|
link.noteDisconnected();
|
||||||
|
// Still inside the grace window: the dot goes amber, nothing blocks.
|
||||||
|
expect(link.status().state).toBe("reconnecting");
|
||||||
|
sched.run();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(link.status().state).toBe("down");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unlocks without reloading when the same build comes back", async () => {
|
||||||
|
const sched = manual();
|
||||||
|
const reload = vi.fn();
|
||||||
|
let reply: DaemonInfo | null = info("aaa", "i1");
|
||||||
|
const link = new DaemonLink({
|
||||||
|
fetchInfo: async () => {
|
||||||
|
if (!reply) throw new Error("down");
|
||||||
|
return reply;
|
||||||
|
},
|
||||||
|
scheduler: sched,
|
||||||
|
reload,
|
||||||
|
});
|
||||||
|
await link.start();
|
||||||
|
|
||||||
|
reply = null;
|
||||||
|
link.noteDisconnected();
|
||||||
|
sched.run(); // grace expires → blocked, poll scheduled
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(link.status().state).toBe("down");
|
||||||
|
|
||||||
|
// Same commit, new process: a restart, not an upgrade — the page stays.
|
||||||
|
reply = info("aaa", "i2");
|
||||||
|
await link.probe();
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
expect(link.status().state).toBe("online");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reloads the page when the daemon comes back on a new commit", async () => {
|
||||||
|
const sched = manual();
|
||||||
|
const reload = vi.fn();
|
||||||
|
let reply = info("aaa", "i1");
|
||||||
|
const link = new DaemonLink({
|
||||||
|
fetchInfo: async () => reply,
|
||||||
|
scheduler: sched,
|
||||||
|
reload,
|
||||||
|
});
|
||||||
|
await link.start();
|
||||||
|
|
||||||
|
link.noteDisconnected();
|
||||||
|
reply = info("bbb", "i2");
|
||||||
|
await link.probe();
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
// Left blocked deliberately: the reload is what ends this page's life, and
|
||||||
|
// flashing an unlocked UI in between would let the user act on stale code.
|
||||||
|
expect(link.status().state).toBe("updating");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps polling on a scheduled cadence while the daemon is unreachable", async () => {
|
||||||
|
const sched = manual();
|
||||||
|
let attempts = 0;
|
||||||
|
const link = new DaemonLink({
|
||||||
|
fetchInfo: async () => {
|
||||||
|
attempts += 1;
|
||||||
|
throw new Error("down");
|
||||||
|
},
|
||||||
|
scheduler: sched,
|
||||||
|
reload: () => {},
|
||||||
|
});
|
||||||
|
link.noteDisconnected();
|
||||||
|
sched.run(); // grace → first poll
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
const first = attempts;
|
||||||
|
expect(first).toBeGreaterThan(0);
|
||||||
|
sched.run(); // next poll tick
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(attempts).toBeGreaterThan(first);
|
||||||
|
link.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies subscribers on every state change", async () => {
|
||||||
|
const sched = manual();
|
||||||
|
const seen: string[] = [];
|
||||||
|
const link = new DaemonLink({
|
||||||
|
fetchInfo: async () => info("aaa", "i1"),
|
||||||
|
scheduler: sched,
|
||||||
|
reload: () => {},
|
||||||
|
});
|
||||||
|
link.subscribe((s) => seen.push(s.state));
|
||||||
|
await link.start();
|
||||||
|
link.noteDisconnected();
|
||||||
|
sched.run();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(seen).toContain("reconnecting");
|
||||||
|
expect(seen[0]).toBe("connecting");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
// The link to the daemon behind this page: is it up, and which release is it?
|
||||||
|
// The sidebar's status indicator and the restart overlay both read this one
|
||||||
|
// controller. See docs/RESTART.md for the restart contract it implements.
|
||||||
|
|
||||||
|
import { getDaemonInfo } from "./api.js";
|
||||||
|
import type { Scheduler } from "./events.js";
|
||||||
|
import type { BuildInfo, DaemonInfo } from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - `connecting` — first probe hasn't landed yet.
|
||||||
|
* - `online` — the daemon answered and is the same process, or a resolved new one.
|
||||||
|
* - `reconnecting` — the socket dropped, but within the grace window; a blip
|
||||||
|
* shouldn't throw a modal in the user's face.
|
||||||
|
* - `down` — unreachable past the grace window: the overlay blocks the UI and
|
||||||
|
* the poll runs until it answers.
|
||||||
|
* - `updating` — it answered on a different commit; the page is reloading and
|
||||||
|
* stays blocked until it does.
|
||||||
|
*/
|
||||||
|
export type LinkState = "connecting" | "online" | "reconnecting" | "down" | "updating";
|
||||||
|
|
||||||
|
export interface LinkStatus {
|
||||||
|
state: LinkState;
|
||||||
|
/** The release the daemon last reported. Undefined until the first probe,
|
||||||
|
* and on a daemon too old to carry one. */
|
||||||
|
build?: BuildInfo;
|
||||||
|
/** Hostname the daemon reports, for the indicator's tooltip. */
|
||||||
|
hostname?: string;
|
||||||
|
/** How many probes have failed in the current outage — the overlay counts
|
||||||
|
* attempts so a long restart doesn't look frozen. */
|
||||||
|
attempts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What happened between two `/api/info` replies. */
|
||||||
|
export type RestartKind = "same" | "restarted" | "upgraded";
|
||||||
|
|
||||||
|
/** How long a dropped socket may stay dropped before the UI blocks. Long enough
|
||||||
|
* to ride out a wifi hiccup, short enough that a real restart is visible. */
|
||||||
|
export const GRACE_MS = 1200;
|
||||||
|
/** Cadence of the `/api/info` poll while the daemon is unreachable. */
|
||||||
|
export const POLL_MS = 700;
|
||||||
|
|
||||||
|
const browserScheduler: Scheduler = {
|
||||||
|
setTimer: (fn, ms) => window.setTimeout(fn, ms),
|
||||||
|
clearTimer: (h) => window.clearTimeout(h),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two daemon replies.
|
||||||
|
*
|
||||||
|
* The commit decides first: a different one means new code, which for a browser
|
||||||
|
* means new assets. The per-process `instance` catches the rest — a restart of
|
||||||
|
* the same build, which needs no reload. A daemon built outside a git checkout
|
||||||
|
* reports an empty commit, so an empty-vs-empty pair never counts as an
|
||||||
|
* upgrade; the instance still reports the restart.
|
||||||
|
*/
|
||||||
|
export function compareIdentity(before: DaemonInfo, after: DaemonInfo): RestartKind {
|
||||||
|
const a = before.build?.commit ?? "";
|
||||||
|
const b = after.build?.commit ?? "";
|
||||||
|
if (a && b && a !== b) return "upgraded";
|
||||||
|
if (before.instance && after.instance && before.instance !== after.instance) return "restarted";
|
||||||
|
return "same";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line naming a release: `f89be4a716c7 · 2026-09-02`, with `(dirty)` when
|
||||||
|
* the build carried uncommitted changes. Empty builds say so rather than
|
||||||
|
* rendering a blank tooltip. */
|
||||||
|
export function formatRelease(build: BuildInfo | undefined): string {
|
||||||
|
if (!build?.commit) return "unknown release";
|
||||||
|
const day = build.commit_date ? build.commit_date.slice(0, 10) : "";
|
||||||
|
const parts = [build.commit];
|
||||||
|
if (day) parts.push(`· ${day}`);
|
||||||
|
if (build.dirty) parts.push("(dirty)");
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DaemonLinkOptions {
|
||||||
|
/** Injectable for tests; defaults to `GET /api/info`. */
|
||||||
|
fetchInfo?: () => Promise<DaemonInfo>;
|
||||||
|
scheduler?: Scheduler;
|
||||||
|
/** What an upgrade does. Injectable so tests don't navigate. */
|
||||||
|
reload?: () => void;
|
||||||
|
graceMs?: number;
|
||||||
|
pollMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LinkListener = (status: LinkStatus) => void;
|
||||||
|
|
||||||
|
export class DaemonLink {
|
||||||
|
private state: LinkState = "connecting";
|
||||||
|
private identity: DaemonInfo | null = null;
|
||||||
|
private attempts = 0;
|
||||||
|
private graceTimer: number | null = null;
|
||||||
|
private pollTimer: number | null = null;
|
||||||
|
private readonly listeners = new Set<LinkListener>();
|
||||||
|
private readonly fetchInfo: () => Promise<DaemonInfo>;
|
||||||
|
private readonly scheduler: Scheduler;
|
||||||
|
private readonly reload: () => void;
|
||||||
|
private readonly graceMs: number;
|
||||||
|
private readonly pollMs: number;
|
||||||
|
|
||||||
|
constructor(opts: DaemonLinkOptions = {}) {
|
||||||
|
this.fetchInfo = opts.fetchInfo ?? getDaemonInfo;
|
||||||
|
this.scheduler = opts.scheduler ?? browserScheduler;
|
||||||
|
this.reload = opts.reload ?? (() => window.location.reload());
|
||||||
|
this.graceMs = opts.graceMs ?? GRACE_MS;
|
||||||
|
this.pollMs = opts.pollMs ?? POLL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record the release we started against. Everything later is a diff on it. */
|
||||||
|
async start(): Promise<void> {
|
||||||
|
await this.probe();
|
||||||
|
}
|
||||||
|
|
||||||
|
status(): LinkStatus {
|
||||||
|
return {
|
||||||
|
state: this.state,
|
||||||
|
build: this.identity?.build,
|
||||||
|
hostname: this.identity?.hostname,
|
||||||
|
attempts: this.attempts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(listener: LinkListener): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
listener(this.status());
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The events socket dropped. Starts the grace timer and the poll. */
|
||||||
|
noteDisconnected(): void {
|
||||||
|
if (this.state === "down" || this.state === "updating") return;
|
||||||
|
this.attempts = 0;
|
||||||
|
this.setState("reconnecting");
|
||||||
|
if (this.graceTimer === null) {
|
||||||
|
this.graceTimer = this.scheduler.setTimer(() => {
|
||||||
|
this.graceTimer = null;
|
||||||
|
if (this.state === "reconnecting") this.setState("down");
|
||||||
|
void this.poll();
|
||||||
|
}, this.graceMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The events socket came back. It carries no identity of its own, so the
|
||||||
|
* state only clears once a probe confirms *which* daemon answered — a
|
||||||
|
* reconnect to an upgraded daemon must reload, not resume. */
|
||||||
|
noteConnected(): void {
|
||||||
|
if (this.state === "online" || this.state === "updating") return;
|
||||||
|
void this.probe();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One `/api/info` round-trip. Resolves `true` when the daemon answered.
|
||||||
|
*
|
||||||
|
* A first answer becomes the baseline. A later one that differs on commit
|
||||||
|
* reloads the page; anything else (plain restart, or the same process) puts
|
||||||
|
* the UI back online.
|
||||||
|
*/
|
||||||
|
async probe(): Promise<boolean> {
|
||||||
|
let info: DaemonInfo;
|
||||||
|
try {
|
||||||
|
info = await this.fetchInfo();
|
||||||
|
} catch {
|
||||||
|
this.attempts += 1;
|
||||||
|
this.emit();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const before = this.identity;
|
||||||
|
this.identity = info;
|
||||||
|
if (before && compareIdentity(before, info) === "upgraded") {
|
||||||
|
// Stay blocked through the reload: an unlocked frame here would let the
|
||||||
|
// user drive new server code from the bundle it just replaced.
|
||||||
|
this.setState("updating");
|
||||||
|
this.reload();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
this.attempts = 0;
|
||||||
|
this.clearTimers();
|
||||||
|
this.setState("online");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop polling. The page's own teardown; not used in normal operation. */
|
||||||
|
stop(): void {
|
||||||
|
this.clearTimers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async poll(): Promise<void> {
|
||||||
|
if (this.state === "online" || this.state === "updating") return;
|
||||||
|
const ok = await this.probe();
|
||||||
|
if (ok || this.pollTimer !== null) return;
|
||||||
|
this.pollTimer = this.scheduler.setTimer(() => {
|
||||||
|
this.pollTimer = null;
|
||||||
|
void this.poll();
|
||||||
|
}, this.pollMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTimers(): void {
|
||||||
|
if (this.graceTimer !== null) {
|
||||||
|
this.scheduler.clearTimer(this.graceTimer);
|
||||||
|
this.graceTimer = null;
|
||||||
|
}
|
||||||
|
if (this.pollTimer !== null) {
|
||||||
|
this.scheduler.clearTimer(this.pollTimer);
|
||||||
|
this.pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setState(state: LinkState): void {
|
||||||
|
if (this.state === state) return;
|
||||||
|
this.state = state;
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(): void {
|
||||||
|
const s = this.status();
|
||||||
|
for (const fn of this.listeners) fn(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -160,3 +160,21 @@ describe("CatalogStore reconnect", () => {
|
|||||||
expect(store.backoff()).toBe(INITIAL_BACKOFF_MS);
|
expect(store.backoff()).toBe(INITIAL_BACKOFF_MS);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("connection listeners", () => {
|
||||||
|
it("report the socket opening and dropping", () => {
|
||||||
|
const sock = new FakeSocket();
|
||||||
|
const store = new CatalogStore({
|
||||||
|
socket: () => sock as unknown as WebSocket,
|
||||||
|
scheduler: fakeScheduler(),
|
||||||
|
});
|
||||||
|
const seen: boolean[] = [];
|
||||||
|
store.subscribeConnection((up) => seen.push(up));
|
||||||
|
store.start();
|
||||||
|
// Nothing on subscribe — the daemon link's own probe sets the first state.
|
||||||
|
expect(seen).toEqual([]);
|
||||||
|
sock.open();
|
||||||
|
sock.drop();
|
||||||
|
expect(seen).toEqual([true, false]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -33,9 +33,15 @@ export interface CatalogStoreOptions {
|
|||||||
refetch?: () => Promise<Session[]>;
|
refetch?: () => Promise<Session[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Called with `true` when the events socket opens, `false` when it drops.
|
||||||
|
* Drives the daemon link (daemonLink.ts) — the socket dying is the first sign
|
||||||
|
* the daemon went down or was upgraded under the page. */
|
||||||
|
export type ConnectionListener = (up: boolean) => void;
|
||||||
|
|
||||||
export class CatalogStore {
|
export class CatalogStore {
|
||||||
private state: CatalogState = { sessions: new Map() };
|
private state: CatalogState = { sessions: new Map() };
|
||||||
private listeners = new Set<CatalogListener>();
|
private listeners = new Set<CatalogListener>();
|
||||||
|
private connectionListeners = new Set<ConnectionListener>();
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private reconnectTimer: number | null = null;
|
private reconnectTimer: number | null = null;
|
||||||
private backoffMs = INITIAL_BACKOFF_MS;
|
private backoffMs = INITIAL_BACKOFF_MS;
|
||||||
@@ -70,6 +76,13 @@ export class CatalogStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Watch the socket's up/down transitions. Not called on subscribe: the
|
||||||
|
* caller's own probe decides the starting state. */
|
||||||
|
subscribeConnection(listener: ConnectionListener): () => void {
|
||||||
|
this.connectionListeners.add(listener);
|
||||||
|
return () => this.connectionListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
subscribe(listener: CatalogListener): () => void {
|
subscribe(listener: CatalogListener): () => void {
|
||||||
this.listeners.add(listener);
|
this.listeners.add(listener);
|
||||||
listener(this.snapshot());
|
listener(this.snapshot());
|
||||||
@@ -99,10 +112,12 @@ export class CatalogStore {
|
|||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
this.backoffMs = INITIAL_BACKOFF_MS;
|
this.backoffMs = INITIAL_BACKOFF_MS;
|
||||||
|
this.emitConnection(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
|
this.emitConnection(false);
|
||||||
this.scheduleReconnect();
|
this.scheduleReconnect();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -161,6 +176,10 @@ export class CatalogStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private emitConnection(up: boolean): void {
|
||||||
|
for (const fn of this.connectionListeners) fn(up);
|
||||||
|
}
|
||||||
|
|
||||||
private emit(): void {
|
private emit(): void {
|
||||||
const snap = this.snapshot();
|
const snap = this.snapshot();
|
||||||
for (const fn of this.listeners) fn(snap);
|
for (const fn of this.listeners) fn(snap);
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { ConnectionPool } from "./connections.js";
|
|||||||
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
|
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
|
||||||
import { installHudKeybind } from "./debug.js";
|
import { installHudKeybind } from "./debug.js";
|
||||||
import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js";
|
import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js";
|
||||||
|
import { DaemonLink } from "./daemonLink.js";
|
||||||
import { CatalogStore } from "./events.js";
|
import { CatalogStore } from "./events.js";
|
||||||
|
import { installRestartOverlay } from "./restartOverlay.js";
|
||||||
import { shouldRetryClose } from "./reconnect.js";
|
import { shouldRetryClose } from "./reconnect.js";
|
||||||
import {
|
import {
|
||||||
createSession,
|
createSession,
|
||||||
@@ -69,6 +71,15 @@ if (import.meta.env.DEV && location.search.includes("kit")) {
|
|||||||
async function bootApp(): Promise<void> {
|
async function bootApp(): Promise<void> {
|
||||||
|
|
||||||
const store = new CatalogStore();
|
const store = new CatalogStore();
|
||||||
|
|
||||||
|
// The daemon's identity and liveness: names the release in the sidebar's status
|
||||||
|
// dot, and blocks the UI behind the restart overlay while the daemon is away
|
||||||
|
// (docs/RESTART.md). The events socket is the trigger — it dies first.
|
||||||
|
const link = new DaemonLink();
|
||||||
|
installRestartOverlay(link);
|
||||||
|
store.subscribeConnection((up) => (up ? link.noteConnected() : link.noteDisconnected()));
|
||||||
|
void link.start();
|
||||||
|
|
||||||
store.start();
|
store.start();
|
||||||
|
|
||||||
// Dev-only: `?mock` seeds a representative catalog so the UI can be reviewed
|
// Dev-only: `?mock` seeds a representative catalog so the UI can be reviewed
|
||||||
@@ -89,6 +100,7 @@ shellEl.dataset.route = "catalog";
|
|||||||
let mv: MultiView;
|
let mv: MultiView;
|
||||||
const sidebar = renderSidebar(store, {
|
const sidebar = renderSidebar(store, {
|
||||||
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
|
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
|
||||||
|
link,
|
||||||
});
|
});
|
||||||
const content = document.createElement("section");
|
const content = document.createElement("section");
|
||||||
content.className = "content";
|
content.className = "content";
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Blocking overlay for a daemon that went away — see docs/RESTART.md.
|
||||||
|
//
|
||||||
|
// Mounted while the link is `down` or `updating`, torn down the moment it is
|
||||||
|
// back. It owns no polling of its own: DaemonLink polls, and this only renders
|
||||||
|
// what the link reports.
|
||||||
|
|
||||||
|
import { formatRelease, type DaemonLink, type LinkStatus } from "./daemonLink.js";
|
||||||
|
|
||||||
|
export interface RestartOverlayHandle {
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mount the overlay controller. It stays subscribed for the page's lifetime. */
|
||||||
|
export function installRestartOverlay(
|
||||||
|
link: DaemonLink,
|
||||||
|
host: HTMLElement = document.body,
|
||||||
|
): RestartOverlayHandle {
|
||||||
|
let el: HTMLElement | null = null;
|
||||||
|
let title: HTMLElement | null = null;
|
||||||
|
let detail: HTMLElement | null = null;
|
||||||
|
let release: HTMLElement | null = null;
|
||||||
|
|
||||||
|
const unsubscribe = link.subscribe((status) => render(status));
|
||||||
|
|
||||||
|
function render(status: LinkStatus): void {
|
||||||
|
const blocking = status.state === "down" || status.state === "updating";
|
||||||
|
if (!blocking) {
|
||||||
|
unmount();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!el) mount();
|
||||||
|
const updating = status.state === "updating";
|
||||||
|
title!.textContent = updating ? "Maestro was updated" : "Maestro is restarting";
|
||||||
|
detail!.textContent = updating
|
||||||
|
? "Reloading this page onto the new release…"
|
||||||
|
: attemptText(status.attempts);
|
||||||
|
release!.textContent = formatRelease(status.build);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The poll runs while this is up; saying how many tries have gone by is what
|
||||||
|
* separates "still restarting" from "wedged" for someone staring at it. */
|
||||||
|
function attemptText(attempts: number): string {
|
||||||
|
if (attempts <= 1) return "Waiting for the daemon to come back…";
|
||||||
|
return `Waiting for the daemon to come back… (${attempts} attempts)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount(): void {
|
||||||
|
el = document.createElement("div");
|
||||||
|
el.className = "modal-backdrop restart-backdrop";
|
||||||
|
const dialog = document.createElement("div");
|
||||||
|
dialog.className = "modal restart-modal";
|
||||||
|
dialog.setAttribute("role", "alertdialog");
|
||||||
|
dialog.setAttribute("aria-modal", "true");
|
||||||
|
dialog.setAttribute("aria-labelledby", "restart-title");
|
||||||
|
|
||||||
|
title = document.createElement("h2");
|
||||||
|
title.id = "restart-title";
|
||||||
|
detail = document.createElement("p");
|
||||||
|
detail.className = "restart-detail";
|
||||||
|
release = document.createElement("p");
|
||||||
|
release.className = "restart-release";
|
||||||
|
const spinner = document.createElement("div");
|
||||||
|
spinner.className = "restart-spinner";
|
||||||
|
spinner.setAttribute("aria-hidden", "true");
|
||||||
|
|
||||||
|
dialog.append(spinner, title, detail, release);
|
||||||
|
el.appendChild(dialog);
|
||||||
|
host.appendChild(el);
|
||||||
|
// The app's shortcuts live on `window`, so hiding the UI behind a backdrop
|
||||||
|
// does not stop them: `n` would still open a spawn form against a daemon
|
||||||
|
// that is not there. Swallow keys in the capture phase until the link is
|
||||||
|
// back, without `preventDefault` so the browser's own chords still work.
|
||||||
|
window.addEventListener("keydown", swallow, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function swallow(e: KeyboardEvent): void {
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function unmount(): void {
|
||||||
|
if (!el) return;
|
||||||
|
window.removeEventListener("keydown", swallow, true);
|
||||||
|
el.remove();
|
||||||
|
el = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dispose(): void {
|
||||||
|
unsubscribe();
|
||||||
|
unmount();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { DaemonLink } from "./daemonLink.js";
|
||||||
|
import { installRestartOverlay } from "./restartOverlay.js";
|
||||||
|
import { createStatusIndicator } from "./statusIndicator.js";
|
||||||
|
import type { DaemonInfo } from "./types.js";
|
||||||
|
import type { Scheduler } from "./events.js";
|
||||||
|
|
||||||
|
function info(commit: string, instance: string): DaemonInfo {
|
||||||
|
return {
|
||||||
|
hostname: "konishi",
|
||||||
|
version: "0.1.0",
|
||||||
|
presets: [],
|
||||||
|
build: { commit, commit_date: "2026-09-02T14:03:11Z", dirty: false },
|
||||||
|
instance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function manual(): Scheduler & { run(): void } {
|
||||||
|
let next = 1;
|
||||||
|
const timers = new Map<number, () => void>();
|
||||||
|
return {
|
||||||
|
setTimer(fn) {
|
||||||
|
const h = next++;
|
||||||
|
timers.set(h, fn);
|
||||||
|
return h;
|
||||||
|
},
|
||||||
|
clearTimer(h) {
|
||||||
|
timers.delete(h);
|
||||||
|
},
|
||||||
|
run() {
|
||||||
|
const due = [...timers.values()];
|
||||||
|
timers.clear();
|
||||||
|
for (const fn of due) fn();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("status indicator", () => {
|
||||||
|
it("shows the release on the tooltip once the daemon answers", async () => {
|
||||||
|
const link = new DaemonLink({
|
||||||
|
fetchInfo: async () => info("f89be4a716c7", "i1"),
|
||||||
|
scheduler: manual(),
|
||||||
|
reload: () => {},
|
||||||
|
});
|
||||||
|
const ind = createStatusIndicator(link);
|
||||||
|
await link.start();
|
||||||
|
|
||||||
|
expect(ind.el.dataset.state).toBe("online");
|
||||||
|
const tip = ind.el.querySelector(".status-tip")!.textContent ?? "";
|
||||||
|
expect(tip).toContain("f89be4a716c7");
|
||||||
|
expect(tip).toContain("2026-09-02");
|
||||||
|
expect(tip).toContain("konishi");
|
||||||
|
expect(ind.el.getAttribute("aria-label")).toContain("Connected");
|
||||||
|
ind.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks the link through a drop", async () => {
|
||||||
|
const sched = manual();
|
||||||
|
const link = new DaemonLink({
|
||||||
|
fetchInfo: async () => {
|
||||||
|
throw new Error("down");
|
||||||
|
},
|
||||||
|
scheduler: sched,
|
||||||
|
reload: () => {},
|
||||||
|
});
|
||||||
|
const ind = createStatusIndicator(link);
|
||||||
|
expect(ind.el.dataset.state).toBe("connecting");
|
||||||
|
|
||||||
|
link.noteDisconnected();
|
||||||
|
expect(ind.el.dataset.state).toBe("reconnecting");
|
||||||
|
sched.run();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(ind.el.dataset.state).toBe("down");
|
||||||
|
ind.dispose();
|
||||||
|
link.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("restart overlay", () => {
|
||||||
|
it("blocks once the daemon is unreachable and unlocks when it answers", async () => {
|
||||||
|
const sched = manual();
|
||||||
|
let reply: DaemonInfo | null = info("aaa", "i1");
|
||||||
|
const link = new DaemonLink({
|
||||||
|
fetchInfo: async () => {
|
||||||
|
if (!reply) throw new Error("down");
|
||||||
|
return reply;
|
||||||
|
},
|
||||||
|
scheduler: sched,
|
||||||
|
reload: () => {},
|
||||||
|
});
|
||||||
|
const overlay = installRestartOverlay(link);
|
||||||
|
await link.start();
|
||||||
|
expect(document.querySelector(".restart-backdrop")).toBeNull();
|
||||||
|
|
||||||
|
reply = null;
|
||||||
|
link.noteDisconnected();
|
||||||
|
expect(document.querySelector(".restart-backdrop")).toBeNull(); // grace
|
||||||
|
sched.run();
|
||||||
|
await Promise.resolve();
|
||||||
|
const dialog = document.querySelector(".restart-modal");
|
||||||
|
expect(dialog?.textContent).toContain("Maestro is restarting");
|
||||||
|
|
||||||
|
// Restarted on the same build: no reload, and the overlay goes away.
|
||||||
|
reply = info("aaa", "i2");
|
||||||
|
await link.probe();
|
||||||
|
expect(document.querySelector(".restart-backdrop")).toBeNull();
|
||||||
|
overlay.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows the app's shortcuts while it is up", async () => {
|
||||||
|
const sched = manual();
|
||||||
|
const link = new DaemonLink({
|
||||||
|
fetchInfo: async () => {
|
||||||
|
throw new Error("down");
|
||||||
|
},
|
||||||
|
scheduler: sched,
|
||||||
|
reload: () => {},
|
||||||
|
});
|
||||||
|
const overlay = installRestartOverlay(link);
|
||||||
|
let sawKey = false;
|
||||||
|
window.addEventListener("keydown", () => {
|
||||||
|
sawKey = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
link.noteDisconnected();
|
||||||
|
sched.run();
|
||||||
|
await Promise.resolve();
|
||||||
|
window.dispatchEvent(new KeyboardEvent("keydown", { key: "n", bubbles: true }));
|
||||||
|
expect(sawKey).toBe(false);
|
||||||
|
|
||||||
|
overlay.dispose();
|
||||||
|
link.stop();
|
||||||
|
window.dispatchEvent(new KeyboardEvent("keydown", { key: "n", bubbles: true }));
|
||||||
|
expect(sawKey).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says the page is reloading after an upgrade", async () => {
|
||||||
|
const sched = manual();
|
||||||
|
let reply = info("aaa", "i1");
|
||||||
|
const link = new DaemonLink({
|
||||||
|
fetchInfo: async () => reply,
|
||||||
|
scheduler: sched,
|
||||||
|
reload: () => {},
|
||||||
|
});
|
||||||
|
const overlay = installRestartOverlay(link);
|
||||||
|
await link.start();
|
||||||
|
link.noteDisconnected();
|
||||||
|
reply = info("bbb", "i2");
|
||||||
|
await link.probe();
|
||||||
|
const dialog = document.querySelector(".restart-modal");
|
||||||
|
expect(dialog?.textContent).toContain("Maestro was updated");
|
||||||
|
expect(dialog?.textContent).toContain("bbb");
|
||||||
|
overlay.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// The sidebar footer's daemon-status dot, sat next to the spawn button.
|
||||||
|
//
|
||||||
|
// Colour is the link state; hovering (or focusing — it is a button, so the
|
||||||
|
// keyboard reaches it) reveals the release the daemon is running: commit hash
|
||||||
|
// and commit date.
|
||||||
|
|
||||||
|
import { formatRelease, type DaemonLink, type LinkState, type LinkStatus } from "./daemonLink.js";
|
||||||
|
|
||||||
|
/** One human-readable phrase per link state, used for both the tooltip's first
|
||||||
|
* line and the button's accessible name. */
|
||||||
|
const LABELS: Record<LinkState, string> = {
|
||||||
|
connecting: "Connecting…",
|
||||||
|
online: "Connected",
|
||||||
|
reconnecting: "Reconnecting…",
|
||||||
|
down: "Daemon unreachable",
|
||||||
|
updating: "Updated — reloading",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface StatusIndicatorHandle {
|
||||||
|
el: HTMLElement;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStatusIndicator(link: DaemonLink): StatusIndicatorHandle {
|
||||||
|
const el = document.createElement("button");
|
||||||
|
el.type = "button";
|
||||||
|
el.className = "btn btn-ghost btn-icon status-chip";
|
||||||
|
el.setAttribute("aria-describedby", "status-tip");
|
||||||
|
|
||||||
|
const dot = document.createElement("span");
|
||||||
|
dot.className = "status-dot";
|
||||||
|
dot.setAttribute("aria-hidden", "true");
|
||||||
|
el.appendChild(dot);
|
||||||
|
|
||||||
|
const tip = document.createElement("span");
|
||||||
|
tip.className = "status-tip";
|
||||||
|
tip.id = "status-tip";
|
||||||
|
tip.setAttribute("role", "tooltip");
|
||||||
|
el.appendChild(tip);
|
||||||
|
|
||||||
|
const unsubscribe = link.subscribe((status) => render(status));
|
||||||
|
|
||||||
|
function render(status: LinkStatus): void {
|
||||||
|
const label = LABELS[status.state];
|
||||||
|
const release = formatRelease(status.build);
|
||||||
|
el.dataset.state = status.state;
|
||||||
|
el.setAttribute("aria-label", `${label} — ${release}`);
|
||||||
|
tip.textContent = "";
|
||||||
|
tip.append(line("status-tip-state", status.hostname ? `${label} · ${status.hostname}` : label));
|
||||||
|
tip.append(line("status-tip-release", release));
|
||||||
|
}
|
||||||
|
|
||||||
|
function line(cls: string, text: string): HTMLElement {
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.className = cls;
|
||||||
|
span.textContent = text;
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
el,
|
||||||
|
dispose(): void {
|
||||||
|
unsubscribe();
|
||||||
|
el.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -435,6 +435,134 @@ textarea {
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- daemon status dot ---------- */
|
||||||
|
|
||||||
|
/* Colour is the link state; the tooltip names the release the daemon is
|
||||||
|
running. The chip is a button so the keyboard reaches the tooltip too. */
|
||||||
|
.status-chip {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--muted);
|
||||||
|
box-shadow: 0 0 0 3px transparent;
|
||||||
|
transition:
|
||||||
|
background var(--dur) var(--ease-out),
|
||||||
|
box-shadow var(--dur) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip[data-state="online"] .status-dot {
|
||||||
|
background: var(--live);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip[data-state="connecting"] .status-dot,
|
||||||
|
.status-chip[data-state="reconnecting"] .status-dot {
|
||||||
|
background: var(--warn);
|
||||||
|
animation: pulse 1.1s var(--ease-out) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip[data-state="down"] .status-dot,
|
||||||
|
.status-chip[data-state="updating"] .status-dot {
|
||||||
|
background: var(--danger);
|
||||||
|
animation: pulse 1.1s var(--ease-out) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + var(--sp-2));
|
||||||
|
left: 0;
|
||||||
|
z-index: var(--z-sticky);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 180px;
|
||||||
|
padding: var(--sp-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-2);
|
||||||
|
background: var(--surface-2);
|
||||||
|
box-shadow: var(--shadow-2);
|
||||||
|
font-size: var(--fs-11);
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition:
|
||||||
|
opacity var(--dur-fast) var(--ease-out),
|
||||||
|
visibility var(--dur-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip:hover .status-tip,
|
||||||
|
.status-chip:focus-visible .status-tip {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tip-state {
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tip-release {
|
||||||
|
color: var(--muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- restart overlay ---------- */
|
||||||
|
|
||||||
|
/* Up while the daemon is unreachable or being replaced. Nothing to dismiss:
|
||||||
|
it clears itself when the link is back (docs/RESTART.md). */
|
||||||
|
.restart-modal {
|
||||||
|
max-width: 380px;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-5) var(--sp-4);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-modal h2 {
|
||||||
|
font-size: var(--fs-15);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-detail {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-release {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--faint);
|
||||||
|
font-size: var(--fs-11);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-spinner {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--border-strong);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
animation: spin 900ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.restart-spinner,
|
||||||
|
.status-dot {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-search .search {
|
.sidebar-search .search {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
|
|||||||
@@ -86,6 +86,24 @@ export interface DaemonInfo {
|
|||||||
home_dir?: string;
|
home_dir?: string;
|
||||||
/** Command presets (claude/opencode/zsh + custom), config-resolved. */
|
/** Command presets (claude/opencode/zsh + custom), config-resolved. */
|
||||||
presets: SpawnPreset[];
|
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
|
/** One deduplicated recent working dir for the spawn form's recents picker
|
||||||
|
|||||||
Reference in New Issue
Block a user