1use std::collections::BTreeMap;
23use std::path::Path;
24use std::sync::Arc;
25
26use serde::{Deserialize, Serialize};
27use serde_json::{json, Value};
28
29pub const INSTALL_SUBTYPE: &str = "agentmux_cli_install";
30pub const VERSION_CHANGED_SUBTYPE: &str = "agentmux_cli_version_changed";
31
32pub const RECORD_FILE: &str = "agent-cli-versions.json";
35
36pub fn record_dir(paths: &agentmux_common::DataPaths) -> &Path {
44 &paths.instance_dir
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum InstallState {
50 Installing,
51 Installed,
52 Failed,
53}
54
55impl InstallState {
56 fn as_str(self) -> &'static str {
57 match self {
58 Self::Installing => "installing",
59 Self::Installed => "installed",
60 Self::Failed => "failed",
61 }
62 }
63}
64
65pub fn version_token(raw: &str) -> Option<String> {
69 raw.split(|c: char| c.is_whitespace() || c == ',' || c == '(' || c == ')')
70 .map(|t| t.trim_start_matches('v'))
71 .find(|t| t.contains('.') && t.chars().next().is_some_and(|c| c.is_ascii_digit()))
72 .map(str::to_string)
73}
74
75pub fn install_frame(
78 install_id: &str,
79 provider: &str,
80 version: &str,
81 state: InstallState,
82 seconds: Option<f64>,
83 error: Option<&str>,
84) -> Value {
85 let mut frame = json!({
86 "type": "system",
87 "subtype": INSTALL_SUBTYPE,
88 "install_id": install_id,
89 "provider": provider,
90 "version": version,
91 "state": state.as_str(),
92 "timestamp": chrono::Utc::now().to_rfc3339(),
93 });
94 if let Some(s) = seconds {
95 frame["seconds"] = json!((s * 10.0).round() / 10.0);
96 }
97 if let Some(e) = error {
98 frame["error"] = json!(e);
99 }
100 frame
101}
102
103pub fn version_changed_frame(provider: &str, from: &str, to: &str, pinned: Option<&str>) -> Value {
107 json!({
108 "type": "system",
109 "subtype": VERSION_CHANGED_SUBTYPE,
110 "provider": provider,
111 "from": from,
112 "to": to,
113 "pinned": pinned,
114 "timestamp": chrono::Utc::now().to_rfc3339(),
115 })
116}
117
118pub fn append_to_pane(
121 broker: &Arc<crate::backend::mps::Broker>,
122 filestore: &Arc<crate::backend::storage::filestore::FileStore>,
123 mstore: &Arc<crate::backend::storage::store::Store>,
124 block_id: &str,
125 frame: &Value,
126) {
127 let line = format!("{frame}\n");
128 let zone = crate::backend::blockcontroller::shell::resolve_global_output_zone(&Some(mstore.clone()), block_id);
129 crate::backend::blockcontroller::shell::handle_append_block_file(
130 broker,
131 block_id,
132 crate::backend::blockcontroller::persistent::PERSISTENT_OUTPUT_SUBJECT,
133 line.as_bytes(),
134 Some(filestore),
135 zone.as_deref(),
136 );
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
140struct LastCli {
141 provider: String,
142 version: String,
143 at: i64,
144}
145
146static RECORD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
148
149pub fn observe_version(dir: &Path, agent_key: &str, provider: &str, version: &str) -> Option<String> {
155 let _guard = RECORD_LOCK.lock().unwrap_or_else(|p| p.into_inner());
156 let path = dir.join(RECORD_FILE);
157 let mut records: BTreeMap<String, LastCli> = std::fs::read(&path)
158 .ok()
159 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
160 .unwrap_or_default();
161 let previous = records.get(agent_key).cloned();
162 if previous.as_ref().is_some_and(|p| p.provider == provider && p.version == version) {
163 return None;
164 }
165 records.insert(
166 agent_key.to_string(),
167 LastCli { provider: provider.to_string(), version: version.to_string(), at: agentmux_common::time::now_ms() },
168 );
169 let tmp = path.with_extension("json.tmp");
170 let written = serde_json::to_vec_pretty(&records)
171 .map_err(|e| e.to_string())
172 .and_then(|bytes| std::fs::write(&tmp, bytes).map_err(|e| e.to_string()))
173 .and_then(|()| std::fs::rename(&tmp, &path).map_err(|e| e.to_string()));
174 if let Err(e) = written {
175 tracing::warn!(path = %path.display(), error = %e, "cli notice: could not save the last-run CLI version");
176 }
177 previous.filter(|p| p.provider == provider).map(|p| p.version)
178}
179
180pub fn agent_key_of_block(mstore: &crate::backend::storage::store::Store, block_id: &str) -> Option<String> {
184 let block = mstore.get::<crate::backend::obj::Block>(block_id).ok().flatten()?;
185 let id = crate::backend::obj::meta_get_string(&block.meta, "agentId", "");
186 (!id.trim().is_empty()).then_some(id)
187}
188
189pub fn observe_and_notify(
195 broker: &Arc<crate::backend::mps::Broker>,
196 filestore: &Arc<crate::backend::storage::filestore::FileStore>,
197 mstore: &Arc<crate::backend::storage::store::Store>,
198 block_id: &str,
199 agent_id: Option<&str>,
200 provider: &str,
201 version: &str,
202) {
203 let Some(agent_key) = agent_id.map(str::to_string).or_else(|| agent_key_of_block(mstore, block_id)) else {
204 return;
205 };
206 let Some(paths) = agentmux_common::DataPaths::from_env() else { return };
207 if let Some(from) = observe_version(record_dir(&paths), &agent_key, provider, version) {
208 let pinned = crate::backend::providers::get_provider(provider).map(|p| p.pinned_version);
209 tracing::info!(block_id, provider, from = %from, to = version, "cli notice: agent's CLI version changed");
210 append_to_pane(broker, filestore, mstore, block_id, &version_changed_frame(provider, &from, version, pinned));
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 #[test]
219 fn version_token_finds_the_number() {
220 assert_eq!(version_token("2.1.285 (Claude Code)").as_deref(), Some("2.1.285"));
221 assert_eq!(version_token("codex-cli 0.154.0").as_deref(), Some("0.154.0"));
222 assert_eq!(version_token("v0.60.0").as_deref(), Some("0.60.0"));
223 assert_eq!(version_token("unknown"), None);
224 assert_eq!(version_token(""), None);
225 }
226
227 #[test]
228 fn first_run_records_without_a_notice_then_a_change_returns_the_old_version() {
229 let dir = tempfile::tempdir().unwrap();
230 assert_eq!(observe_version(dir.path(), "uid-1", "claude", "2.1.218"), None);
231 assert_eq!(observe_version(dir.path(), "uid-1", "claude", "2.1.218"), None, "same version");
232 assert_eq!(observe_version(dir.path(), "uid-1", "claude", "2.1.285").as_deref(), Some("2.1.218"));
233 assert_eq!(observe_version(dir.path(), "uid-1", "claude", "2.1.285"), None, "recorded the new one");
234 }
235
236 #[test]
237 fn agents_are_independent_and_a_provider_switch_is_not_an_upgrade() {
238 let dir = tempfile::tempdir().unwrap();
239 observe_version(dir.path(), "uid-1", "claude", "2.1.218");
240 assert_eq!(observe_version(dir.path(), "uid-2", "claude", "2.1.285"), None, "other agent's first run");
241 assert_eq!(observe_version(dir.path(), "uid-1", "codex", "0.154.0"), None, "switched provider");
242 assert_eq!(observe_version(dir.path(), "uid-1", "codex", "0.155.0").as_deref(), Some("0.154.0"));
243 }
244
245 #[test]
246 fn an_unreadable_record_is_treated_as_empty() {
247 let dir = tempfile::tempdir().unwrap();
248 std::fs::write(dir.path().join(RECORD_FILE), b"not json").unwrap();
249 assert_eq!(observe_version(dir.path(), "uid-1", "claude", "2.1.285"), None);
250 assert_eq!(observe_version(dir.path(), "uid-1", "claude", "2.1.286").as_deref(), Some("2.1.285"));
251 }
252
253 #[test]
254 fn frames_carry_what_the_pane_shows() {
255 let f = install_frame("claude-2.1.285-1", "claude", "2.1.285", InstallState::Installed, Some(14.04), None);
256 assert_eq!(f["subtype"], INSTALL_SUBTYPE);
257 assert_eq!(f["state"], "installed");
258 assert_eq!(f["seconds"], 14.0);
259 assert!(f.get("error").is_none());
260 let failed = install_frame("i", "claude", "2.1.285", InstallState::Failed, None, Some("exit 1"));
261 assert_eq!(failed["error"], "exit 1");
262
263 let v = version_changed_frame("claude", "2.1.218", "2.1.285", Some("2.1.285"));
264 assert_eq!(v["subtype"], VERSION_CHANGED_SUBTYPE);
265 assert_eq!((v["from"].as_str(), v["to"].as_str(), v["pinned"].as_str()), (Some("2.1.218"), Some("2.1.285"), Some("2.1.285")));
266 }
267}