Fleet: per-node dev-tool version cards + nightly latest-check (Phase 1, read-only)
ci / gates (push) Successful in 5s
ci / rust (push) Failing after 7s
ci / frontend (push) Successful in 23s
ci / e2e (push) Has been skipped

Each node card now shows installed versions of Docker / Claude Code / Kimi / GLM /
Ollama (conditional per node) under the ssh card, with an "update available" badge.

- daemon: probe_tools() finds docker/claude/kimi-cli/ollama across candidate bin dirs,
  extracts semver from --version, reports {"t":"node_tools",...} on connect + every 15m.
- migration node_tools + tool_latest; cm-db repo node_tools (upsert/list/latest).
- cm-api: fleet.rs NodeTools uplink → upsert; tool_versions.rs spawn_latest_checker
  (24h, npm/pypi/github; docker display-only); GET /api/nodes/{id}/tools (glm mirrors
  claude). Spawned in clawmates-server.
- frontend: NodeTools cards on each HostCard with the ↑latest badge.

Phase 2 (one-click update execution) intentionally deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-27 06:01:51 -07:00
co-authored by Claude Opus 4.8
parent 69c64ad676
commit 9263418fcb
10 changed files with 322 additions and 0 deletions
+88
View File
@@ -122,6 +122,18 @@ async fn run(ws_url: &str) -> Result<(), Box<dyn std::error::Error>> {
} }
}); });
// Probe installed dev-tool versions (docker/claude/kimi-cli/ollama) on a
// dedicated thread — the version commands shell out (blocking) — on connect,
// then every 15 min. The server flags updates against upstream.
let tools_tx = out_tx.clone();
std::thread::spawn(move || loop {
let frame = json!({ "t": "node_tools", "tools": probe_tools() }).to_string();
if tools_tx.send(frame).is_err() {
break;
}
std::thread::sleep(Duration::from_secs(900));
});
// Liveness: the server pings every 15s. If nothing inbound arrives for 40s // Liveness: the server pings every 15s. If nothing inbound arrives for 40s
// the socket is dead — return so main() reconnects. // the socket is dead — return so main() reconnects.
let mut idle_tick = tokio::time::interval(Duration::from_secs(5)); let mut idle_tick = tokio::time::interval(Duration::from_secs(5));
@@ -192,6 +204,82 @@ fn heartbeat(sys: &mut System) -> String {
.to_string() .to_string()
} }
/// Probe installed dev-tool versions: for each tool, find its binary across the
/// usual bin dirs and read `--version`. Returns `{ tool: "x.y.z", … }` for the
/// ones found. Probes `kimi-cli` (the real uv tool), not the `kimi` API wrapper.
fn probe_tools() -> Value {
let home = std::env::var("HOME").unwrap_or_default();
let dirs = [
format!("{home}/.local/bin"),
"/opt/homebrew/bin".to_string(),
"/usr/local/bin".to_string(),
"/usr/bin".to_string(),
"/bin".to_string(),
format!("{home}/.local/share/uv/tools/kimi-cli/bin"),
];
let mut out = serde_json::Map::new();
for tool in ["docker", "claude", "kimi-cli", "ollama"] {
for d in &dirs {
let p = std::path::Path::new(d).join(tool);
if p.exists() {
if let Some(v) = tool_version(&p) {
out.insert(tool.to_string(), Value::String(v));
}
break;
}
}
}
Value::Object(out)
}
/// Run `<bin> --version` with a 2s cap (off-thread, so a hung binary can't wedge
/// the prober) and extract the first semver from its output.
fn tool_version(bin: &std::path::Path) -> Option<String> {
let bin = bin.to_path_buf();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let text = std::process::Command::new(&bin)
.arg("--version")
.output()
.ok()
.map(|o| {
let mut s = String::from_utf8_lossy(&o.stdout).into_owned();
s.push_str(&String::from_utf8_lossy(&o.stderr));
s
});
let _ = tx.send(text);
});
let text = rx.recv_timeout(Duration::from_secs(2)).ok().flatten()?;
extract_semver(&text)
}
/// First `\d+\.\d+(\.\d+)?` run in `s` (e.g. "29.1.3" from "Docker version
/// 29.1.3, build …"; stops before a trailing `-0ubuntu…`).
fn extract_semver(s: &str) -> Option<String> {
let c: Vec<char> = s.chars().collect();
let mut i = 0;
while i < c.len() {
if c[i].is_ascii_digit() {
let start = i;
while i < c.len() && (c[i].is_ascii_digit() || c[i] == '.') {
i += 1;
}
let mut end = i;
while end > start && c[end - 1] == '.' {
end -= 1;
}
let cand: String = c[start..end].iter().collect();
let parts: Vec<&str> = cand.split('.').collect();
if parts.len() >= 2 && parts.iter().all(|p| !p.is_empty()) {
return Some(cand);
}
} else {
i += 1;
}
}
None
}
/// Total + available bytes of the filesystem backing `/` (largest disk as a /// Total + available bytes of the filesystem backing `/` (largest disk as a
/// fallback). /// fallback).
fn root_disk() -> (i64, i64) { fn root_disk() -> (i64, i64) {
+2
View File
@@ -284,6 +284,8 @@ async fn run() -> Result<(), String> {
cm_api::beszel::spawn_poller(pool.clone(), std::time::Duration::from_secs(15)); cm_api::beszel::spawn_poller(pool.clone(), std::time::Duration::from_secs(15));
// Fleet automation: evaluate metric-threshold rules → drain/undrain/alert. // Fleet automation: evaluate metric-threshold rules → drain/undrain/alert.
cm_api::node_rules::spawn_evaluator(pool.clone(), std::time::Duration::from_secs(20)); cm_api::node_rules::spawn_evaluator(pool.clone(), std::time::Duration::from_secs(20));
// Nightly: check upstream for newer dev-tool releases (claude/kimi/ollama).
cm_api::tool_versions::spawn_latest_checker(pool.clone(), std::time::Duration::from_secs(86_400));
// Hosted identity (Clerk / OIDC): pin the issuer and load its JWKS. // Hosted identity (Clerk / OIDC): pin the issuer and load its JWKS.
let auth_verifier = match config.auth.mode { let auth_verifier = match config.auth.mode {
+8
View File
@@ -326,6 +326,10 @@ enum Uplink {
}, },
#[serde(rename = "webrtc_failed")] #[serde(rename = "webrtc_failed")]
WebRtcFailed { sid: u64 }, WebRtcFailed { sid: u64 },
#[serde(rename = "node_tools")]
NodeTools {
tools: std::collections::HashMap<String, String>,
},
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -466,6 +470,10 @@ pub async fn run_channel(pool: PgPool, hub: Arc<NodeHub>, node_id: NodeId, socke
let _ = s.send(json!({ "type": "webrtc_failed" }).to_string()); let _ = s.send(json!({ "type": "webrtc_failed" }).to_string());
} }
} }
Ok(Uplink::NodeTools { tools }) => {
let pairs: Vec<(String, String)> = tools.into_iter().collect();
let _ = cm_db::repo::node_tools::upsert(&pool, node_id, &pairs).await;
}
Err(_) => {} Err(_) => {}
} }
}, },
+2
View File
@@ -8,6 +8,7 @@ pub mod fleet;
mod mcp_door; mod mcp_door;
pub mod node_rules; pub mod node_rules;
pub mod quota; pub mod quota;
pub mod tool_versions;
mod recursive_exec; mod recursive_exec;
mod routes; mod routes;
mod runtime_provision; mod runtime_provision;
@@ -138,6 +139,7 @@ pub fn router(state: AppState) -> Router {
"/api/nodes/{id}/metrics", "/api/nodes/{id}/metrics",
get(routes::beszel::node_metrics_get), get(routes::beszel::node_metrics_get),
) )
.route("/api/nodes/{id}/tools", get(routes::nodes::tools))
.route("/api/nodes/{id}", delete(routes::nodes::remove)) .route("/api/nodes/{id}", delete(routes::nodes::remove))
.route( .route(
"/api/fleet/beszel", "/api/fleet/beszel",
+45
View File
@@ -174,6 +174,51 @@ pub async fn agent_ws(
upgrade.on_upgrade(move |socket| run_channel(pool, hub, node_id, socket)) upgrade.on_upgrade(move |socket| run_channel(pool, hub, node_id, socket))
} }
/// `GET /api/nodes/{id}/tools` — installed dev-tool versions for a node, the
/// latest upstream version, and an update flag (Phase 1: read-only). `glm` is
/// derived from `claude` (it runs Claude Code with a z.ai config).
pub async fn tools(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<Value>, ApiError> {
let node_id = NodeId::from(id);
nodes::get(&state.pool, node_id, user.workspace_id)
.await?
.ok_or(ApiError::NotFound)?;
let installed: std::collections::HashMap<String, String> =
cm_db::repo::node_tools::list(&state.pool, node_id)
.await
.unwrap_or_default()
.into_iter()
.collect();
let latest = cm_db::repo::node_tools::all_latest(&state.pool)
.await
.unwrap_or_default();
// (probe key, display name, ui key) in display order; `glm` mirrors `claude`.
let order = [
("docker", "Docker", "docker"),
("claude", "Claude Code", "claude"),
("kimi-cli", "Kimi", "kimi"),
("glm", "GLM (Claude Code)", "glm"),
("ollama", "Ollama", "ollama"),
];
let mut out: Vec<Value> = Vec::new();
for (probe, name, key) in order {
let src = if probe == "glm" { "claude" } else { probe };
if let Some(inst) = installed.get(src) {
let lat = latest.get(src).cloned();
let update = lat.as_ref().map(|l| l != inst).unwrap_or(false);
out.push(json!({
"name": name, "key": key, "installed": inst,
"latest": lat, "updateAvailable": update,
}));
}
}
Ok(Json(json!({ "tools": out })))
}
/// `POST /api/nodes/{id}/terminal/ticket` — mint a single-use terminal ticket /// `POST /api/nodes/{id}/terminal/ticket` — mint a single-use terminal ticket
/// (the browser WS handshake can't carry a bearer header). /// (the browser WS handshake can't carry a bearer header).
pub async fn terminal_ticket( pub async fn terminal_ticket(
+70
View File
@@ -0,0 +1,70 @@
//! Nightly upstream-version checker for the fleet dev-tools. Each tool's latest
//! release is global (same everywhere), so we fetch once per tick and store it in
//! `tool_latest`; per-node "update available" is then `installed != latest`.
//! Docker is display-only (OS-package managed) — no upstream check.
use std::time::Duration;
use cm_db::repo::node_tools;
use serde_json::Value;
use sqlx::PgPool;
/// Fetch the latest version of each checkable tool and upsert `tool_latest`.
/// Stored under the same keys the daemon reports (`claude`, `kimi-cli`, `ollama`).
async fn check(pool: &PgPool, client: &reqwest::Client) {
// Claude Code — the npm package's latest dist-tag.
if let Some(v) = fetch_field(
client,
"https://registry.npmjs.org/@anthropic-ai/claude-code/latest",
&["version"],
)
.await
{
let _ = node_tools::set_latest(pool, "claude", &v).await;
}
// Kimi CLI — PyPI (it's a uv/python tool).
if let Some(v) = fetch_field(client, "https://pypi.org/pypi/kimi-cli/json", &["info", "version"]).await {
let _ = node_tools::set_latest(pool, "kimi-cli", &v).await;
}
// Ollama — latest GitHub release tag (strip leading "v").
if let Some(v) = fetch_field(
client,
"https://api.github.com/repos/ollama/ollama/releases/latest",
&["tag_name"],
)
.await
{
let _ = node_tools::set_latest(pool, "ollama", v.trim_start_matches('v')).await;
}
}
/// GET `url` as JSON and read a (possibly nested) string field by `path`.
async fn fetch_field(client: &reqwest::Client, url: &str, path: &[&str]) -> Option<String> {
let resp = client
.get(url)
.header("User-Agent", "clawmates")
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let mut v: Value = resp.json().await.ok()?;
for key in path {
v = v.get(key)?.clone();
}
v.as_str().map(|s| s.to_owned())
}
/// Spawn the nightly latest-version checker (runs once on boot, then every
/// `interval`).
pub fn spawn_latest_checker(pool: PgPool, interval: Duration) {
tokio::spawn(async move {
let client = reqwest::Client::new();
let mut tick = tokio::time::interval(interval);
loop {
tick.tick().await;
check(&pool, &client).await;
}
});
}
+1
View File
@@ -11,6 +11,7 @@ pub mod fleet_tailscale;
pub mod messages; pub mod messages;
pub mod node_metrics; pub mod node_metrics;
pub mod node_rules; pub mod node_rules;
pub mod node_tools;
pub mod nodes; pub mod nodes;
pub mod orgs; pub mod orgs;
pub mod outbox; pub mod outbox;
+64
View File
@@ -0,0 +1,64 @@
//! Per-node installed dev-tool versions (probed by the daemon) and the latest
//! upstream version per tool (filled by a nightly checker). Drives the fleet
//! UI's "update available" badges.
use std::collections::HashMap;
use cm_domain::NodeId;
use sqlx::{PgPool, Row};
use crate::DbError;
/// Replace a node's reported tool versions with `tools` (tool, version).
pub async fn upsert(pool: &PgPool, node_id: NodeId, tools: &[(String, String)]) -> Result<(), DbError> {
for (tool, version) in tools {
sqlx::query(
"INSERT INTO node_tools (node_id, tool, version, updated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (node_id, tool) DO UPDATE SET version = excluded.version, updated_at = now()",
)
.bind(node_id.as_uuid())
.bind(tool)
.bind(version)
.execute(pool)
.await?;
}
Ok(())
}
/// A node's installed tool versions as `(tool, version)`.
pub async fn list(pool: &PgPool, node_id: NodeId) -> Result<Vec<(String, String)>, DbError> {
let rows = sqlx::query("SELECT tool, version FROM node_tools WHERE node_id = $1")
.bind(node_id.as_uuid())
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| (r.get::<String, _>("tool"), r.get::<String, _>("version")))
.collect())
}
/// Record the latest upstream version for a tool.
pub async fn set_latest(pool: &PgPool, tool: &str, latest: &str) -> Result<(), DbError> {
sqlx::query(
"INSERT INTO tool_latest (tool, latest_version, checked_at)
VALUES ($1, $2, now())
ON CONFLICT (tool) DO UPDATE SET latest_version = excluded.latest_version, checked_at = now()",
)
.bind(tool)
.bind(latest)
.execute(pool)
.await?;
Ok(())
}
/// All known latest versions, keyed by tool.
pub async fn all_latest(pool: &PgPool) -> Result<HashMap<String, String>, DbError> {
let rows = sqlx::query("SELECT tool, latest_version FROM tool_latest")
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| (r.get::<String, _>("tool"), r.get::<String, _>("latest_version")))
.collect())
}
@@ -122,6 +122,30 @@ function Mini({ label, value }: { label: string; value: string }) {
); );
} }
interface NodeTool { name: string; key: string; installed: string; latest: string | null; updateAvailable: boolean }
/** Installed dev-tool versions for a node (Docker / Claude Code / Kimi / GLM /
* Ollama), with an "update available" badge when the nightly check finds a newer
* release. Read-only (Phase 1); the one-click update lands next to the badge later. */
function NodeTools({ nodeId, online }: { nodeId: string; online: boolean }) {
const { data } = useFetchJson<{ tools: NodeTool[] }>(online ? `/api/nodes/${nodeId}/tools` : null);
const tools = data?.tools ?? [];
if (!online || tools.length === 0) return null;
return (
<div style={{ marginTop: 8, display: "flex", flexDirection: "column", gap: 5 }}>
{tools.map((t) => (
<div key={t.key} style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 10px", borderRadius: 8, background: "#070708", border: `1px solid ${t.updateAvailable ? "rgba(232,196,106,.3)" : "rgba(255,255,255,.06)"}` }}>
<span style={{ fontFamily: mono, fontSize: 10.5, color: "#9a9aa2", flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{t.name}</span>
<span style={{ fontFamily: mono, fontSize: 10.5, color: "#cfcfd5" }}>{t.installed}</span>
{t.updateAvailable ? (
<span title={`update available → ${t.latest}`} style={{ fontFamily: mono, fontSize: 9, color: "#e8b465", background: "rgba(232,196,106,.12)", border: "1px solid rgba(232,196,106,.3)", borderRadius: 5, padding: "1px 6px" }}>↑ {t.latest}</span>
) : null}
</div>
))}
</div>
);
}
function HostCard({ node, onRemoved, onMonitor }: { node: FleetNode; onRemoved: () => void; onMonitor: (id: string, name: string) => void }) { function HostCard({ node, onRemoved, onMonitor }: { node: FleetNode; onRemoved: () => void; onMonitor: (id: string, name: string) => void }) {
const [, setParams] = useQueryStates(panelParsers, { shallow: true }); const [, setParams] = useQueryStates(panelParsers, { shallow: true });
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -196,6 +220,8 @@ function HostCard({ node, onRemoved, onMonitor }: { node: FleetNode; onRemoved:
<span style={{ fontFamily: mono, fontSize: 10, color: "#e8b465" }}>waiting for daemon to dial home…</span> <span style={{ fontFamily: mono, fontSize: 10, color: "#e8b465" }}>waiting for daemon to dial home…</span>
</div> </div>
) : null} ) : null}
<NodeTools nodeId={node.id} online={st === "online"} />
</div> </div>
); );
} }
+16
View File
@@ -0,0 +1,16 @@
-- Per-node installed dev-tool versions (probed by the daemon) + the latest
-- upstream version per tool (a nightly checker), so the fleet UI can flag updates.
CREATE TABLE IF NOT EXISTS node_tools (
node_id UUID NOT NULL REFERENCES nodes (id) ON DELETE CASCADE,
tool TEXT NOT NULL,
version TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (node_id, tool)
);
CREATE TABLE IF NOT EXISTS tool_latest (
tool TEXT PRIMARY KEY,
latest_version TEXT NOT NULL,
checked_at TIMESTAMPTZ NOT NULL DEFAULT now()
);