1use std::path::Path;
32use std::sync::Arc;
33
34use serde_json::{Map, Value};
35
36#[cfg(not(test))]
37use crate::backend::config_watcher_fs::resolve_settings_dir;
38use crate::backend::storage::store::Store;
39use crate::backend::wconfig;
40
41use super::{Migration, MigrationContext, MigrationError, MigrationScope, VerifyOutcome};
42
43pub struct M0037RetireOldPaneNames;
44
45impl Migration for M0037RetireOldPaneNames {
46 fn id(&self) -> &'static str { "0037_retire_old_pane_names" }
47 fn scope(&self) -> MigrationScope { MigrationScope::Channel }
48 fn description(&self) -> &'static str {
49 "Move saved Armory, Knowledge and Remotes panes, pins, colors and keybindings to Connectors and Memory"
50 }
51
52 fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
53 if ctx.channel_store_path.exists() {
54 let store = Arc::new(
55 Store::open(&ctx.channel_store_path)
56 .map_err(|e| MigrationError(format!("retire_old_pane_names: open store: {e}")))?,
57 );
58 let blocks = migrate_blocks(&store).map_err(MigrationError)?;
59 let snapshots = migrate_client_snapshots(&store).map_err(MigrationError)?;
60 if blocks + snapshots > 0 {
61 tracing::info!(blocks, snapshots, "retire_old_pane_names: moved saved panes to Connectors and Memory");
62 }
63 }
64 if let Some(settings_dir) = settings_dir() {
65 migrate_settings_file(&settings_dir.join(wconfig::SETTINGS_FILE));
66 migrate_widgets_file(&settings_dir.join(crate::backend::user_widgets::USER_WIDGETS_FILE));
67 }
68 Ok(())
69 }
70
71 fn verify(&self, ctx: &MigrationContext) -> VerifyOutcome {
72 if !ctx.channel_store_path.exists() {
73 return VerifyOutcome::Ok("no channel store".into());
74 }
75 let store = match Store::open(&ctx.channel_store_path) {
76 Ok(s) => s,
77 Err(e) => return VerifyOutcome::Error(format!("open store: {e}")),
78 };
79 match old_view_block_count(&store) {
80 Ok(0) => VerifyOutcome::Ok("no block saved under a retired pane name".into()),
81 Ok(n) => VerifyOutcome::Mismatch(format!("{n} block(s) still saved under a retired pane name")),
82 Err(e) => VerifyOutcome::Error(e),
83 }
84 }
85}
86
87#[cfg(not(test))]
91fn settings_dir() -> Option<std::path::PathBuf> {
92 Some(resolve_settings_dir())
93}
94#[cfg(test)]
95fn settings_dir() -> Option<std::path::PathBuf> {
96 None
97}
98
99const CONNECTORS: &str = "connectors";
100const MEMORY: &str = "memory";
101const CONNECTORS_SECTION: &str = "connectors:section";
102const MEMORY_SECTION: &str = "memory:section";
103const RETIRED_VIEWS: &[&str] = &["armory", "trust", "knowledge", "remotes"];
104
105fn migrate_meta(meta: &mut Map<String, Value>) -> bool {
107 let view = match meta.get("view").and_then(Value::as_str) {
108 Some(v) if RETIRED_VIEWS.contains(&v) => v.to_string(),
109 _ => return false,
110 };
111 match view.as_str() {
112 "armory" | "trust" => {
113 let section = meta.get("armory:section").and_then(Value::as_str).map(str::to_string);
115 let sub = meta.get("armory:memory:subsection").and_then(Value::as_str).map(str::to_string);
116 let (target_view, key, target_section) = match section.as_deref() {
117 Some("mcp") => (CONNECTORS, CONNECTORS_SECTION, "mcp"),
118 Some("memory") => (MEMORY, MEMORY_SECTION, if sub.as_deref() == Some("personal") { "personal" } else { "global" }),
119 Some("native_memory") => (MEMORY, MEMORY_SECTION, "personal"),
120 Some("skills") => (MEMORY, MEMORY_SECTION, "skills"),
121 Some("bundles") => (MEMORY, MEMORY_SECTION, "bundles"),
122 _ => (CONNECTORS, CONNECTORS_SECTION, "accounts"),
123 };
124 meta.insert("view".into(), target_view.into());
125 meta.insert(key.into(), target_section.into());
126 meta.remove("armory:section");
127 meta.remove("armory:memory:subsection");
128 }
129 "knowledge" => {
130 meta.insert("view".into(), MEMORY.into());
131 if let Some(section) = meta.remove("knowledge:section") {
132 meta.entry(MEMORY_SECTION.to_string()).or_insert(section);
133 }
134 }
135 "remotes" => {
136 meta.insert("view".into(), CONNECTORS.into());
139 meta.insert(CONNECTORS_SECTION.into(), "remotes".into());
140 }
141 _ => unreachable!(),
142 }
143 true
144}
145
146fn migrate_tree(value: &mut Value) -> usize {
148 match value {
149 Value::Object(map) => {
150 let mut n = usize::from(migrate_meta(map));
151 for child in map.values_mut() {
152 n += migrate_tree(child);
153 }
154 n
155 }
156 Value::Array(items) => items.iter_mut().map(migrate_tree).sum(),
157 _ => 0,
158 }
159}
160
161fn read_rows(store: &Store, table: &str) -> Result<Vec<(String, Vec<u8>)>, String> {
162 let conn = store.conn().lock().unwrap();
163 let mut stmt = conn
164 .prepare(&format!("SELECT oid, data FROM {table}"))
165 .map_err(|e| format!("retire_old_pane_names: read {table}: {e}"))?;
166 let rows = stmt
167 .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?)))
168 .map_err(|e| format!("retire_old_pane_names: read {table}: {e}"))?
169 .filter_map(Result::ok)
170 .collect();
171 Ok(rows)
172}
173
174fn migrate_blocks(store: &Store) -> Result<usize, String> {
175 let mut changed = 0;
176 for (oid, data) in read_rows(store, "db_block")? {
177 let Ok(mut block) = serde_json::from_slice::<Value>(&data) else { continue };
178 let moved = match block.get_mut("meta") {
179 Some(Value::Object(meta)) => migrate_meta(meta),
180 _ => false,
181 };
182 if moved {
183 store
184 .update_raw("block", &oid, &block)
185 .map_err(|e| format!("retire_old_pane_names: update block {oid}: {e}"))?;
186 changed += 1;
187 }
188 }
189 Ok(changed)
190}
191
192const SNAPSHOT_META_KEY: &str = "session:last_topology";
193
194fn migrate_client_snapshots(store: &Store) -> Result<usize, String> {
195 let mut changed = 0;
196 for (oid, data) in read_rows(store, "db_client")? {
197 let Ok(mut client) = serde_json::from_slice::<Value>(&data) else { continue };
198 let moved = client
199 .get_mut("meta")
200 .and_then(|m| m.get_mut(SNAPSHOT_META_KEY))
201 .map(migrate_tree)
202 .unwrap_or(0);
203 if moved > 0 {
204 store
205 .update_raw("client", &oid, &client)
206 .map_err(|e| format!("retire_old_pane_names: update client {oid}: {e}"))?;
207 changed += moved;
208 }
209 }
210 Ok(changed)
211}
212
213fn old_view_block_count(store: &Store) -> Result<usize, String> {
214 Ok(read_rows(store, "db_block")?
215 .into_iter()
216 .filter(|(_, data)| {
217 serde_json::from_slice::<Value>(data)
218 .ok()
219 .and_then(|b| b.get("meta")?.get("view")?.as_str().map(|v| RETIRED_VIEWS.contains(&v)))
220 .unwrap_or(false)
221 })
222 .count())
223}
224
225fn current_names(short: &str) -> Vec<&str> {
229 match short {
230 "armory" => vec![CONNECTORS, MEMORY],
231 "knowledge" => vec![MEMORY],
232 "remotes" => vec![CONNECTORS],
233 other => vec![other],
234 }
235}
236
237fn dedup(items: Vec<String>) -> Vec<String> {
238 let mut seen = std::collections::HashSet::new();
239 items.into_iter().filter(|s| seen.insert(s.clone())).collect()
240}
241
242fn migrate_widget_list(value: &Value, full_keys: bool) -> Option<Value> {
244 let items = value.as_array()?;
245 let mut out = Vec::new();
246 let mut changed = false;
247 for item in items {
248 let Some(s) = item.as_str() else {
249 out.push(item.clone());
250 continue;
251 };
252 let (prefix, short) = match (full_keys, s.strip_prefix("defwidget@")) {
253 (true, Some(short)) => ("defwidget@", short),
254 (true, None) => ("", s),
255 (false, _) => ("", s),
256 };
257 let names = if !full_keys || !prefix.is_empty() { current_names(short) } else { vec![short] };
258 if names != [short] {
259 changed = true;
260 }
261 out.extend(names.into_iter().map(|n| Value::String(format!("{prefix}{n}"))));
262 }
263 if !changed {
264 return None;
265 }
266 let strings: Vec<String> = out.iter().filter_map(|v| v.as_str().map(str::to_string)).collect();
267 Some(Value::Array(dedup(strings).into_iter().map(Value::String).collect()))
268}
269
270fn migrate_colors(value: &Value) -> Option<Value> {
274 let colors = value.as_object()?;
275 let renames = [("knowledge", MEMORY), ("remotes", CONNECTORS), ("trust", CONNECTORS), ("armory", CONNECTORS)];
276 if !renames.iter().any(|(old, _)| colors.contains_key(*old)) {
277 return None;
278 }
279 let mut out = colors.clone();
280 for (old, new) in renames {
281 if let Some(color) = out.remove(old) {
282 out.entry(new.to_string()).or_insert(color);
283 }
284 }
285 Some(Value::Object(out))
286}
287
288fn migrate_command(command: &str) -> Option<String> {
289 let (unbind, id) = match command.strip_prefix('-') {
290 Some(id) => ("-", id),
291 None => ("", command),
292 };
293 let new = match id {
294 "app:knowledge" => "app:memory",
295 "app:identity" => "app:connectors",
296 _ => return None,
297 };
298 Some(format!("{unbind}{new}"))
299}
300
301fn migrate_when(when: &str) -> Option<String> {
302 let mut out = when.to_string();
303 for (old, new) in [("knowledge", MEMORY), ("remotes", CONNECTORS), ("armory", CONNECTORS), ("trust", CONNECTORS)] {
304 for op in ["==", "!="] {
305 out = out.replace(&format!("viewType {op} {old}"), &format!("viewType {op} {new}"));
306 }
307 }
308 (out != when).then_some(out)
309}
310
311fn migrate_keybindings(value: &Value) -> Option<Value> {
313 let entries = value.as_array()?;
314 let mut out = entries.clone();
315 let mut changed = false;
316 for entry in out.iter_mut() {
317 let Some(obj) = entry.as_object_mut() else { continue };
318 if let Some(new) = obj.get("command").and_then(Value::as_str).and_then(migrate_command) {
319 obj.insert("command".into(), new.into());
320 changed = true;
321 }
322 if let Some(new) = obj.get("when").and_then(Value::as_str).and_then(migrate_when) {
323 obj.insert("when".into(), new.into());
324 changed = true;
325 }
326 }
327 changed.then_some(Value::Array(out))
328}
329
330fn migrated_settings(settings: &Map<String, Value>) -> Map<String, Value> {
332 let mut changed = Map::new();
333 if let Some(v) = settings.get("widget:pinned").and_then(|v| migrate_widget_list(v, false)) {
334 changed.insert("widget:pinned".into(), v);
335 }
336 if let Some(v) = settings.get("widget:hidden").and_then(|v| migrate_widget_list(v, true)) {
337 changed.insert("widget:hidden".into(), v);
338 }
339 if let Some(v) = settings.get("pane:colors").and_then(migrate_colors) {
340 changed.insert("pane:colors".into(), v);
341 }
342 if let Some(v) = settings.get("keybindings").and_then(migrate_keybindings) {
343 changed.insert("keybindings".into(), v);
344 }
345 changed
346}
347
348fn migrate_settings_file(path: &Path) {
349 if !path.exists() {
350 return;
351 }
352 let mut settings = wconfig::read_settings_raw_jsonc(path);
353 let changed = migrated_settings(&settings);
354 if changed.is_empty() {
355 return;
356 }
357 let keys: Vec<String> = changed.keys().cloned().collect();
358 settings.extend(changed);
359 let merged = wconfig::merge_into_template(wconfig::SETTINGS_TEMPLATE, &settings);
360 match std::fs::write(path, merged) {
361 Ok(()) => tracing::info!(?keys, "retire_old_pane_names: settings.json moved to Connectors and Memory"),
362 Err(e) => tracing::warn!(error = %e, "retire_old_pane_names: couldn't write settings.json; old names left"),
363 }
364}
365
366fn migrated_widgets(widgets: &Map<String, Value>) -> Option<Map<String, Value>> {
371 let mut out = widgets.clone();
372 let mut changed = false;
373 for (old, new) in [("defwidget@knowledge", "defwidget@memory"), ("defwidget@remotes", "defwidget@connectors")] {
374 if let Some(entry) = out.remove(old) {
375 changed = true;
376 out.entry(new.to_string()).or_insert(entry);
377 }
378 }
379 for entry in out.values_mut() {
380 if let Some(Value::Object(meta)) = entry.get_mut("blockdef").and_then(|b| b.get_mut("meta")) {
381 changed |= migrate_meta(meta);
382 }
383 }
384 changed.then_some(out)
385}
386
387fn migrate_widgets_file(path: &Path) {
388 let Ok(content) = std::fs::read_to_string(path) else { return };
389 if content.trim().is_empty() {
390 return;
391 }
392 let widgets: Map<String, Value> = match serde_json::from_str(&content) {
393 Ok(w) => w,
394 Err(e) => {
395 tracing::warn!(error = %e, "retire_old_pane_names: widgets.json doesn't parse; left alone");
396 return;
397 }
398 };
399 let Some(next) = migrated_widgets(&widgets) else { return };
400 let text = match serde_json::to_string_pretty(&next) {
401 Ok(t) => t + "\n",
402 Err(_) => return,
403 };
404 match std::fs::write(path, text) {
405 Ok(()) => tracing::info!("retire_old_pane_names: widgets.json moved to Connectors and Memory"),
406 Err(e) => tracing::warn!(error = %e, "retire_old_pane_names: couldn't write widgets.json; old names left"),
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use serde_json::json;
414
415 fn meta(v: Value) -> Map<String, Value> {
416 v.as_object().unwrap().clone()
417 }
418
419 #[test]
420 fn armory_and_trust_go_where_their_section_was() {
421 let mut m = meta(json!({"view": "armory", "armory:section": "skills", "term:zoom": 1.2}));
422 assert!(migrate_meta(&mut m));
423 assert_eq!(Value::Object(m), json!({"view": "memory", "memory:section": "skills", "term:zoom": 1.2}));
424
425 let mut m = meta(json!({"view": "armory", "armory:section": "memory", "armory:memory:subsection": "personal"}));
426 migrate_meta(&mut m);
427 assert_eq!(Value::Object(m), json!({"view": "memory", "memory:section": "personal"}));
428
429 let mut m = meta(json!({"view": "trust"}));
430 migrate_meta(&mut m);
431 assert_eq!(Value::Object(m), json!({"view": "connectors", "connectors:section": "accounts"}));
432
433 let mut m = meta(json!({"view": "armory", "armory:section": "mcp"}));
434 migrate_meta(&mut m);
435 assert_eq!(Value::Object(m), json!({"view": "connectors", "connectors:section": "mcp"}));
436 }
437
438 #[test]
439 fn knowledge_and_remotes_move_and_others_are_untouched() {
440 let mut m = meta(json!({"view": "knowledge", "knowledge:section": "bundles"}));
441 assert!(migrate_meta(&mut m));
442 assert_eq!(Value::Object(m), json!({"view": "memory", "memory:section": "bundles"}));
443
444 let mut m = meta(json!({"view": "remotes", "remotes:expand": "box"}));
445 migrate_meta(&mut m);
446 assert_eq!(Value::Object(m), json!({"view": "connectors", "connectors:section": "remotes", "remotes:expand": "box"}));
447
448 let mut m = meta(json!({"view": "term", "armory:section": "skills"}));
449 assert!(!migrate_meta(&mut m));
450 }
451
452 #[test]
453 fn a_snapshot_tree_is_walked() {
454 let mut snap = json!({"windows": [{"tabs": [{"blocks": [{"meta": {"view": "knowledge"}}, {"meta": {"view": "agent"}}]}]}]});
455 assert_eq!(migrate_tree(&mut snap), 1);
456 assert_eq!(snap["windows"][0]["tabs"][0]["blocks"][0]["meta"]["view"], "memory");
457 }
458
459 #[test]
460 fn settings_keys_move_and_untouched_settings_change_nothing() {
461 let settings = meta(json!({
462 "widget:pinned": ["agent", "armory", "memory", "remotes"],
463 "widget:hidden": ["defwidget@knowledge", "ext@acme.board/main"],
464 "pane:colors": {"knowledge": 120, "memory": 200, "trust": 30},
465 "keybindings": [
466 {"key": "ctrl+k", "command": "app:knowledge"},
467 {"command": "-app:identity"},
468 {"key": "ctrl+e", "command": "split:right", "when": "viewType == knowledge && !textInputFocus"}
469 ],
470 "term:fontsize": 14
471 }));
472 let changed = migrated_settings(&settings);
473 assert_eq!(changed["widget:pinned"], json!(["agent", "connectors", "memory"]));
474 assert_eq!(changed["widget:hidden"], json!(["defwidget@memory", "ext@acme.board/main"]));
475 assert_eq!(changed["pane:colors"], json!({"memory": 200, "connectors": 30}));
476 assert_eq!(changed["keybindings"][0]["command"], "app:memory");
477 assert_eq!(changed["keybindings"][1]["command"], "-app:connectors");
478 assert_eq!(changed["keybindings"][2]["when"], "viewType == memory && !textInputFocus");
479 assert!(!changed.contains_key("term:fontsize"));
480
481 let clean = meta(json!({"widget:pinned": ["agent"], "pane:colors": {"memory": 1}, "keybindings": []}));
482 assert!(migrated_settings(&clean).is_empty());
483 }
484
485 #[test]
486 fn widgets_json_entries_move_and_a_new_key_wins() {
487 let widgets = meta(json!({
488 "defwidget@remotes": {"label": "My remotes", "blockdef": {"meta": {"view": "remotes"}}},
489 "defwidget@knowledge": {"label": "Old"},
490 "defwidget@memory": {"label": "Mine"},
491 "defwidget@x": {"blockdef": {"meta": {"view": "armory", "armory:section": "bundles"}}}
492 }));
493 let next = migrated_widgets(&widgets).unwrap();
494 assert_eq!(next["defwidget@connectors"]["blockdef"]["meta"], json!({"view": "connectors", "connectors:section": "remotes"}));
495 assert_eq!(next["defwidget@memory"]["label"], "Mine");
496 assert_eq!(next["defwidget@x"]["blockdef"]["meta"], json!({"view": "memory", "memory:section": "bundles"}));
497 assert!(!next.contains_key("defwidget@remotes") && !next.contains_key("defwidget@knowledge"));
498 assert!(migrated_widgets(&meta(json!({"defwidget@agent": {"label": "A"}}))).is_none());
499 }
500
501 #[test]
502 fn files_are_rewritten_only_when_they_change() {
503 let dir = tempfile::tempdir().unwrap();
504 let settings = dir.path().join("settings.json");
505 std::fs::write(&settings, r#"{ "widget:pinned": ["knowledge"], "term:fontsize": 14 }"#).unwrap();
506 migrate_settings_file(&settings);
507 let after = wconfig::read_settings_raw_jsonc(&settings);
508 assert_eq!(after["widget:pinned"], json!(["memory"]));
509 assert_eq!(after["term:fontsize"], json!(14));
510
511 let widgets = dir.path().join("widgets.json");
512 std::fs::write(&widgets, "{\"defwidget@agent\": {\"label\": \"A\"}}").unwrap();
513 migrate_widgets_file(&widgets);
514 assert_eq!(std::fs::read_to_string(&widgets).unwrap(), "{\"defwidget@agent\": {\"label\": \"A\"}}");
515 }
516
517 #[test]
518 fn blocks_and_snapshots_in_the_store_are_moved() {
519 let dir = tempfile::tempdir().unwrap();
520 let store = Store::open(&dir.path().join("objects.db")).unwrap();
521 let mut block = crate::backend::obj::Block {
522 oid: "b1".into(),
523 meta: meta(json!({"view": "armory", "armory:section": "bundles"})).into_iter().collect(),
524 ..Default::default()
525 };
526 store.insert(&mut block).unwrap();
527 let mut other = crate::backend::obj::Block {
528 oid: "b2".into(),
529 meta: meta(json!({"view": "term"})).into_iter().collect(),
530 ..Default::default()
531 };
532 store.insert(&mut other).unwrap();
533 assert_eq!(old_view_block_count(&store).unwrap(), 1);
534 assert_eq!(migrate_blocks(&store).unwrap(), 1);
535 assert_eq!(migrate_blocks(&store).unwrap(), 0, "idempotent");
536 let b1 = store.get_raw("block", "b1").unwrap().unwrap();
537 assert_eq!(b1["meta"]["view"], "memory");
538 assert_eq!(b1["meta"]["memory:section"], "bundles");
539 assert_eq!(old_view_block_count(&store).unwrap(), 0);
540 }
541}