1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4fn split_colon_url_provider(raw: &str) -> (String, Option<String>) {
12 if let Some(colon_idx) = raw.find(':') {
13 let (prefix, rest) = raw.split_at(colon_idx);
14 let url = &rest[1..];
15 if (prefix == "custom" || prefix == "anthropic-custom")
16 && (url.starts_with("https://") || url.starts_with("http://"))
17 {
18 return (prefix.to_string(), Some(url.to_string()));
19 }
20 }
21 (raw.to_string(), None)
22}
23
24#[derive(Debug, Default, Deserialize, Serialize)]
27pub struct V2Config {
28 #[serde(default = "default_v2_schema_version")]
29 pub schema_version: u32,
30
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub autonomy: Option<toml::Value>,
34
35 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub agent: Option<toml::Value>,
38
39 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
41 pub swarms: HashMap<String, toml::Value>,
42
43 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub cron: Option<toml::Value>,
47
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub providers: Option<toml::Value>,
51
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub cost: Option<toml::Value>,
55
56 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub channels: Option<toml::Value>,
60
61 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
65 pub agents: HashMap<String, toml::Value>,
66
67 #[serde(flatten)]
69 pub passthrough: toml::Table,
70}
71
72fn default_v2_schema_version() -> u32 {
73 2
74}
75
76pub const V3_CHANNEL_TYPES: &[&str] = &[
83 "telegram",
84 "discord",
85 "slack",
86 "mattermost",
87 "webhook",
88 "imessage",
89 "matrix",
90 "signal",
91 "whatsapp",
92 "linq",
93 "wati",
94 "nextcloud_talk",
95 "email",
96 "gmail_push",
97 "irc",
98 "lark",
99 "line",
100 "dingtalk",
101 "wecom",
102 "wecom_ws",
103 "wechat",
104 "qq",
105 "twitter",
106 "mochat",
107 "nostr",
108 "clawdtalk",
109 "reddit",
110 "bluesky",
111 "voice_call",
112 "voice_wake",
113 "voice_duplex",
114 "mqtt",
115];
116
117impl V2Config {
118 pub fn migrate(self) -> anyhow::Result<toml::Value> {
122 let V2Config {
123 schema_version: _,
124 autonomy,
125 agent,
126 swarms,
127 cron,
128 providers,
129 cost,
130 channels,
131 agents,
132 mut passthrough,
133 } = self;
134
135 if let Some(autonomy_value) = autonomy {
149 let renamed = rename_table_keys(
150 autonomy_value,
151 &[("non_cli_excluded_tools", "excluded_tools")],
152 );
153 let (risk_fields, runtime_fields) = split_autonomy_into_profile_buckets(renamed);
154 if let Some(risk_table) = risk_fields {
155 let mut risk_profiles = passthrough
156 .remove("risk_profiles")
157 .and_then(|v| v.try_into::<toml::Table>().ok())
158 .unwrap_or_default();
159 merge_into_profile_default(&mut risk_profiles, risk_table);
160 passthrough.insert(
161 "risk_profiles".to_string(),
162 toml::Value::Table(risk_profiles),
163 );
164 ::zeroclaw_log::record!(
165 INFO,
166 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
167 "[autonomy] authorization fields → [risk_profiles.default]"
168 );
169 }
170 if let Some(runtime_table) = runtime_fields {
171 let mut runtime_profiles = passthrough
172 .remove("runtime_profiles")
173 .and_then(|v| v.try_into::<toml::Table>().ok())
174 .unwrap_or_default();
175 merge_into_profile_default(&mut runtime_profiles, runtime_table);
176 passthrough.insert(
177 "runtime_profiles".to_string(),
178 toml::Value::Table(runtime_profiles),
179 );
180 ::zeroclaw_log::record!(
181 INFO,
182 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
183 "[autonomy] budget/timeout fields → [runtime_profiles.default]"
184 );
185 }
186 }
187
188 fold_security_into_risk_profile(&mut passthrough);
194
195 if let Some(toml::Value::Table(mut agent_table)) = agent {
203 let allowed_tools = agent_table.remove("allowed_tools");
204 if let Some(at_value) = allowed_tools {
205 let mut risk_profiles = passthrough
206 .remove("risk_profiles")
207 .and_then(|v| v.try_into::<toml::Table>().ok())
208 .unwrap_or_default();
209 let mut risk_default = toml::Table::new();
210 risk_default.insert("allowed_tools".to_string(), at_value);
211 merge_into_profile_default(&mut risk_profiles, risk_default);
212 passthrough.insert(
213 "risk_profiles".to_string(),
214 toml::Value::Table(risk_profiles),
215 );
216 ::zeroclaw_log::record!(
217 INFO,
218 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
219 "[agent.allowed_tools] → [risk_profiles.default.allowed_tools]"
220 );
221 }
222 if !agent_table.is_empty() {
223 let mut runtime_profiles = passthrough
224 .remove("runtime_profiles")
225 .and_then(|v| v.try_into::<toml::Table>().ok())
226 .unwrap_or_default();
227 merge_into_profile_default(&mut runtime_profiles, agent_table);
228 passthrough.insert(
229 "runtime_profiles".to_string(),
230 toml::Value::Table(runtime_profiles),
231 );
232 ::zeroclaw_log::record!(
233 INFO,
234 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
235 "[agent] → [runtime_profiles.default]"
236 );
237 }
238 }
239
240 if !swarms.is_empty() {
242 ::zeroclaw_log::record!(
243 INFO,
244 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
245 &format!("[swarms] dropped ({} entries)", swarms.len())
246 );
247 }
248
249 if let Some(toml::Value::Table(reliability_table)) = passthrough.get_mut("reliability") {
252 let dropped_fb = reliability_table.remove("fallback_providers").is_some();
253 let dropped_mf = reliability_table.remove("model_fallbacks").is_some();
254 if dropped_fb || dropped_mf {
255 ::zeroclaw_log::record!(
256 INFO,
257 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
258 "[reliability] {{fallback_providers, model_fallbacks}} dropped (provider fallback eradicated in V3)"
259 );
260 }
261 }
262
263 let mut new_providers = providers
266 .and_then(|v| match v {
267 toml::Value::Table(t) => Some(t),
268 _ => None,
269 })
270 .unwrap_or_default();
271 if new_providers.remove("fallback").is_some() {
272 ::zeroclaw_log::record!(
273 INFO,
274 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
275 "providers.fallback eradicated"
276 );
277 }
278 let mut aliased_models = alias_provider_models(new_providers.remove("models"));
279
280 fold_providers_globals_into_models(&mut new_providers, &mut aliased_models);
283
284 let cost_passthrough = if let Some(cost_value) = cost {
288 let (cost_remaining, prices) = strip_cost_prices(cost_value);
289 if !prices.is_empty() {
290 drop_cost_prices_with_logs(&prices);
291 }
292 cost_remaining
293 } else {
294 None
295 };
296 if !aliased_models.is_empty() {
297 new_providers.insert("models".to_string(), toml::Value::Table(aliased_models));
298 }
299
300 rename_route_provider_field(&mut new_providers, "model_routes");
305 rename_route_provider_field(&mut new_providers, "embedding_routes");
306 rename_route_provider_field(&mut passthrough, "model_routes");
307 rename_route_provider_field(&mut passthrough, "embedding_routes");
308
309 fold_v2_tts_into_providers(&mut passthrough, &mut new_providers);
314 fold_v2_transcription_into_providers(&mut passthrough, &mut new_providers);
315
316 let mut v3_providers = toml::Table::new();
321 if let Some(models) = new_providers.remove("models") {
322 v3_providers.insert("models".to_string(), models);
323 }
324 if let Some(tts) = new_providers.remove("tts") {
325 v3_providers.insert("tts".to_string(), tts);
326 }
327 if let Some(transcription) = new_providers.remove("transcription") {
328 v3_providers.insert("transcription".to_string(), transcription);
329 }
330 if !v3_providers.is_empty() {
331 passthrough.insert("providers".to_string(), toml::Value::Table(v3_providers));
332 }
333 if let Some(routes) = new_providers.remove("model_routes") {
334 passthrough.insert("model_routes".to_string(), routes);
335 }
336 if let Some(routes) = new_providers.remove("embedding_routes") {
337 passthrough.insert("embedding_routes".to_string(), routes);
338 }
339 if !new_providers.is_empty() {
340 ::zeroclaw_log::record!(
341 WARN,
342 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
343 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
344 &format!(
345 "[providers] residual keys dropped during V3 hoist: {:?}",
346 new_providers.keys().collect::<Vec<_>>()
347 )
348 );
349 }
350 if let Some(remaining_cost) = cost_passthrough {
351 passthrough.insert("cost".to_string(), remaining_cost);
352 }
353
354 fold_v2_storage_subsystems(&mut passthrough);
357
358 if let Some(channels_value) = channels {
365 let mut peer_groups_for_fold = match passthrough.remove("peer_groups") {
366 Some(toml::Value::Table(t)) => t,
367 _ => toml::Table::new(),
368 };
369 let new_channels = alias_wrap_channels(channels_value, &mut peer_groups_for_fold);
370 passthrough.insert("channels".to_string(), toml::Value::Table(new_channels));
371 if !peer_groups_for_fold.is_empty() {
372 passthrough.insert(
373 "peer_groups".to_string(),
374 toml::Value::Table(peer_groups_for_fold),
375 );
376 }
377 ::zeroclaw_log::record!(
378 INFO,
379 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
380 "[channels] sections alias-wrapped, discord_history folded, inbound peer-auth folded into [peer_groups.*]"
381 );
382 }
383
384 if let Some(cron_value) = cron {
385 let (new_cron, scheduler_extras) = restructure_cron(cron_value);
386 if !new_cron.is_empty() {
387 passthrough.insert("cron".to_string(), toml::Value::Table(new_cron));
388 }
389 if !scheduler_extras.is_empty() {
390 merge_into_table(&mut passthrough, "scheduler", scheduler_extras);
391 }
392 ::zeroclaw_log::record!(
393 INFO,
394 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
395 "[cron] restructured into [cron.<alias>] + [scheduler]"
396 );
397 }
398
399 let new_agents = if !agents.is_empty() {
405 synthesize_agent_brains(agents, &mut passthrough)
406 } else {
407 let synthesized = synthesize_default_agent_if_needed(&passthrough);
408 if !synthesized.is_empty() {
409 ensure_profile_entry(&mut passthrough, "risk_profiles", "default");
410 ensure_profile_entry(&mut passthrough, "runtime_profiles", "default");
411 }
412 synthesized
413 };
414 if !new_agents.is_empty() {
415 passthrough.insert("agents".to_string(), toml::Value::Table(new_agents));
416 }
417
418 lift_top_level_identity_into_agents(&mut passthrough);
422
423 backfill_heartbeat_agent(&mut passthrough);
427
428 rewrite_dangling_peer_group_agents(&mut passthrough);
433
434 rename_subkey(&mut passthrough, "tunnel", "provider", "tunnel_provider");
438 rename_subkey(
439 &mut passthrough,
440 "web_search",
441 "provider",
442 "search_provider",
443 );
444
445 passthrough.insert("schema_version".to_string(), toml::Value::Integer(3));
446
447 Ok(toml::Value::Table(passthrough))
448 }
449}
450
451fn rename_subkey(table: &mut toml::Table, parent: &str, inner: &str, replacement: &str) {
457 let Some(toml::Value::Table(parent_tbl)) = table.get_mut(parent) else {
458 return;
459 };
460 if parent_tbl.contains_key(replacement) {
461 let _ = parent_tbl.remove(inner);
465 return;
466 }
467 if let Some(value) = parent_tbl.remove(inner) {
468 parent_tbl.insert(replacement.to_string(), value);
469 ::zeroclaw_log::record!(
470 INFO,
471 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
472 ::serde_json::json!({"parent": parent, "inner": inner, "replacement": replacement})
473 ),
474 &format!(
475 "[{parent}].{inner} renamed to [{parent}].{replacement} (V3 qualified-noun rename)"
476 )
477 );
478 }
479}
480
481fn restructure_cron(cron_value: toml::Value) -> (toml::Table, toml::Table) {
483 let mut new_cron = toml::Table::new();
484 let mut scheduler_extras = toml::Table::new();
485 let mut cron_table = match cron_value {
486 toml::Value::Table(t) => t,
487 _ => return (new_cron, scheduler_extras),
488 };
489
490 if let Some(toml::Value::Array(jobs)) = cron_table.remove("jobs") {
493 for (i, job) in jobs.into_iter().enumerate() {
494 let key = job
496 .get("name")
497 .and_then(toml::Value::as_str)
498 .map(slugify)
499 .or_else(|| {
500 job.get("id")
501 .and_then(toml::Value::as_str)
502 .map(ToString::to_string)
503 })
504 .unwrap_or_else(|| format!("job_{}", i + 1));
505 let key = ensure_unique_key(&new_cron, key);
506 let stripped = match job {
507 toml::Value::Table(mut t) => {
508 t.remove("id");
509 dot_delivery_channel(&mut t);
510 toml::Value::Table(t)
511 }
512 other => other,
513 };
514 new_cron.insert(key, stripped);
515 }
516 }
517
518 for knob in ["enabled", "catch_up_on_startup", "max_run_history"] {
520 if let Some(v) = cron_table.remove(knob) {
521 scheduler_extras.insert(knob.to_string(), v);
522 }
523 }
524
525 if !cron_table.is_empty() {
528 ::zeroclaw_log::record!(
529 INFO,
530 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
531 &format!(
532 "[cron] had unmodeled keys: {:?}",
533 cron_table.keys().collect::<Vec<_>>()
534 )
535 );
536 }
537
538 (new_cron, scheduler_extras)
539}
540
541fn dot_delivery_channel(job: &mut toml::Table) {
542 let Some(toml::Value::Table(delivery)) = job.get_mut("delivery") else {
543 return;
544 };
545 let Some(toml::Value::String(channel)) = delivery.get_mut("channel") else {
546 return;
547 };
548 if !channel.contains('.') {
549 *channel = format!("{channel}.default");
550 }
551}
552
553fn normalize_provider_type(
566 raw: &str,
567 incoming_alias: &str,
568) -> (String, String, Vec<(&'static str, toml::Value)>) {
569 let mut extras: Vec<(&'static str, toml::Value)> = Vec::new();
570
571 let synonym_canonical = match raw {
573 "azure_openai" | "azure-openai" | "azure" => Some("azure"),
575 "xai" | "grok" => Some("xai"),
577 "gemini" | "google" | "google-gemini" => Some("gemini"),
579 "together" | "together-ai" => Some("together"),
581 "fireworks" | "fireworks-ai" => Some("fireworks"),
583 "vercel" | "vercel-ai" => Some("vercel"),
585 "cloudflare" | "cloudflare-ai" => Some("cloudflare"),
587 "nvidia" | "nvidia-nim" | "build.nvidia.com" => Some("nvidia"),
589 "bedrock" | "aws-bedrock" => Some("bedrock"),
591 "lmstudio" | "lm-studio" => Some("lmstudio"),
593 "litellm" | "lite-llm" => Some("litellm"),
595 "huggingface" | "hf" => Some("huggingface"),
597 "yi" | "01ai" | "lingyiwanwu" => Some("yi"),
599 "hunyuan" | "tencent" => Some("hunyuan"),
601 "qianfan" | "baidu" => Some("qianfan"),
603 "copilot" | "github-copilot" => Some("copilot"),
605 "ovhcloud" | "ovh" => Some("ovh"),
607 "opencode" | "opencode-zen" => Some("opencode"),
609 "llamacpp" | "llama.cpp" => Some("llamacpp"),
611 "deepmyst" | "deep-myst" => Some("deepmyst"),
613 "siliconflow" | "silicon-flow" => Some("siliconflow"),
615 "deepinfra" | "deep-infra" => Some("deepinfra"),
617 "ai21" | "ai21-labs" => Some("ai21"),
619 "friendli" | "friendliai" => Some("friendli"),
621 "lepton" | "lepton-ai" => Some("lepton"),
623 "stepfun" | "step" => Some("stepfun"),
625 "kilocli" | "kilo" => Some("kilocli"),
627 _ => None,
628 };
629
630 if let Some(canonical) = synonym_canonical {
631 return (canonical.to_string(), incoming_alias.to_string(), extras);
632 }
633
634 if raw == "opencode-go" {
636 return ("opencode".to_string(), "go".to_string(), extras);
637 }
638
639 if matches!(raw, "openai-codex" | "openai_codex" | "codex") {
641 extras.push(("wire_api", toml::Value::String("responses".to_string())));
642 extras.push(("requires_openai_auth", toml::Value::Boolean(true)));
643 return ("openai".to_string(), "codex".to_string(), extras);
644 }
645
646 if raw == "claude-code" {
649 return ("anthropic".to_string(), "claude-code".to_string(), extras);
650 }
651
652 if raw == "anthropic-custom" {
658 return ("anthropic".to_string(), "custom".to_string(), extras);
659 }
660
661 if raw == "custom" {
665 return ("custom".to_string(), incoming_alias.to_string(), extras);
666 }
667
668 if matches!(
673 raw,
674 "moonshot-intl" | "moonshot-global" | "kimi-intl" | "kimi-global"
675 ) {
676 extras.push(("endpoint", toml::Value::String("intl".to_string())));
677 return ("moonshot".to_string(), incoming_alias.to_string(), extras);
678 }
679 if matches!(raw, "moonshot" | "kimi" | "moonshot-cn" | "kimi-cn") {
680 extras.push(("endpoint", toml::Value::String("cn".to_string())));
681 return ("moonshot".to_string(), incoming_alias.to_string(), extras);
682 }
683 if matches!(raw, "kimi-code" | "kimi_coding" | "kimi_for_coding") {
684 extras.push(("endpoint", toml::Value::String("code".to_string())));
685 return ("moonshot".to_string(), incoming_alias.to_string(), extras);
686 }
687
688 if matches!(raw, "qwen-cn" | "dashscope" | "qwen" | "dashscope-cn") {
690 extras.push(("endpoint", toml::Value::String("cn".to_string())));
691 return ("qwen".to_string(), incoming_alias.to_string(), extras);
692 }
693 if matches!(
694 raw,
695 "qwen-intl" | "dashscope-intl" | "qwen-international" | "dashscope-international"
696 ) {
697 extras.push(("endpoint", toml::Value::String("intl".to_string())));
698 return ("qwen".to_string(), incoming_alias.to_string(), extras);
699 }
700 if matches!(raw, "qwen-us" | "dashscope-us") {
701 extras.push(("endpoint", toml::Value::String("us".to_string())));
702 return ("qwen".to_string(), incoming_alias.to_string(), extras);
703 }
704 if matches!(raw, "qwen-code" | "qwen-oauth" | "qwen_oauth") {
705 extras.push(("endpoint", toml::Value::String("code".to_string())));
706 extras.push(("auth_mode", toml::Value::String("oauth".to_string())));
707 return ("qwen".to_string(), incoming_alias.to_string(), extras);
708 }
709 if matches!(raw, "bailian" | "aliyun-bailian" | "aliyun") {
710 extras.push(("endpoint", toml::Value::String("cn".to_string())));
711 return ("qwen".to_string(), incoming_alias.to_string(), extras);
712 }
713
714 if matches!(raw, "glm" | "zhipu" | "glm-global" | "zhipu-global") {
716 extras.push(("endpoint", toml::Value::String("global".to_string())));
717 return ("glm".to_string(), incoming_alias.to_string(), extras);
718 }
719 if matches!(raw, "glm-cn" | "zhipu-cn" | "bigmodel") {
720 extras.push(("endpoint", toml::Value::String("cn".to_string())));
721 return ("glm".to_string(), incoming_alias.to_string(), extras);
722 }
723
724 if matches!(raw, "zai" | "z.ai" | "zai-global" | "z.ai-global") {
726 extras.push(("endpoint", toml::Value::String("global".to_string())));
727 return ("zai".to_string(), incoming_alias.to_string(), extras);
728 }
729 if matches!(raw, "zai-cn" | "z.ai-cn") {
730 extras.push(("endpoint", toml::Value::String("cn".to_string())));
731 return ("zai".to_string(), incoming_alias.to_string(), extras);
732 }
733
734 if matches!(
736 raw,
737 "minimax"
738 | "minimax-intl"
739 | "minimax-io"
740 | "minimax-global"
741 | "minimax-portal"
742 | "minimax-portal-global"
743 ) {
744 extras.push(("endpoint", toml::Value::String("intl".to_string())));
745 return ("minimax".to_string(), incoming_alias.to_string(), extras);
746 }
747 if matches!(raw, "minimax-oauth" | "minimax-oauth-global") {
748 extras.push(("endpoint", toml::Value::String("intl".to_string())));
749 extras.push(("auth_mode", toml::Value::String("oauth".to_string())));
750 return ("minimax".to_string(), incoming_alias.to_string(), extras);
751 }
752 if matches!(raw, "minimax-cn" | "minimaxi" | "minimax-portal-cn") {
753 extras.push(("endpoint", toml::Value::String("cn".to_string())));
754 return ("minimax".to_string(), incoming_alias.to_string(), extras);
755 }
756 if matches!(raw, "minimax-oauth-cn") {
757 extras.push(("endpoint", toml::Value::String("cn".to_string())));
758 extras.push(("auth_mode", toml::Value::String("oauth".to_string())));
759 return ("minimax".to_string(), incoming_alias.to_string(), extras);
760 }
761
762 if matches!(raw, "doubao" | "volcengine" | "ark" | "doubao-cn") {
764 return ("doubao".to_string(), incoming_alias.to_string(), extras);
765 }
766
767 if raw == "gemini-cli" {
769 return ("gemini_cli".to_string(), incoming_alias.to_string(), extras);
770 }
771
772 if matches!(raw, "stepfun-intl" | "step-intl") {
774 extras.push((
775 "uri",
776 toml::Value::String("https://api.stepfun.com/intl/v1".to_string()),
777 ));
778 return ("stepfun".to_string(), incoming_alias.to_string(), extras);
779 }
780
781 (raw.to_string(), incoming_alias.to_string(), extras)
786}
787
788fn alias_provider_models(models: Option<toml::Value>) -> toml::Table {
789 let flat = match models {
790 Some(toml::Value::Table(t)) => t,
791 _ => return toml::Table::new(),
792 };
793 let mut aliased = toml::Table::new();
794 for (provider_id, mut config) in flat {
795 let (raw_type, url) = split_colon_url_provider(&provider_id);
798 if let Some(url) = url
799 && let toml::Value::Table(t) = &mut config
800 {
801 t.entry("uri".to_string())
802 .or_insert(toml::Value::String(url));
803 }
804
805 if let toml::Value::Table(t) = &mut config {
812 fold_base_url_api_path_into_uri(t);
813 }
814
815 let (provider_type, alias, extras) = normalize_provider_type(&raw_type, "default");
816
817 if let toml::Value::Table(t) = &mut config {
821 for (field, value) in extras {
822 t.entry(field.to_string()).or_insert(value);
823 }
824 }
825
826 let entry = aliased
827 .entry(provider_type)
828 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
829 if let toml::Value::Table(entry_table) = entry {
830 entry_table.insert(alias, config);
831 }
832 }
833 aliased
834}
835
836fn fold_providers_globals_into_models(
858 new_providers: &mut toml::Table,
859 aliased_models: &mut toml::Table,
860) {
861 let g_api_key = new_providers.remove("api_key");
862 let g_api_url = new_providers.remove("api_url");
863 let g_api_path = new_providers.remove("api_path");
864 let g_default_provider = new_providers.remove("default_provider");
865 let g_default_model = new_providers.remove("default_model");
866 let g_default_temperature = new_providers.remove("default_temperature");
867 let g_provider_timeout_secs = new_providers.remove("provider_timeout_secs");
868 let g_provider_max_tokens = new_providers.remove("provider_max_tokens");
869 let g_extra_headers = new_providers.remove("extra_headers");
870
871 let any_value_globals = g_api_key.is_some()
872 || g_api_url.is_some()
873 || g_api_path.is_some()
874 || g_default_model.is_some()
875 || g_default_temperature.is_some()
876 || g_provider_timeout_secs.is_some()
877 || g_provider_max_tokens.is_some()
878 || g_extra_headers.is_some();
879
880 if !any_value_globals && g_default_provider.is_none() {
881 return;
882 }
883
884 let (target_type, target_alias, colon_url, normalized_extras) =
896 match g_default_provider.as_ref().and_then(toml::Value::as_str) {
897 Some(s) => {
898 let (raw_type, url) = split_colon_url_provider(s);
899 let (canonical, alias, extras) = normalize_provider_type(&raw_type, "default");
900 (canonical, alias, url, extras)
901 }
902 None => match aliased_models.keys().next() {
903 Some(k) => (k.clone(), "default".to_string(), None, Vec::new()),
904 None => (
905 "openrouter".to_string(),
906 "default".to_string(),
907 None,
908 Vec::new(),
909 ),
910 },
911 };
912
913 let provider_value = aliased_models
914 .entry(target_type.clone())
915 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
916 let provider_table = match provider_value.as_table_mut() {
917 Some(t) => t,
918 None => return,
919 };
920 let alias_value = provider_table
921 .entry(target_alias.clone())
922 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
923 let alias_table = match alias_value.as_table_mut() {
924 Some(t) => t,
925 None => return,
926 };
927
928 let base_url_source = colon_url.map(toml::Value::String).or(g_api_url);
935 let uri_source = match (base_url_source, g_api_path) {
936 (Some(toml::Value::String(b)), Some(toml::Value::String(p))) => {
937 let trimmed_b = b.trim_end_matches('/');
938 let suffix = if p.starts_with('/') {
939 p
940 } else {
941 format!("/{p}")
942 };
943 Some(toml::Value::String(format!("{trimmed_b}{suffix}")))
944 }
945 (Some(b), _) => Some(b),
946 (None, _) => None,
948 };
949
950 for (target_key, source) in [
952 ("api_key", g_api_key),
953 ("uri", uri_source),
954 ("model", g_default_model),
955 ("temperature", g_default_temperature),
956 ("timeout_secs", g_provider_timeout_secs),
957 ("max_tokens", g_provider_max_tokens),
958 ("extra_headers", g_extra_headers),
959 ] {
960 if let Some(value) = source
961 && !alias_table.contains_key(target_key)
962 {
963 alias_table.insert(target_key.to_string(), value);
964 }
965 }
966
967 for (field, value) in normalized_extras {
971 if !alias_table.contains_key(field) {
972 alias_table.insert(field.to_string(), value);
973 }
974 }
975
976 if any_value_globals {
977 ::zeroclaw_log::record!(
978 INFO,
979 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
980 ::serde_json::json!({"target_type": target_type, "target_alias": target_alias})
981 ),
982 "[providers] globals folded onto model_providers.."
983 );
984 }
985}
986
987fn strip_cost_prices(cost_value: toml::Value) -> (Option<toml::Value>, toml::Table) {
991 let mut cost_table = match cost_value {
992 toml::Value::Table(t) => t,
993 other => return (Some(other), toml::Table::new()),
994 };
995 let prices = match cost_table.remove("prices") {
996 Some(toml::Value::Table(p)) => p,
997 Some(other) => {
998 cost_table.insert("prices".to_string(), other);
1000 return (Some(toml::Value::Table(cost_table)), toml::Table::new());
1001 }
1002 None => toml::Table::new(),
1003 };
1004 let cost_passthrough = if cost_table.is_empty() {
1005 None
1006 } else {
1007 Some(toml::Value::Table(cost_table))
1008 };
1009 (cost_passthrough, prices)
1010}
1011
1012fn drop_cost_prices_with_logs(prices: &toml::Table) {
1019 for (model_id, price) in prices {
1020 let (input, output) = match price.as_table() {
1021 Some(t) => (
1022 t.get("input").and_then(toml::Value::as_float),
1023 t.get("output").and_then(toml::Value::as_float),
1024 ),
1025 None => (None, None),
1026 };
1027 ::zeroclaw_log::record!(
1028 INFO,
1029 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
1030 ::serde_json::json!({"model_id": model_id, "input": format!("{:?}", input), "output": format!("{:?}", output)})
1031 ),
1032 &format!(
1033 "[cost.prices.{model_id}] dropped (V3 puts pricing on each \
1034 [model_providers.<type>.<alias>] block); last-known rates: \
1035 input={input:?} output={output:?}"
1036 )
1037 );
1038 }
1039}
1040
1041fn synthesize_peer_group_from_allowlist(
1061 peer_groups: &mut toml::Table,
1062 channel_type: &str,
1063 channel_alias: &str,
1064 field_name: &str,
1065 allowed: toml::Value,
1066) {
1067 let toml::Value::Array(allowed) = allowed else {
1068 return;
1069 };
1070 let usernames: Vec<String> = allowed
1071 .iter()
1072 .filter_map(|v| v.as_str())
1073 .map(str::trim)
1074 .filter(|s| !s.is_empty() && *s != "*")
1075 .map(str::to_string)
1076 .collect();
1077 if usernames.is_empty() {
1078 return;
1079 }
1080 let group_name = format!("{channel_type}_{channel_alias}");
1081 if peer_groups.contains_key(&group_name) {
1082 return;
1084 }
1085 let mut group_entry = toml::Table::new();
1086 group_entry.insert(
1088 "channel".to_string(),
1089 toml::Value::String(channel_type.to_string()),
1090 );
1091 group_entry.insert(
1093 "agents".to_string(),
1094 toml::Value::Array(vec![toml::Value::String("default".to_string())]),
1095 );
1096 let external_peers: Vec<toml::Value> = usernames.into_iter().map(toml::Value::String).collect();
1097 group_entry.insert(
1098 "external_peers".to_string(),
1099 toml::Value::Array(external_peers),
1100 );
1101 peer_groups.insert(group_name, toml::Value::Table(group_entry));
1102 ::zeroclaw_log::record!(
1103 INFO,
1104 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
1105 ::serde_json::json!({"channel_type": channel_type, "channel_alias": channel_alias, "field_name": field_name})
1106 ),
1107 &format!(
1108 "channels.{channel_type}.{channel_alias}.{field_name} folded into [peer_groups.{channel_type}_{channel_alias}]"
1109 )
1110 );
1111}
1112
1113fn alias_wrap_channels(channels_value: toml::Value, peer_groups: &mut toml::Table) -> toml::Table {
1128 let mut channels_table = match channels_value {
1129 toml::Value::Table(t) => t,
1130 _ => return toml::Table::new(),
1131 };
1132 let mut new_channels = toml::Table::new();
1133
1134 if let Some(cli) = channels_table.remove("cli") {
1136 new_channels.insert("cli".to_string(), cli);
1137 }
1138
1139 fold_discord_history(&mut channels_table);
1142
1143 let stashed_feishu_v2 = strip_feishu_block(&mut channels_table);
1151
1152 for ct in V3_CHANNEL_TYPES {
1155 let Some(value) = channels_table.remove(*ct) else {
1156 continue;
1157 };
1158 let mut instance = match value {
1159 toml::Value::Table(t) => t,
1160 other => {
1161 let mut wrapped = toml::Table::new();
1165 wrapped.insert("default".to_string(), other);
1166 new_channels.insert((*ct).to_string(), toml::Value::Table(wrapped));
1167 continue;
1168 }
1169 };
1170 apply_v2_to_v3_channel_folds(ct, &mut instance);
1171 fold_channel_peer_auth_into_peer_groups(ct, &mut instance, peer_groups);
1172 let mut wrapped = toml::Table::new();
1177 wrapped.insert("default".to_string(), toml::Value::Table(instance));
1178 new_channels.insert((*ct).to_string(), toml::Value::Table(wrapped));
1179 }
1180
1181 if !channels_table.is_empty() {
1183 let leftover_keys: Vec<String> = channels_table.keys().cloned().collect();
1184 ::zeroclaw_log::record!(
1185 INFO,
1186 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1187 &format!(
1188 "[channels] passthrough for unmodeled keys: {:?}",
1189 leftover_keys
1190 )
1191 );
1192 for (k, v) in channels_table {
1193 new_channels.insert(k, v);
1194 }
1195 }
1196
1197 inject_feishu_as_lark_alias(&mut new_channels, stashed_feishu_v2);
1203
1204 new_channels
1205}
1206
1207fn strip_feishu_block(channels: &mut toml::Table) -> Option<toml::Table> {
1211 let feishu_value = channels.remove("feishu")?;
1212 match feishu_value {
1213 toml::Value::Table(t) => Some(t),
1214 _ => {
1215 ::zeroclaw_log::record!(
1216 WARN,
1217 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1218 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1219 "[channels.feishu] is not a table; dropping during fold to lark"
1220 );
1221 None
1222 }
1223 }
1224}
1225
1226fn inject_feishu_as_lark_alias(new_channels: &mut toml::Table, feishu_table: Option<toml::Table>) {
1237 let Some(mut feishu_table) = feishu_table else {
1238 return;
1239 };
1240
1241 feishu_table.insert("use_feishu".to_string(), toml::Value::Boolean(true));
1242
1243 let lark_entry = new_channels
1244 .entry("lark".to_string())
1245 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1246 let Some(lark_aliases) = lark_entry.as_table_mut() else {
1247 ::zeroclaw_log::record!(
1248 WARN,
1249 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1250 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1251 "[channels.lark] is not a table; cannot inject feishu alias"
1252 );
1253 return;
1254 };
1255
1256 if lark_aliases.contains_key("feishu") {
1257 ::zeroclaw_log::record!(
1258 WARN,
1259 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1260 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1261 "[channels.lark.feishu] already exists; the V2 [channels.feishu] \
1262 block was dropped to avoid clobbering it. Recover the dropped \
1263 value from the pre-migration <config>.backup if needed."
1264 );
1265 return;
1266 }
1267
1268 lark_aliases.insert("feishu".to_string(), toml::Value::Table(feishu_table));
1269 ::zeroclaw_log::record!(
1270 INFO,
1271 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1272 "[channels.feishu] folded into [channels.lark.feishu] (use_feishu=true)"
1273 );
1274}
1275
1276fn fold_discord_history(channels: &mut toml::Table) {
1289 let history_value = match channels.remove("discord_history") {
1290 Some(v) => v,
1291 None => return,
1292 };
1293
1294 let discord_bot_token = channels
1296 .get("discord")
1297 .and_then(toml::Value::as_table)
1298 .and_then(|t| t.get("bot_token"))
1299 .and_then(toml::Value::as_str)
1300 .map(ToString::to_string);
1301 let history_bot_token = history_value
1302 .as_table()
1303 .and_then(|t| t.get("bot_token"))
1304 .and_then(toml::Value::as_str)
1305 .map(ToString::to_string);
1306 let bot_token_conflict = match (&discord_bot_token, &history_bot_token) {
1307 (Some(d), Some(h)) => d != h,
1308 _ => false,
1309 };
1310
1311 let history_enabled = history_value
1312 .as_table()
1313 .and_then(|t| t.get("enabled"))
1314 .and_then(toml::Value::as_bool)
1315 .unwrap_or(false);
1316 let discord_enabled = channels
1317 .get("discord")
1318 .and_then(toml::Value::as_table)
1319 .and_then(|t| t.get("enabled"))
1320 .and_then(toml::Value::as_bool)
1321 .unwrap_or(false);
1322 let effective_enabled = discord_enabled || history_enabled;
1323
1324 let discord_entry = channels
1325 .entry("discord".to_string())
1326 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1327 if let Some(discord_table) = discord_entry.as_table_mut() {
1328 discord_table.insert("archive".to_string(), toml::Value::Boolean(true));
1329 if let toml::Value::Table(history_table) = history_value {
1330 for (k, v) in history_table {
1331 if k == "enabled" {
1332 continue;
1334 }
1335 discord_table.entry(k).or_insert(v);
1336 }
1337 }
1338 discord_table.insert(
1339 "enabled".to_string(),
1340 toml::Value::Boolean(effective_enabled),
1341 );
1342 }
1343 ::zeroclaw_log::record!(
1344 INFO,
1345 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1346 .with_attrs(::serde_json::json!({"effective_enabled": effective_enabled})),
1347 "[channels.discord_history] folded into [channels.discord] (archive=true, effective enabled=)"
1348 );
1349 if bot_token_conflict {
1350 ::zeroclaw_log::record!(
1351 WARN,
1352 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1353 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1354 "[channels.discord_history].bot_token differed from [channels.discord].bot_token; \
1355 the discord_history token was dropped and the discord token survives. \
1356 Two-bot deployments must reconfigure manually — recover the dropped value \
1357 from the pre-migration <config>.backup file adjacent to the migrated config."
1358 );
1359 }
1360}
1361
1362fn apply_v2_to_v3_channel_folds(channel_type: &str, instance: &mut toml::Table) {
1367 use crate::migration::fold_string_into_array;
1368 match channel_type {
1369 "discord" if fold_string_into_array(instance, "guild_id", "guild_ids") => {
1370 ::zeroclaw_log::record!(
1371 INFO,
1372 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1373 "channels.discord.guild_id folded into channels.discord.guild_ids[]"
1374 );
1375 }
1376 "mattermost" if fold_string_into_array(instance, "channel_id", "channel_ids") => {
1377 ::zeroclaw_log::record!(
1378 INFO,
1379 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1380 "channels.mattermost.channel_id folded into channels.mattermost.channel_ids[]"
1381 );
1382 }
1383 "reddit" if fold_string_into_array(instance, "subreddit", "subreddits") => {
1384 ::zeroclaw_log::record!(
1385 INFO,
1386 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1387 "channels.reddit.subreddit folded into channels.reddit.subreddits[]"
1388 );
1389 }
1390 "signal" => {
1391 if let Some(toml::Value::String(group_id)) = instance.remove("group_id")
1395 && !group_id.is_empty()
1396 {
1397 if group_id == "dm" {
1398 instance.insert("dm_only".to_string(), toml::Value::Boolean(true));
1399 ::zeroclaw_log::record!(
1400 INFO,
1401 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1402 "channels.signal.group_id=\"dm\" → channels.signal.dm_only=true"
1403 );
1404 } else {
1405 let entry = instance
1406 .entry("group_ids".to_string())
1407 .or_insert_with(|| toml::Value::Array(Vec::new()));
1408 if let Some(arr) = entry.as_array_mut() {
1409 let already = arr.iter().any(|v| v.as_str() == Some(group_id.as_str()));
1410 if !already {
1411 arr.push(toml::Value::String(group_id));
1412 }
1413 }
1414 ::zeroclaw_log::record!(
1415 INFO,
1416 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1417 "channels.signal.group_id folded into channels.signal.group_ids[]"
1418 );
1419 }
1420 }
1421 }
1422 _ => {}
1423 }
1424}
1425
1426fn fold_channel_peer_auth_into_peer_groups(
1445 channel_type: &str,
1446 instance: &mut toml::Table,
1447 peer_groups: &mut toml::Table,
1448) {
1449 let Some(field_name) = (match channel_type {
1450 "telegram" | "discord" | "slack" | "mattermost" | "matrix" | "nextcloud_talk" | "irc"
1451 | "lark" | "line" | "feishu" | "dingtalk" | "wecom" | "wechat" | "qq" | "twitter"
1452 | "mochat" => Some("allowed_users"),
1453 "imessage" => Some("allowed_contacts"),
1454 "signal" => Some("allowed_from"),
1455 "whatsapp" | "wati" => Some("allowed_numbers"),
1456 "linq" | "email" | "gmail_push" => Some("allowed_senders"),
1457 "nostr" => Some("allowed_pubkeys"),
1458 _ => None,
1459 }) else {
1460 return;
1461 };
1462 if let Some(allowed) = instance.remove(field_name) {
1463 synthesize_peer_group_from_allowlist(
1464 peer_groups,
1465 channel_type,
1466 "default",
1467 field_name,
1468 allowed,
1469 );
1470 }
1471}
1472
1473fn synthesize_agent_brains(
1494 agents: HashMap<String, toml::Value>,
1495 passthrough: &mut toml::Table,
1496) -> toml::Table {
1497 let mut new_agents = toml::Table::new();
1498 for (alias, agent_value) in agents {
1499 let mut agent_table = match agent_value {
1500 toml::Value::Table(t) => t,
1501 other => {
1502 new_agents.insert(alias, other);
1503 continue;
1504 }
1505 };
1506
1507 let provider = agent_table.remove("provider");
1512 let model = agent_table.remove("model");
1513 let api_key = agent_table.remove("api_key");
1514 let temperature = agent_table.remove("temperature");
1515 let provider_timeout_secs = extract_provider_timeout_secs(&mut agent_table);
1516 if let Some(toml::Value::String(raw_provider)) = provider {
1517 let (provider_type, colon_url) = split_colon_url_provider(&raw_provider);
1522 let provider_alias = format!("agent_{}", alias);
1523 let mut entry = toml::Table::new();
1524 if let Some(url) = colon_url {
1525 entry.insert("uri".to_string(), toml::Value::String(url));
1526 }
1527 if let Some(m) = model {
1528 entry.insert("model".to_string(), m);
1529 }
1530 if let Some(k) = api_key {
1531 entry.insert("api_key".to_string(), k);
1532 }
1533 if let Some(t) = temperature {
1534 entry.insert("temperature".to_string(), t);
1535 }
1536 if let Some(t) = provider_timeout_secs {
1537 entry.insert("timeout_secs".to_string(), t);
1538 }
1539 let providers_value = passthrough
1542 .entry("providers".to_string())
1543 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1544 if let Some(providers_table) = providers_value.as_table_mut() {
1545 let models_value = providers_table
1546 .entry("models".to_string())
1547 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1548 if let Some(models_table) = models_value.as_table_mut() {
1549 let provider_value = models_table
1550 .entry(provider_type.clone())
1551 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1552 if let Some(provider_table) = provider_value.as_table_mut() {
1553 provider_table.insert(provider_alias.clone(), toml::Value::Table(entry));
1554 }
1555 }
1556 }
1557 agent_table.insert(
1558 "model_provider".to_string(),
1559 toml::Value::String(format!("{provider_type}.{provider_alias}")),
1560 );
1561 ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"alias": alias, "provider_type": provider_type, "provider_alias": provider_alias})), "agents.: inline brain → providers.models..");
1562 } else {
1563 if provider_timeout_secs.is_some() {
1567 ::zeroclaw_log::record!(
1568 WARN,
1569 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1570 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1571 .with_attrs(::serde_json::json!({"alias": alias})),
1572 "agents..timeout_secs dropped: V3 stores it on \
1573 [model_providers.<type>.<alias>] and this agent has no \
1574 inline provider to fold it onto. Set it manually after \
1575 migration."
1576 );
1577 }
1578 if let Some(other) = provider {
1579 agent_table.insert("provider".to_string(), other);
1580 }
1581 }
1582
1583 if let Some(v) = agent_table.remove("max_iterations") {
1585 agent_table
1586 .entry("max_tool_iterations".to_string())
1587 .or_insert(v);
1588 ::zeroclaw_log::record!(
1589 INFO,
1590 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1591 .with_attrs(::serde_json::json!({"alias": alias})),
1592 "agents..max_iterations → agents..max_tool_iterations"
1593 );
1594 }
1595
1596 let allowed_tools = agent_table.remove("allowed_tools");
1602 let agentic_flag = agent_table.remove("agentic");
1603 let max_depth = agent_table.remove("max_depth");
1604 let agentic_timeout_secs = extract_agentic_timeout_secs(&mut agent_table);
1605
1606 let profile_alias = format!("agent_{}", alias);
1607
1608 if let Some(at_value) = allowed_tools {
1609 let mut overrides = toml::Table::new();
1610 overrides.insert("allowed_tools".to_string(), at_value);
1611 install_profile_entry(passthrough, "risk_profiles", &profile_alias, overrides);
1612 agent_table
1613 .entry("risk_profile".to_string())
1614 .or_insert_with(|| toml::Value::String(profile_alias.clone()));
1615 ::zeroclaw_log::record!(
1616 INFO,
1617 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1618 .with_attrs(
1619 ::serde_json::json!({"alias": alias, "profile_alias": profile_alias})
1620 ),
1621 "agents..allowed_tools → risk_profiles..allowed_tools"
1622 );
1623 }
1624
1625 if agentic_flag.is_some() || max_depth.is_some() || agentic_timeout_secs.is_some() {
1626 let mut overrides = toml::Table::new();
1627 if let Some(v) = agentic_flag {
1628 overrides.insert("agentic".to_string(), v);
1629 }
1630 if let Some(d) = max_depth {
1631 overrides.insert("max_delegation_depth".to_string(), d);
1632 }
1633 if let Some(t) = agentic_timeout_secs {
1634 overrides.insert("agentic_timeout_secs".to_string(), t);
1635 }
1636 install_profile_entry(passthrough, "runtime_profiles", &profile_alias, overrides);
1637 agent_table
1638 .entry("runtime_profile".to_string())
1639 .or_insert_with(|| toml::Value::String(profile_alias.clone()));
1640 ::zeroclaw_log::record!(
1641 INFO,
1642 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1643 .with_attrs(
1644 ::serde_json::json!({"alias": alias, "profile_alias": profile_alias})
1645 ),
1646 "agents.: agentic/max_depth/agentic_timeout_secs → runtime_profiles."
1647 );
1648 }
1649
1650 if let Some(toml::Value::String(skills_dir)) = agent_table.remove("skills_directory")
1659 && !skills_dir.is_empty()
1660 {
1661 let bundle_alias = format!("agent_{}", alias);
1662 let mut bundle_entry = toml::Table::new();
1663 let trimmed = skills_dir.trim().trim_start_matches("./");
1664 let stays_inside_shared = !std::path::Path::new(trimmed).is_absolute()
1665 && (trimmed == "shared" || trimmed.starts_with("shared/"));
1666 if stays_inside_shared {
1667 bundle_entry.insert("directory".to_string(), toml::Value::String(skills_dir));
1668 } else {
1669 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"alias": alias, "skills_dir": skills_dir, "bundle_alias": bundle_alias})), "agents..skills_directory = \"\" lies outside \
1670 <install>/shared/. V3 confines skill-bundles to \
1671 <install>/shared/skills/<alias>/; the path was dropped and the bundle \
1672 falls back to the default. Copy the V2 skill files into \
1673 <install>/shared/skills// to restore them.");
1674 }
1675 install_profile_entry(passthrough, "skill_bundles", &bundle_alias, bundle_entry);
1676 let existing = agent_table
1679 .remove("skill_bundles")
1680 .and_then(|v| match v {
1681 toml::Value::Array(a) => Some(a),
1682 _ => None,
1683 })
1684 .unwrap_or_default();
1685 let mut new_list = existing;
1686 let already = new_list
1687 .iter()
1688 .any(|v| v.as_str() == Some(bundle_alias.as_str()));
1689 if !already {
1690 new_list.push(toml::Value::String(bundle_alias.clone()));
1691 }
1692 agent_table.insert("skill_bundles".to_string(), toml::Value::Array(new_list));
1693 ::zeroclaw_log::record!(
1694 INFO,
1695 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1696 .with_attrs(
1697 ::serde_json::json!({"alias": alias, "bundle_alias": bundle_alias})
1698 ),
1699 "agents..skills_directory → [skill_bundles.] (referenced \
1700 from agents..skill_bundles)"
1701 );
1702 }
1703
1704 let agent_risk = agent_table
1709 .get("risk_profile")
1710 .and_then(toml::Value::as_str)
1711 .map(ToString::to_string)
1712 .filter(|s| !s.is_empty());
1713 let risk_alias = agent_risk.unwrap_or_else(|| "default".to_string());
1714 ensure_profile_entry(passthrough, "risk_profiles", &risk_alias);
1715 agent_table.insert("risk_profile".to_string(), toml::Value::String(risk_alias));
1716
1717 let agent_runtime = agent_table
1718 .get("runtime_profile")
1719 .and_then(toml::Value::as_str)
1720 .map(ToString::to_string)
1721 .filter(|s| !s.is_empty());
1722 let runtime_alias = agent_runtime.unwrap_or_else(|| "default".to_string());
1723 ensure_profile_entry(passthrough, "runtime_profiles", &runtime_alias);
1724 agent_table.insert(
1725 "runtime_profile".to_string(),
1726 toml::Value::String(runtime_alias),
1727 );
1728
1729 agent_table.remove("memory_namespace");
1735
1736 new_agents.insert(alias, toml::Value::Table(agent_table));
1737 }
1738 new_agents
1739}
1740
1741fn extract_agentic_timeout_secs(agent: &mut toml::Table) -> Option<toml::Value> {
1745 agent.remove("agentic_timeout_secs")
1746}
1747
1748fn extract_provider_timeout_secs(agent: &mut toml::Table) -> Option<toml::Value> {
1751 agent.remove("timeout_secs")
1752}
1753
1754fn install_profile_entry(
1757 passthrough: &mut toml::Table,
1758 section: &str,
1759 alias: &str,
1760 fields: toml::Table,
1761) {
1762 let section_value = passthrough
1763 .entry(section.to_string())
1764 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1765 if let Some(section_table) = section_value.as_table_mut() {
1766 let alias_value = section_table
1767 .entry(alias.to_string())
1768 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1769 if let Some(alias_table) = alias_value.as_table_mut() {
1770 for (k, v) in fields {
1771 alias_table.entry(k).or_insert(v);
1772 }
1773 }
1774 }
1775}
1776
1777fn merge_into_table(top: &mut toml::Table, section: &str, extras: toml::Table) {
1781 let entry = top
1782 .entry(section.to_string())
1783 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1784 if let Some(section_table) = entry.as_table_mut() {
1785 for (k, v) in extras {
1786 section_table.insert(k, v);
1787 }
1788 }
1789}
1790
1791fn fold_base_url_api_path_into_uri(entry: &mut toml::Table) {
1797 if entry.contains_key("uri") {
1798 entry.remove("base_url");
1801 entry.remove("api_path");
1802 return;
1803 }
1804 let base = match entry.remove("base_url") {
1805 Some(toml::Value::String(s)) if !s.is_empty() => s,
1806 _ => {
1807 entry.remove("api_path");
1809 return;
1810 }
1811 };
1812 let path = match entry.remove("api_path") {
1813 Some(toml::Value::String(p)) if !p.is_empty() => Some(p),
1814 _ => None,
1815 };
1816 let uri = match path {
1817 Some(p) => {
1818 let trimmed = base.trim_end_matches('/');
1819 let suffix = if p.starts_with('/') {
1820 p
1821 } else {
1822 format!("/{p}")
1823 };
1824 format!("{trimmed}{suffix}")
1825 }
1826 None => base,
1827 };
1828 entry.insert("uri".to_string(), toml::Value::String(uri));
1829}
1830
1831fn rewrite_dangling_peer_group_agents(passthrough: &mut toml::Table) {
1850 let replacement_alias = {
1851 let Some(agents_table) = passthrough.get("agents").and_then(toml::Value::as_table) else {
1852 return;
1853 };
1854 if agents_table.is_empty() || agents_table.contains_key("default") {
1855 return;
1856 }
1857 let Some(alias) = agents_table.keys().next().cloned() else {
1858 return;
1859 };
1860 alias
1861 };
1862
1863 let mut rewritten_channel_types: Vec<String> = Vec::new();
1864 {
1865 let Some(toml::Value::Table(peer_groups)) = passthrough.get_mut("peer_groups") else {
1866 return;
1867 };
1868 for (group_name, group_value) in peer_groups.iter_mut() {
1869 let Some(group_table) = group_value.as_table_mut() else {
1870 continue;
1871 };
1872 let Some(toml::Value::Array(agents_arr)) = group_table.get("agents") else {
1873 continue;
1874 };
1875 let only_default = agents_arr.len() == 1 && agents_arr[0].as_str() == Some("default");
1876 if !only_default {
1877 continue;
1878 }
1879 group_table.insert(
1880 "agents".to_string(),
1881 toml::Value::Array(vec![toml::Value::String(replacement_alias.clone())]),
1882 );
1883 if let Some(toml::Value::String(channel_ref)) = group_table.get("channel") {
1884 rewritten_channel_types.push(channel_ref.clone());
1885 }
1886 ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"group_name": group_name, "replacement_alias": format!("{:?}", replacement_alias)})), "peer_groups..agents rewritten from [\"default\"] to [] (no agents.default exists)");
1887 }
1888 }
1889
1890 if rewritten_channel_types.is_empty() {
1891 return;
1892 }
1893
1894 let mut resolved_refs: Vec<String> = Vec::new();
1900 if let Some(toml::Value::Table(channels_table)) = passthrough.get("channels") {
1901 for channel_type in &rewritten_channel_types {
1902 let aliases = channels_table
1903 .get(channel_type)
1904 .and_then(toml::Value::as_table)
1905 .map(|t| t.keys().cloned().collect::<Vec<_>>())
1906 .unwrap_or_default();
1907 for alias in aliases {
1908 let dotted = format!("{channel_type}.{alias}");
1909 if !resolved_refs.contains(&dotted) {
1910 resolved_refs.push(dotted);
1911 }
1912 }
1913 }
1914 }
1915 if resolved_refs.is_empty() {
1916 return;
1917 }
1918
1919 let Some(toml::Value::Table(agents_table)) = passthrough.get_mut("agents") else {
1920 return;
1921 };
1922 let Some(toml::Value::Table(agent_entry)) = agents_table.get_mut(&replacement_alias) else {
1923 return;
1924 };
1925 let channels_array = agent_entry
1926 .entry("channels".to_string())
1927 .or_insert_with(|| toml::Value::Array(Vec::new()));
1928 let Some(channels_arr) = channels_array.as_array_mut() else {
1929 return;
1930 };
1931 let mut added: Vec<String> = Vec::new();
1932 for ch in &resolved_refs {
1933 let present = channels_arr.iter().any(|v| v.as_str() == Some(ch.as_str()));
1934 if !present {
1935 channels_arr.push(toml::Value::String(ch.clone()));
1936 added.push(ch.clone());
1937 }
1938 }
1939 if !added.is_empty() {
1940 ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"replacement_alias": replacement_alias, "added": format!("{:?}", added)})), "agents..channels extended with so the rewritten peer_groups resolve");
1941 }
1942}
1943
1944fn backfill_heartbeat_agent(passthrough: &mut toml::Table) {
1950 let needs_backfill = passthrough
1951 .get("heartbeat")
1952 .and_then(toml::Value::as_table)
1953 .is_some_and(|hb| {
1954 let enabled = hb
1955 .get("enabled")
1956 .and_then(toml::Value::as_bool)
1957 .unwrap_or(false);
1958 let agent_set = hb
1959 .get("agent")
1960 .and_then(toml::Value::as_str)
1961 .is_some_and(|s| !s.trim().is_empty());
1962 enabled && !agent_set
1963 });
1964 if !needs_backfill {
1965 return;
1966 }
1967 let alias = passthrough
1968 .get("agents")
1969 .and_then(toml::Value::as_table)
1970 .and_then(|agents| {
1971 if agents.contains_key("default") {
1972 Some("default".to_string())
1973 } else {
1974 agents.keys().next().cloned()
1975 }
1976 });
1977 let Some(alias) = alias else {
1978 return;
1979 };
1980 if let Some(toml::Value::Table(hb)) = passthrough.get_mut("heartbeat") {
1981 hb.insert("agent".to_string(), toml::Value::String(alias.clone()));
1982 ::zeroclaw_log::record!(
1983 INFO,
1984 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1985 .with_attrs(::serde_json::json!({"alias": format!("{:?}", alias)})),
1986 &format!(
1987 "heartbeat.agent unset with heartbeat.enabled = true → backfilled to {alias:?}"
1988 )
1989 );
1990 }
1991}
1992
1993fn ensure_profile_entry(passthrough: &mut toml::Table, section: &str, alias: &str) {
1997 let entry = passthrough
1998 .entry(section.to_string())
1999 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2000 if let Some(section_table) = entry.as_table_mut() {
2001 section_table
2002 .entry(alias.to_string())
2003 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2004 }
2005}
2006
2007fn lift_top_level_identity_into_agents(passthrough: &mut toml::Table) {
2015 let Some(identity_value) = passthrough.remove("identity") else {
2016 return;
2017 };
2018 let Some(agents_value) = passthrough.get_mut("agents") else {
2019 ::zeroclaw_log::record!(
2020 WARN,
2021 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2022 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
2023 "[identity] dropped during V2->V3 (no [agents] table to attach to)"
2024 );
2025 return;
2026 };
2027 let Some(agents_table) = agents_value.as_table_mut() else {
2028 return;
2029 };
2030 if agents_table.is_empty() {
2031 ::zeroclaw_log::record!(
2032 WARN,
2033 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2034 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
2035 "[identity] dropped during V2->V3 (agents map empty after fold)"
2036 );
2037 return;
2038 }
2039 let aliases: Vec<String> = agents_table.keys().cloned().collect();
2040 let mut folded = 0usize;
2041 for alias in &aliases {
2042 let Some(agent_table) = agents_table
2043 .get_mut(alias)
2044 .and_then(toml::Value::as_table_mut)
2045 else {
2046 continue;
2047 };
2048 if agent_table.contains_key("identity") {
2049 continue;
2050 }
2051 agent_table.insert("identity".to_string(), identity_value.clone());
2052 folded += 1;
2053 }
2054 ::zeroclaw_log::record!(
2055 INFO,
2056 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2057 .with_attrs(::serde_json::json!({"folded": folded})),
2058 &format!("[identity] lifted into [agents.<alias>.identity] ({folded} agent(s))")
2059 );
2060}
2061
2062fn synthesize_default_agent_if_needed(passthrough: &toml::Table) -> toml::Table {
2071 let models = match passthrough
2074 .get("providers")
2075 .and_then(toml::Value::as_table)
2076 .and_then(|providers| providers.get("models"))
2077 .and_then(toml::Value::as_table)
2078 {
2079 Some(t) => t,
2080 None => return toml::Table::new(),
2081 };
2082 let first_alias = models.iter().find_map(|(provider_type, value)| {
2083 let inner = value.as_table()?;
2084 let alias = inner.keys().next()?;
2085 Some(format!("{provider_type}.{alias}"))
2086 });
2087 let alias_ref = match first_alias {
2088 Some(s) => s,
2089 None => return toml::Table::new(),
2090 };
2091
2092 let mut default_agent = toml::Table::new();
2093 default_agent.insert("model_provider".to_string(), toml::Value::String(alias_ref));
2094 default_agent.insert(
2095 "risk_profile".to_string(),
2096 toml::Value::String("default".into()),
2097 );
2098 default_agent.insert(
2099 "runtime_profile".to_string(),
2100 toml::Value::String("default".into()),
2101 );
2102
2103 let mut agents = toml::Table::new();
2104 agents.insert("default".to_string(), toml::Value::Table(default_agent));
2105 ::zeroclaw_log::record!(
2106 INFO,
2107 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2108 "synthesized [agents.default] from V1/V2 implicit single-agent semantics"
2109 );
2110 agents
2111}
2112
2113const V3_TTS_TYPES: &[&str] = &["openai", "elevenlabs", "google", "edge", "piper"];
2116
2117fn fold_v2_tts_into_providers(passthrough: &mut toml::Table, new_providers: &mut toml::Table) {
2127 let Some(toml::Value::Table(tts_table)) = passthrough.get_mut("tts") else {
2128 return;
2129 };
2130
2131 let mut tts_aliased = toml::Table::new();
2132 for ty in V3_TTS_TYPES {
2133 if let Some(mut value) = tts_table.remove(*ty) {
2134 if *ty == "elevenlabs"
2138 && let Some(t) = value.as_table_mut()
2139 && let Some(v) = t.remove("model_id")
2140 {
2141 t.entry("model".to_string()).or_insert(v);
2142 ::zeroclaw_log::record!(
2143 INFO,
2144 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2145 "tts.elevenlabs.model_id renamed to tts.elevenlabs.model"
2146 );
2147 }
2148 let mut wrapped = toml::Table::new();
2149 wrapped.insert("default".to_string(), value);
2150 tts_aliased.insert((*ty).to_string(), toml::Value::Table(wrapped));
2151 }
2152 }
2153
2154 if tts_table.remove("default_provider").is_some() {
2155 ::zeroclaw_log::record!(
2156 INFO,
2157 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2158 "[tts].default_provider dropped (V3 has no global default-provider; set agent.<X>.tts_provider instead)"
2159 );
2160 }
2161
2162 if !tts_aliased.is_empty() {
2163 new_providers.insert("tts".to_string(), toml::Value::Table(tts_aliased));
2164 ::zeroclaw_log::record!(
2165 INFO,
2166 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2167 "[tts.<type>] sub-blocks promoted to [tts_providers.<type>.default]"
2168 );
2169 }
2170}
2171
2172fn fold_v2_transcription_into_providers(
2186 passthrough: &mut toml::Table,
2187 new_providers: &mut toml::Table,
2188) {
2189 let Some(toml::Value::Table(transcription_table)) = passthrough.get_mut("transcription") else {
2190 return;
2191 };
2192
2193 let mut transcription_aliased = toml::Table::new();
2194
2195 const V3_TRANSCRIPTION_FAMILIES: &[&str] = &[
2197 "openai",
2198 "deepgram",
2199 "assemblyai",
2200 "google",
2201 "local_whisper",
2202 ];
2203 for family in V3_TRANSCRIPTION_FAMILIES {
2204 if let Some(value) = transcription_table.remove(*family) {
2205 let mut wrapped = toml::Table::new();
2206 wrapped.insert("default".to_string(), value);
2207 transcription_aliased.insert((*family).to_string(), toml::Value::Table(wrapped));
2208 }
2209 }
2210
2211 let mut groq_entry = toml::Table::new();
2217 for groq_field in &["api_key", "api_url", "model", "language", "initial_prompt"] {
2218 if let Some(v) = transcription_table.remove(*groq_field) {
2219 groq_entry.insert((*groq_field).to_string(), v);
2220 }
2221 }
2222 if !groq_entry.is_empty() {
2223 let mut wrapped = toml::Table::new();
2224 wrapped.insert("default".to_string(), toml::Value::Table(groq_entry));
2225 transcription_aliased.insert("groq".to_string(), toml::Value::Table(wrapped));
2226 ::zeroclaw_log::record!(
2227 INFO,
2228 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2229 "[transcription] Groq fields promoted to [transcription_providers.groq.default]"
2230 );
2231 }
2232
2233 for legacy_default in &[
2237 "default_provider",
2238 "default_model_provider",
2239 "default_transcription_provider",
2240 ] {
2241 if transcription_table.remove(*legacy_default).is_some() {
2242 ::zeroclaw_log::record!(
2243 INFO,
2244 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2245 .with_attrs(::serde_json::json!({"legacy_default": legacy_default})),
2246 &format!(
2247 "[transcription].{legacy_default} dropped (V3 has no global default-provider; set agent.<X>.transcription_provider instead)"
2248 )
2249 );
2250 }
2251 }
2252
2253 if !transcription_aliased.is_empty() {
2254 let providers_transcription = new_providers
2257 .entry("transcription".to_string())
2258 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2259 if let Some(existing) = providers_transcription.as_table_mut() {
2260 for (family, value) in transcription_aliased {
2261 existing.entry(family).or_insert(value);
2262 }
2263 }
2264 ::zeroclaw_log::record!(
2265 INFO,
2266 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2267 "[transcription.<family>] sub-blocks promoted to [transcription_providers.<family>.default]"
2268 );
2269 }
2270}
2271
2272fn rename_route_provider_field(new_providers: &mut toml::Table, routes_key: &str) {
2278 let Some(toml::Value::Array(routes)) = new_providers.get_mut(routes_key) else {
2279 return;
2280 };
2281 let mut renamed = 0usize;
2282 let mut promoted = 0usize;
2283 for entry in routes.iter_mut() {
2284 let toml::Value::Table(t) = entry else {
2285 continue;
2286 };
2287 if t.contains_key("model_provider") {
2288 t.remove("provider");
2292 } else if let Some(value) = t.remove("provider") {
2293 t.insert("model_provider".to_string(), value);
2294 renamed += 1;
2295 }
2296 if let Some(toml::Value::String(s)) = t.get_mut("model_provider")
2301 && !s.is_empty()
2302 && !s.contains('.')
2303 {
2304 *s = format!("{s}.default");
2305 promoted += 1;
2306 }
2307 }
2308 if renamed > 0 {
2309 ::zeroclaw_log::record!(
2310 INFO,
2311 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2312 .with_attrs(::serde_json::json!({"routes_key": routes_key, "renamed": renamed})),
2313 "[providers.] entry/entries: `provider` field renamed to `model_provider`"
2314 );
2315 }
2316 if promoted > 0 {
2317 ::zeroclaw_log::record!(
2318 INFO,
2319 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2320 .with_attrs(::serde_json::json!({"routes_key": routes_key, "promoted": promoted})),
2321 "[providers.] entry/entries: bare `model_provider` promoted to dotted `<type>.default` form"
2322 );
2323 }
2324}
2325
2326fn fold_v2_storage_subsystems(passthrough: &mut toml::Table) {
2344 let (memory_qdrant, memory_postgres, memory_sqlite_timeout) = match passthrough
2345 .get_mut("memory")
2346 .and_then(toml::Value::as_table_mut)
2347 {
2348 Some(memory) => (
2349 memory.remove("qdrant"),
2350 memory.remove("postgres"),
2351 memory.remove("sqlite_open_timeout_secs"),
2352 ),
2353 None => (None, None, None),
2354 };
2355
2356 let storage_provider = match passthrough
2357 .get_mut("storage")
2358 .and_then(toml::Value::as_table_mut)
2359 {
2360 Some(storage) => storage.remove("provider"),
2361 None => None,
2362 };
2363
2364 if memory_qdrant.is_none()
2365 && memory_postgres.is_none()
2366 && memory_sqlite_timeout.is_none()
2367 && storage_provider.is_none()
2368 {
2369 return;
2370 }
2371
2372 let storage_entry = passthrough
2373 .entry("storage".to_string())
2374 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2375 let Some(storage_table) = storage_entry.as_table_mut() else {
2376 return;
2377 };
2378
2379 if let Some(toml::Value::Table(qdrant_data)) = memory_qdrant {
2380 merge_storage_default(storage_table, "qdrant", qdrant_data);
2381 ::zeroclaw_log::record!(
2382 INFO,
2383 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2384 "[memory.qdrant] promoted to [storage.qdrant.default]"
2385 );
2386 }
2387 if let Some(timeout_value) = memory_sqlite_timeout {
2388 let mut sqlite_fields = toml::Table::new();
2389 sqlite_fields.insert("open_timeout_secs".to_string(), timeout_value);
2390 merge_storage_default(storage_table, "sqlite", sqlite_fields);
2391 ::zeroclaw_log::record!(
2392 INFO,
2393 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2394 "memory.sqlite_open_timeout_secs → [storage.sqlite.default].open_timeout_secs"
2395 );
2396 }
2397 if let Some(toml::Value::Table(postgres_vector_data)) = memory_postgres {
2398 merge_storage_default(storage_table, "postgres", postgres_vector_data);
2399 ::zeroclaw_log::record!(
2400 INFO,
2401 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2402 "[memory.postgres] vector fields promoted to [storage.postgres.default]"
2403 );
2404 }
2405
2406 if let Some(provider_section_value) = storage_provider {
2407 let config_table = match provider_section_value {
2412 toml::Value::Table(mut section) => {
2413 if let Some(toml::Value::Table(inner)) = section.remove("config") {
2414 inner
2415 } else {
2416 section
2417 }
2418 }
2419 _ => {
2420 drop_empty_subsystem_blocks(passthrough);
2421 return;
2422 }
2423 };
2424 if config_table.is_empty() {
2425 drop_empty_subsystem_blocks(passthrough);
2426 return;
2427 }
2428
2429 let (provider_type, mut adapted_fields) = adapt_storage_provider_config(config_table);
2430 if !adapted_fields.is_empty() {
2431 merge_storage_default(
2435 storage_table,
2436 &provider_type,
2437 std::mem::take(&mut adapted_fields),
2438 );
2439 ::zeroclaw_log::record!(
2440 INFO,
2441 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2442 .with_attrs(::serde_json::json!({"provider_type": provider_type})),
2443 "[storage.provider.config provider=] promoted to [storage..default]"
2444 );
2445 }
2446 }
2447
2448 drop_empty_subsystem_blocks(passthrough);
2449}
2450
2451fn drop_empty_subsystem_blocks(passthrough: &mut toml::Table) {
2457 for key in ["memory", "storage"] {
2458 if let Some(toml::Value::Table(t)) = passthrough.get(key)
2459 && t.is_empty()
2460 {
2461 passthrough.remove(key);
2462 }
2463 }
2464}
2465
2466fn adapt_storage_provider_config(mut config: toml::Table) -> (String, toml::Table) {
2470 let provider_type = config
2471 .remove("provider")
2472 .and_then(|v| match v {
2473 toml::Value::String(s) if !s.is_empty() => Some(s),
2474 _ => None,
2475 })
2476 .unwrap_or_else(|| "sqlite".to_string());
2477
2478 match provider_type.as_str() {
2479 "sqlite" => {
2480 let mut out = toml::Table::new();
2481 if let Some(toml::Value::String(db_url)) = config.remove("db_url") {
2483 let path = db_url
2484 .strip_prefix("sqlite://")
2485 .or_else(|| db_url.strip_prefix("sqlite:"))
2486 .map(ToString::to_string)
2487 .unwrap_or(db_url);
2488 if !path.is_empty() {
2489 out.insert("path".to_string(), toml::Value::String(path));
2490 }
2491 }
2492 if let Some(v) = config.remove("connect_timeout_secs") {
2494 out.insert("open_timeout_secs".to_string(), v);
2495 }
2496 (provider_type, out)
2498 }
2499 "postgres" => {
2500 (provider_type, config)
2502 }
2503 "qdrant" => {
2504 let mut out = toml::Table::new();
2505 if let Some(v) = config.remove("db_url") {
2506 out.insert("url".to_string(), v);
2507 }
2508 (provider_type, out)
2510 }
2511 _ => {
2512 ::zeroclaw_log::record!(
2513 INFO,
2514 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2515 .with_attrs(
2516 ::serde_json::json!({"provider_type": format!("{:?}", provider_type)})
2517 ),
2518 "[storage.provider.config] unknown provider type ; passthrough as-is"
2519 );
2520 (provider_type, config)
2521 }
2522 }
2523}
2524
2525fn merge_storage_default(storage_table: &mut toml::Table, backend_type: &str, fields: toml::Table) {
2528 let backend_entry = storage_table
2529 .entry(backend_type.to_string())
2530 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2531 if let Some(backend_table) = backend_entry.as_table_mut() {
2532 let default_entry = backend_table
2533 .entry("default".to_string())
2534 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2535 if let Some(default_table) = default_entry.as_table_mut() {
2536 for (k, v) in fields {
2537 default_table.entry(k).or_insert(v);
2538 }
2539 }
2540 }
2541}
2542
2543fn fold_security_into_risk_profile(passthrough: &mut toml::Table) {
2561 let (sandbox, resources) = {
2562 let security_table = match passthrough
2563 .get_mut("security")
2564 .and_then(toml::Value::as_table_mut)
2565 {
2566 Some(t) => t,
2567 None => return,
2568 };
2569 (
2570 security_table.remove("sandbox"),
2571 security_table.remove("resources"),
2572 )
2573 };
2574 if sandbox.is_none() && resources.is_none() {
2575 return;
2576 }
2577
2578 if let Some(toml::Value::Table(resources_table)) = resources
2579 && !resources_table.is_empty()
2580 {
2581 let dropped: Vec<String> = resources_table
2582 .iter()
2583 .map(|(k, v)| format!("{k}={v}"))
2584 .collect();
2585 ::zeroclaw_log::record!(
2586 WARN,
2587 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2588 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
2589 &format!(
2590 "[security.resources] dropped during V2→V3 migration (no V3 enforcement \
2591 codepath existed; sandbox backends own resource budgets): {}",
2592 dropped.join(", ")
2593 )
2594 );
2595 }
2596
2597 let Some(toml::Value::Table(sandbox_table)) = sandbox else {
2598 return;
2599 };
2600 if sandbox_table.is_empty() {
2601 return;
2602 }
2603
2604 let risk_profiles = passthrough
2605 .entry("risk_profiles".to_string())
2606 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2607 let Some(risk_profiles_table) = risk_profiles.as_table_mut() else {
2608 return;
2609 };
2610 let default_entry = risk_profiles_table
2611 .entry("default".to_string())
2612 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2613 let Some(default_profile) = default_entry.as_table_mut() else {
2614 return;
2615 };
2616
2617 for (k, v) in sandbox_table {
2618 let target_key = match k.as_str() {
2619 "enabled" => "sandbox_enabled",
2620 "backend" => "sandbox_backend",
2621 "firejail_args" => "firejail_args",
2622 _ => continue,
2623 };
2624 default_profile.entry(target_key.to_string()).or_insert(v);
2625 }
2626 ::zeroclaw_log::record!(
2627 INFO,
2628 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2629 "[security.sandbox] folded into [risk_profiles.default]"
2630 );
2631}
2632
2633fn split_autonomy_into_profile_buckets(
2642 value: toml::Value,
2643) -> (Option<toml::Table>, Option<toml::Table>) {
2644 let Ok(table) = value.try_into::<toml::Table>() else {
2645 return (None, None);
2646 };
2647 const RUNTIME_FIELDS: &[&str] = &[
2648 "max_actions_per_hour",
2649 "max_cost_per_day_cents",
2650 "shell_timeout_secs",
2651 "max_delegation_depth",
2652 "delegation_timeout_secs",
2653 "agentic_timeout_secs",
2654 ];
2655 let mut risk = toml::Table::new();
2656 let mut runtime = toml::Table::new();
2657 for (k, v) in table {
2658 if RUNTIME_FIELDS.contains(&k.as_str()) {
2659 runtime.insert(k, v);
2660 } else {
2661 risk.insert(k, v);
2662 }
2663 }
2664 let risk = (!risk.is_empty()).then_some(risk);
2665 let runtime = (!runtime.is_empty()).then_some(runtime);
2666 (risk, runtime)
2667}
2668
2669fn merge_into_profile_default(profiles: &mut toml::Table, fields: toml::Table) {
2672 let default_entry = profiles
2673 .entry("default".to_string())
2674 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2675 let Some(default_table) = default_entry.as_table_mut() else {
2676 return;
2677 };
2678 for (k, v) in fields {
2679 default_table.entry(k).or_insert(v);
2680 }
2681}
2682
2683fn rename_table_keys(value: toml::Value, renames: &[(&str, &str)]) -> toml::Value {
2687 let mut table = match value {
2688 toml::Value::Table(t) => t,
2689 other => return other,
2690 };
2691 for (old, new) in renames {
2692 if let Some(v) = table.remove(*old)
2693 && !table.contains_key(*new)
2694 {
2695 table.insert((*new).to_string(), v);
2696 }
2697 }
2698 toml::Value::Table(table)
2699}
2700
2701fn slugify(s: &str) -> String {
2703 let mut out = String::with_capacity(s.len());
2704 let mut prev_underscore = false;
2705 for c in s.chars() {
2706 if c.is_alphanumeric() {
2707 out.push(c.to_ascii_lowercase());
2708 prev_underscore = false;
2709 } else if !prev_underscore {
2710 out.push('_');
2711 prev_underscore = true;
2712 }
2713 }
2714 out.trim_matches('_').to_string()
2715}
2716
2717fn ensure_unique_key(existing: &toml::Table, key: String) -> String {
2719 if !existing.contains_key(&key) {
2720 return key;
2721 }
2722 let mut n = 2;
2723 loop {
2724 let candidate = format!("{key}_{n}");
2725 if !existing.contains_key(&candidate) {
2726 return candidate;
2727 }
2728 n += 1;
2729 }
2730}
2731
2732use anyhow::{Context as MigContext, Result as MigResult};
2742use rusqlite::{Connection, OptionalExtension, params};
2743use std::path::{Path, PathBuf};
2744
2745#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2748pub enum V2WorkspaceDest {
2749 DataDir,
2751 SharedDir,
2753 AgentDefault,
2755 MemorySubentryDispatch,
2760}
2761
2762pub const V2_WORKSPACE_TOPLEVEL_DISPATCH: &[(&str, V2WorkspaceDest)] = &[
2770 ("memory", V2WorkspaceDest::MemorySubentryDispatch),
2771 ("sessions", V2WorkspaceDest::DataDir),
2772 ("state", V2WorkspaceDest::DataDir),
2773 ("skills", V2WorkspaceDest::SharedDir),
2774 ("devices.db", V2WorkspaceDest::DataDir),
2779];
2780
2781pub const V2_MEMORY_DATA_NAMES: &[&str] = &[
2789 "brain.db",
2790 "audit.db",
2791 "response_cache.db",
2792 "MEMORY_SNAPSHOT.md",
2793 "archive",
2794];
2795
2796pub const V3_INSTALL_ROOT_NAMES: &[&str] = &["data", "shared", "agents"];
2800
2801pub fn v2_workspace_toplevel_dest(name: &str) -> V2WorkspaceDest {
2803 V2_WORKSPACE_TOPLEVEL_DISPATCH
2804 .iter()
2805 .copied()
2806 .find(|(n, _)| *n == name)
2807 .map(|(_, d)| d)
2808 .unwrap_or(V2WorkspaceDest::AgentDefault)
2809}
2810
2811pub fn workspace_toplevel_v3_path(install: &Path, name: &str) -> PathBuf {
2817 match v2_workspace_toplevel_dest(name) {
2818 V2WorkspaceDest::DataDir | V2WorkspaceDest::MemorySubentryDispatch => {
2819 install.join("data").join(name)
2820 }
2821 V2WorkspaceDest::SharedDir => install.join("shared").join(name),
2822 V2WorkspaceDest::AgentDefault => install
2823 .join("agents")
2824 .join("default")
2825 .join("workspace")
2826 .join(name),
2827 }
2828}
2829
2830pub fn memory_subentry_v3_path(install: &Path, sub_name: &str) -> PathBuf {
2832 if V2_MEMORY_DATA_NAMES.contains(&sub_name) {
2833 install.join("data").join("memory").join(sub_name)
2834 } else {
2835 install
2836 .join("agents")
2837 .join("default")
2838 .join("workspace")
2839 .join("memory")
2840 .join(sub_name)
2841 }
2842}
2843
2844#[derive(Debug, Clone)]
2846pub struct FilesystemMigrationReport {
2847 pub backup_dir: Option<PathBuf>,
2850 pub entries_relocated: usize,
2852}
2853
2854pub fn migrate_v2_to_v3_install_filesystem(
2870 install_root: &Path,
2871) -> MigResult<FilesystemMigrationReport> {
2872 let legacy = install_root.join("workspace");
2873 let agent_default = install_root
2874 .join("agents")
2875 .join("default")
2876 .join("workspace");
2877
2878 if !legacy.is_dir() {
2879 relocate_default_agent_skills_to_shared(install_root)?;
2880 return Ok(FilesystemMigrationReport {
2881 backup_dir: None,
2882 entries_relocated: 0,
2883 });
2884 }
2885
2886 let data_target = install_root.join("data");
2887 let data_populated = data_target
2888 .is_dir()
2889 .then(|| std::fs::read_dir(&data_target).ok())
2890 .flatten()
2891 .is_some_and(|mut it| it.next().is_some());
2892 let agent_populated = agent_default
2893 .is_dir()
2894 .then(|| std::fs::read_dir(&agent_default).ok())
2895 .flatten()
2896 .is_some_and(|mut it| it.next().is_some());
2897 if data_populated && agent_populated {
2898 ::zeroclaw_log::record!(
2899 INFO,
2900 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
2901 ::serde_json::json!({
2902 "data_target": data_target.display().to_string(),
2903 "agent_target": agent_default.display().to_string(),
2904 "legacy": legacy.display().to_string(),
2905 })
2906 ),
2907 "[system] filesystem migration: targets already populated; skipping split"
2908 );
2909 relocate_default_agent_skills_to_shared(install_root)?;
2910 return Ok(FilesystemMigrationReport {
2911 backup_dir: None,
2912 entries_relocated: 0,
2913 });
2914 }
2915
2916 let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%S").to_string();
2917 let backup_dir = install_root
2918 .join(format!("backup-{timestamp}"))
2919 .join("legacy-workspace");
2920 std::fs::create_dir_all(&backup_dir).with_context(|| {
2921 format!(
2922 "[system] failed to create migration backup dir at {}",
2923 backup_dir.display()
2924 )
2925 })?;
2926 copy_dir_recursive(&legacy, &backup_dir).with_context(|| {
2927 format!(
2928 "[system] failed to back up legacy workspace from {} to {}",
2929 legacy.display(),
2930 backup_dir.display()
2931 )
2932 })?;
2933 ::zeroclaw_log::record!(
2934 INFO,
2935 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
2936 ::serde_json::json!({
2937 "backup": backup_dir.display().to_string(),
2938 })
2939 ),
2940 "[system] filesystem migration: legacy workspace backed up"
2941 );
2942
2943 let entries_relocated = relocate_workspace_toplevel(&legacy, install_root, &backup_dir)
2944 .with_context(|| {
2945 format!(
2946 "[system] failed during workspace top-level relocation under {}",
2947 install_root.display()
2948 )
2949 })?;
2950
2951 if std::fs::read_dir(&legacy)
2952 .map(|mut it| it.next().is_none())
2953 .unwrap_or(false)
2954 {
2955 let _ = std::fs::remove_dir(&legacy);
2956 }
2957
2958 relocate_default_agent_skills_to_shared(install_root)?;
2959
2960 ::zeroclaw_log::record!(
2961 INFO,
2962 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
2963 ::serde_json::json!({
2964 "backup": backup_dir.display().to_string(),
2965 "entries_relocated": entries_relocated,
2966 })
2967 ),
2968 "[system] filesystem migration: legacy workspace split into V3 layout"
2969 );
2970
2971 Ok(FilesystemMigrationReport {
2972 backup_dir: Some(backup_dir.parent().unwrap_or(&backup_dir).to_path_buf()),
2973 entries_relocated,
2974 })
2975}
2976
2977fn relocate_workspace_toplevel(
2981 legacy: &Path,
2982 install_root: &Path,
2983 backup_dir: &Path,
2984) -> MigResult<usize> {
2985 let mut count = 0usize;
2986 for entry in std::fs::read_dir(legacy).with_context(|| {
2987 format!(
2988 "[system] failed to enumerate legacy workspace at {}",
2989 legacy.display()
2990 )
2991 })? {
2992 let entry = entry?;
2993 let name = entry.file_name();
2994 let Some(name_str) = name.to_str() else {
2995 ::zeroclaw_log::record!(
2996 WARN,
2997 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2998 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2999 .with_attrs(::serde_json::json!({
3000 "legacy": legacy.display().to_string(),
3001 })),
3002 "[system] filesystem migration: skipping non-UTF-8 entry"
3003 );
3004 continue;
3005 };
3006 let src = entry.path();
3007
3008 match v2_workspace_toplevel_dest(name_str) {
3009 V2WorkspaceDest::MemorySubentryDispatch => {
3010 count += relocate_memory_subentries(&src, install_root, backup_dir)?;
3011 }
3012 _ => {
3013 let dst = workspace_toplevel_v3_path(install_root, name_str);
3014 if move_with_refuse_to_clobber(&src, &dst)? {
3015 count += 1;
3016 }
3017 }
3018 }
3019 }
3020 Ok(count)
3021}
3022
3023fn relocate_memory_subentries(
3026 legacy_memory_dir: &Path,
3027 install_root: &Path,
3028 _backup_dir: &Path,
3029) -> MigResult<usize> {
3030 if !legacy_memory_dir.is_dir() {
3031 return Ok(0);
3032 }
3033 let mut count = 0usize;
3034 for entry in std::fs::read_dir(legacy_memory_dir).with_context(|| {
3035 format!(
3036 "[system] failed to enumerate {} during memory sub-dispatch",
3037 legacy_memory_dir.display()
3038 )
3039 })? {
3040 let entry = entry?;
3041 let name = entry.file_name();
3042 let Some(name_str) = name.to_str() else {
3043 ::zeroclaw_log::record!(
3044 WARN,
3045 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3046 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
3047 .with_attrs(::serde_json::json!({
3048 "legacy_memory_dir": legacy_memory_dir.display().to_string(),
3049 })),
3050 "[system] filesystem migration: skipping non-UTF-8 entry under memory"
3051 );
3052 continue;
3053 };
3054 let src = entry.path();
3055 let dst = memory_subentry_v3_path(install_root, name_str);
3056 if move_with_refuse_to_clobber(&src, &dst)? {
3057 count += 1;
3058 }
3059 }
3060 if std::fs::read_dir(legacy_memory_dir)
3062 .map(|mut it| it.next().is_none())
3063 .unwrap_or(false)
3064 {
3065 let _ = std::fs::remove_dir(legacy_memory_dir);
3066 }
3067 Ok(count)
3068}
3069
3070fn move_with_refuse_to_clobber(src: &Path, dst: &Path) -> MigResult<bool> {
3076 if dst.exists() {
3077 ::zeroclaw_log::record!(
3078 WARN,
3079 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3080 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
3081 .with_attrs(::serde_json::json!({
3082 "source": src.display().to_string(),
3083 "target": dst.display().to_string(),
3084 })),
3085 "[system] filesystem migration: target already exists; refusing to clobber"
3086 );
3087 return Ok(false);
3088 }
3089 if let Some(parent) = dst.parent() {
3090 std::fs::create_dir_all(parent).with_context(|| {
3091 format!("[system] failed to create parent dir {}", parent.display())
3092 })?;
3093 }
3094 if std::fs::rename(src, dst).is_ok() {
3095 return Ok(true);
3096 }
3097 if src.is_dir() {
3099 copy_dir_recursive(src, dst).with_context(|| {
3100 format!(
3101 "[system] failed to copy {} to {}",
3102 src.display(),
3103 dst.display()
3104 )
3105 })?;
3106 std::fs::remove_dir_all(src)
3107 .with_context(|| format!("[system] failed to remove {} after copy", src.display()))?;
3108 } else {
3109 std::fs::copy(src, dst).with_context(|| {
3110 format!(
3111 "[system] failed to copy {} to {}",
3112 src.display(),
3113 dst.display()
3114 )
3115 })?;
3116 std::fs::remove_file(src)
3117 .with_context(|| format!("[system] failed to remove {} after copy", src.display()))?;
3118 }
3119 Ok(true)
3120}
3121
3122pub fn relocate_default_agent_skills_to_shared(install_root: &Path) -> MigResult<bool> {
3126 let src = install_root
3127 .join("agents")
3128 .join("default")
3129 .join("workspace")
3130 .join("skills");
3131 let dst = install_root.join("shared").join("skills");
3132 if !src.is_dir() {
3133 return Ok(false);
3134 }
3135 let dst_populated = dst
3136 .is_dir()
3137 .then(|| std::fs::read_dir(&dst).ok())
3138 .flatten()
3139 .is_some_and(|mut it| it.next().is_some());
3140 if dst_populated {
3141 return Ok(false);
3142 }
3143 if let Some(parent) = dst.parent() {
3144 std::fs::create_dir_all(parent).with_context(|| {
3145 format!(
3146 "[system] failed to create shared workspace parent {}",
3147 parent.display()
3148 )
3149 })?;
3150 }
3151 if std::fs::rename(&src, &dst).is_err() {
3152 copy_dir_recursive(&src, &dst).with_context(|| {
3153 format!(
3154 "[system] failed to copy {} to {}",
3155 src.display(),
3156 dst.display()
3157 )
3158 })?;
3159 std::fs::remove_dir_all(&src)
3160 .with_context(|| format!("[system] failed to remove {} after copy", src.display()))?;
3161 }
3162 ::zeroclaw_log::record!(
3163 INFO,
3164 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
3165 ::serde_json::json!({
3166 "from": src.display().to_string(),
3167 "to": dst.display().to_string(),
3168 })
3169 ),
3170 "[system] filesystem migration: lifted default-agent skills into shared/"
3171 );
3172 Ok(true)
3173}
3174
3175fn copy_dir_recursive(src: &Path, dst: &Path) -> MigResult<()> {
3176 std::fs::create_dir_all(dst)?;
3177 for entry in std::fs::read_dir(src)? {
3178 let entry = entry?;
3179 let from = entry.path();
3180 let to = dst.join(entry.file_name());
3181 let ft = entry.file_type()?;
3182 if ft.is_dir() {
3183 copy_dir_recursive(&from, &to)?;
3184 } else if ft.is_symlink() {
3185 #[cfg(unix)]
3186 {
3187 let target = std::fs::read_link(&from)?;
3188 std::os::unix::fs::symlink(&target, &to)?;
3189 }
3190 #[cfg(not(unix))]
3191 {
3192 std::fs::copy(&from, &to)?;
3193 }
3194 } else {
3195 std::fs::copy(&from, &to)?;
3196 }
3197 }
3198 Ok(())
3199}
3200
3201pub const SQLITE_MEMORY_SCHEMA_VERSION: i64 = 1;
3209
3210pub fn migrate_sqlite_memory_to_v3(db_path: &Path, conn: &Connection) -> MigResult<()> {
3223 if sqlite_memories_agent_id_is_not_null(conn)? {
3224 return Ok(());
3225 }
3226
3227 if sqlite_memories_row_count(conn)? > 0 && db_path.exists() {
3228 backup_sqlite_for_multi_agent_migration(db_path)?;
3229 }
3230
3231 conn.execute_batch("BEGIN IMMEDIATE; PRAGMA defer_foreign_keys = ON;")?;
3232 let result = (|| -> MigResult<()> {
3233 conn.execute_batch(
3234 "CREATE TABLE IF NOT EXISTS agents (
3235 id TEXT PRIMARY KEY,
3236 alias TEXT NOT NULL UNIQUE,
3237 created_at TEXT NOT NULL
3238 );",
3239 )?;
3240 let default_uuid = sqlite_ensure_default_agent_uuid(conn)?;
3241
3242 if !sqlite_memories_has_agent_id_column(conn)? {
3243 conn.execute_batch("ALTER TABLE memories ADD COLUMN agent_id TEXT;")?;
3244 }
3245 conn.execute(
3246 "UPDATE memories SET agent_id = ?1 WHERE agent_id IS NULL",
3247 params![default_uuid],
3248 )?;
3249
3250 conn.execute_batch(
3251 "DROP TRIGGER IF EXISTS memories_ai;
3252 DROP TRIGGER IF EXISTS memories_ad;
3253 DROP TRIGGER IF EXISTS memories_au;
3254 DROP TABLE IF EXISTS memories_fts;
3255
3256 CREATE TABLE memories_new (
3257 id TEXT PRIMARY KEY,
3258 key TEXT NOT NULL,
3259 content TEXT NOT NULL,
3260 category TEXT NOT NULL DEFAULT 'core',
3261 embedding BLOB,
3262 created_at TEXT NOT NULL,
3263 updated_at TEXT NOT NULL,
3264 session_id TEXT,
3265 namespace TEXT DEFAULT 'default',
3266 importance REAL DEFAULT 0.5,
3267 superseded_by TEXT,
3268 agent_id TEXT NOT NULL REFERENCES agents(id),
3269 UNIQUE (agent_id, key)
3270 );
3271
3272 INSERT INTO memories_new (
3273 id, key, content, category, embedding, created_at, updated_at,
3274 session_id, namespace, importance, superseded_by, agent_id
3275 )
3276 SELECT
3277 id, key, content, category, embedding, created_at, updated_at,
3278 session_id, namespace, importance, superseded_by, agent_id
3279 FROM memories;
3280
3281 DROP TABLE memories;
3282 ALTER TABLE memories_new RENAME TO memories;
3283
3284 CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(category);
3285 CREATE INDEX IF NOT EXISTS idx_memories_key ON memories(key);
3286 CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id);
3287 CREATE INDEX IF NOT EXISTS idx_memories_namespace ON memories(namespace);
3288 CREATE INDEX IF NOT EXISTS idx_memories_agent_id ON memories(agent_id);
3289
3290 CREATE VIRTUAL TABLE memories_fts USING fts5(
3291 key, content, content=memories, content_rowid=rowid
3292 );
3293 INSERT INTO memories_fts(memories_fts) VALUES('rebuild');
3294
3295 CREATE TRIGGER memories_ai AFTER INSERT ON memories BEGIN
3296 INSERT INTO memories_fts(rowid, key, content)
3297 VALUES (new.rowid, new.key, new.content);
3298 END;
3299 CREATE TRIGGER memories_ad AFTER DELETE ON memories BEGIN
3300 INSERT INTO memories_fts(memories_fts, rowid, key, content)
3301 VALUES ('delete', old.rowid, old.key, old.content);
3302 END;
3303 CREATE TRIGGER memories_au AFTER UPDATE ON memories BEGIN
3304 INSERT INTO memories_fts(memories_fts, rowid, key, content)
3305 VALUES ('delete', old.rowid, old.key, old.content);
3306 INSERT INTO memories_fts(rowid, key, content)
3307 VALUES (new.rowid, new.key, new.content);
3308 END;",
3309 )?;
3310
3311 sqlite_ensure_schema_version_table(conn)?;
3312 conn.execute(
3313 "INSERT OR REPLACE INTO schema_version (component, version, applied_at) \
3314 VALUES ('memories', ?1, ?2)",
3315 params![
3316 SQLITE_MEMORY_SCHEMA_VERSION,
3317 chrono::Utc::now().to_rfc3339()
3318 ],
3319 )?;
3320 Ok(())
3321 })();
3322
3323 match result {
3324 Ok(()) => {
3325 conn.execute_batch("COMMIT;")?;
3326 Ok(())
3327 }
3328 Err(e) => {
3329 let _ = conn.execute_batch("ROLLBACK;");
3330 Err(e)
3331 }
3332 }
3333}
3334
3335fn sqlite_ensure_schema_version_table(conn: &Connection) -> MigResult<()> {
3336 conn.execute_batch(
3337 "CREATE TABLE IF NOT EXISTS schema_version (
3338 component TEXT PRIMARY KEY,
3339 version INTEGER NOT NULL,
3340 applied_at TEXT NOT NULL
3341 );",
3342 )?;
3343 Ok(())
3344}
3345
3346fn sqlite_memories_agent_id_is_not_null(conn: &Connection) -> MigResult<bool> {
3347 let mut stmt = conn.prepare("PRAGMA table_info(memories)")?;
3348 let agent_id_notnull: Option<bool> = stmt
3349 .query_map([], |row| {
3350 let name: String = row.get(1)?;
3351 let notnull: i64 = row.get(3)?;
3352 Ok((name, notnull != 0))
3353 })?
3354 .filter_map(Result::ok)
3355 .find(|(name, _)| name == "agent_id")
3356 .map(|(_, notnull)| notnull);
3357
3358 let Some(true) = agent_id_notnull else {
3359 return Ok(false);
3360 };
3361
3362 let mut fk_stmt = conn.prepare("PRAGMA foreign_key_list(memories)")?;
3363 let has_fk = fk_stmt
3364 .query_map([], |row| {
3365 let target_table: String = row.get(2)?;
3366 let from_col: String = row.get(3)?;
3367 Ok((target_table, from_col))
3368 })?
3369 .filter_map(Result::ok)
3370 .any(|(target, from)| target == "agents" && from == "agent_id");
3371 Ok(has_fk)
3372}
3373
3374fn sqlite_memories_has_agent_id_column(conn: &Connection) -> MigResult<bool> {
3375 let mut stmt = conn.prepare("PRAGMA table_info(memories)")?;
3376 Ok(stmt
3377 .query_map([], |row| row.get::<_, String>(1))?
3378 .filter_map(Result::ok)
3379 .any(|name| name == "agent_id"))
3380}
3381
3382fn sqlite_memories_row_count(conn: &Connection) -> MigResult<i64> {
3383 let table_exists: bool = conn
3384 .query_row(
3385 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='memories' LIMIT 1",
3386 [],
3387 |_| Ok(()),
3388 )
3389 .optional()?
3390 .is_some();
3391 if !table_exists {
3392 return Ok(0);
3393 }
3394 let count: i64 = conn.query_row("SELECT COUNT(*) FROM memories", [], |row| row.get(0))?;
3395 Ok(count)
3396}
3397
3398pub fn sqlite_ensure_default_agent_uuid(conn: &Connection) -> MigResult<String> {
3402 sqlite_ensure_agent_uuid(conn, "default")
3403}
3404
3405pub fn sqlite_ensure_agent_uuid(conn: &Connection, alias: &str) -> MigResult<String> {
3409 let new_id = uuid::Uuid::new_v4().to_string();
3410 let now = chrono::Utc::now().to_rfc3339();
3411 conn.execute(
3412 "INSERT OR IGNORE INTO agents (id, alias, created_at) VALUES (?1, ?2, ?3)",
3413 params![new_id, alias, now],
3414 )?;
3415 let final_id: String = conn.query_row(
3416 "SELECT id FROM agents WHERE alias = ?1 LIMIT 1",
3417 params![alias],
3418 |row| row.get(0),
3419 )?;
3420 Ok(final_id)
3421}
3422
3423fn backup_sqlite_for_multi_agent_migration(db_path: &Path) -> MigResult<()> {
3424 let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%S").to_string();
3425 let backup_path = db_path.with_file_name(format!(
3426 "{}.backup-{timestamp}",
3427 db_path
3428 .file_name()
3429 .map(|n| n.to_string_lossy().into_owned())
3430 .unwrap_or_else(|| "brain.db".to_string()),
3431 ));
3432 std::fs::copy(db_path, &backup_path).with_context(|| {
3433 format!(
3434 "failed to copy {} to {} before multi-agent migration",
3435 db_path.display(),
3436 backup_path.display(),
3437 )
3438 })?;
3439 ::zeroclaw_log::record!(
3440 INFO,
3441 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
3442 ::serde_json::json!({
3443 "backup": backup_path.display().to_string(),
3444 })
3445 ),
3446 "multi-agent migration: backed up SQLite memory DB before adding agents table"
3447 );
3448 Ok(())
3449}
3450
3451#[cfg(feature = "memory-postgres")]
3467pub fn migrate_postgres_memory_to_v3(
3468 client: &mut postgres::Client,
3469 schema_ident: &str,
3470 qualified_table: &str,
3471) -> MigResult<()> {
3472 let qualified_agents = format!("{schema_ident}.agents");
3473
3474 client.batch_execute(&format!(
3475 "CREATE TABLE IF NOT EXISTS {qualified_agents} (
3476 id TEXT PRIMARY KEY,
3477 alias TEXT NOT NULL UNIQUE,
3478 created_at TIMESTAMPTZ NOT NULL
3479 );"
3480 ))?;
3481
3482 let candidate_uuid = uuid::Uuid::new_v4().to_string();
3483 client.execute(
3484 &format!(
3485 "INSERT INTO {qualified_agents} (id, alias, created_at)
3486 VALUES ($1, 'default', NOW())
3487 ON CONFLICT (alias) DO NOTHING"
3488 ),
3489 &[&candidate_uuid],
3490 )?;
3491 let default_uuid: String = client
3492 .query_one(
3493 &format!("SELECT id FROM {qualified_agents} WHERE alias = 'default' LIMIT 1"),
3494 &[],
3495 )?
3496 .get(0);
3497
3498 client.batch_execute(&format!(
3499 "ALTER TABLE {qualified_table} ADD COLUMN IF NOT EXISTS agent_id TEXT;
3500 CREATE INDEX IF NOT EXISTS idx_memories_agent_id ON {qualified_table}(agent_id);"
3501 ))?;
3502 client.execute(
3503 &format!("UPDATE {qualified_table} SET agent_id = $1 WHERE agent_id IS NULL"),
3504 &[&default_uuid],
3505 )?;
3506
3507 client.batch_execute(&format!(
3508 "
3509 DO $$
3510 BEGIN
3511 IF NOT EXISTS (
3512 SELECT 1 FROM pg_constraint
3513 WHERE conname = 'memories_agent_id_notnull_chk'
3514 ) THEN
3515 ALTER TABLE {qualified_table}
3516 ADD CONSTRAINT memories_agent_id_notnull_chk
3517 CHECK (agent_id IS NOT NULL) NOT VALID;
3518 END IF;
3519 END$$;
3520 ALTER TABLE {qualified_table} VALIDATE CONSTRAINT memories_agent_id_notnull_chk;
3521 ALTER TABLE {qualified_table} ALTER COLUMN agent_id SET NOT NULL;
3522 DO $$
3523 BEGIN
3524 IF NOT EXISTS (
3525 SELECT 1 FROM pg_constraint
3526 WHERE conname = 'memories_agent_id_fk'
3527 ) THEN
3528 ALTER TABLE {qualified_table}
3529 ADD CONSTRAINT memories_agent_id_fk
3530 FOREIGN KEY (agent_id) REFERENCES {qualified_agents}(id) NOT VALID;
3531 END IF;
3532 END$$;
3533 ALTER TABLE {qualified_table} VALIDATE CONSTRAINT memories_agent_id_fk;
3534 -- Swap the legacy key-only uniqueness for composite (agent_id, key)
3535 -- so two agents may hold rows under the same caller-chosen key.
3536 ALTER TABLE {qualified_table} DROP CONSTRAINT IF EXISTS memories_key_key;
3537 DO $$
3538 BEGIN
3539 IF NOT EXISTS (
3540 SELECT 1 FROM pg_constraint
3541 WHERE conname = 'memories_agent_key_uniq'
3542 ) THEN
3543 ALTER TABLE {qualified_table}
3544 ADD CONSTRAINT memories_agent_key_uniq UNIQUE (agent_id, key);
3545 END IF;
3546 END$$;
3547 "
3548 ))?;
3549
3550 client.batch_execute(&format!(
3551 "CREATE TABLE IF NOT EXISTS {schema_ident}.schema_version (
3552 component TEXT PRIMARY KEY,
3553 version INTEGER NOT NULL,
3554 applied_at TIMESTAMPTZ NOT NULL
3555 );"
3556 ))?;
3557 client.execute(
3558 &format!(
3559 "INSERT INTO {schema_ident}.schema_version (component, version, applied_at) \
3560 VALUES ('memories', $1, NOW()) \
3561 ON CONFLICT (component) DO UPDATE SET version = EXCLUDED.version, applied_at = EXCLUDED.applied_at"
3562 ),
3563 &[&SQLITE_MEMORY_SCHEMA_VERSION],
3564 )?;
3565 Ok(())
3566}
3567
3568pub const QDRANT_DEFAULT_AGENT_ID: &str = "default";
3581
3582pub async fn migrate_qdrant_collection_to_v3(
3593 client: &reqwest::Client,
3594 base_url: &str,
3595 collection: &str,
3596 api_key: Option<&str>,
3597) -> MigResult<usize> {
3598 let base_url = base_url.trim_end_matches('/');
3599 let mut next_offset: Option<serde_json::Value> = None;
3600 let mut updated = 0usize;
3601
3602 loop {
3603 let mut scroll_body = serde_json::json!({
3604 "limit": 1000,
3605 "with_payload": true,
3606 "with_vector": false,
3607 "filter": {
3611 "must": [{ "is_empty": { "key": "agent_id" } }]
3612 }
3613 });
3614 if let Some(ref offset) = next_offset {
3615 scroll_body["offset"] = offset.clone();
3616 }
3617
3618 let url = format!("{base_url}/collections/{collection}/points/scroll");
3619 let mut req = client.request(reqwest::Method::POST, &url);
3620 if let Some(key) = api_key {
3621 req = req.header("api-key", key);
3622 }
3623 let resp = req
3624 .header("Content-Type", "application/json")
3625 .json(&scroll_body)
3626 .send()
3627 .await
3628 .context("[system] Qdrant V3 migration: scroll request failed")?;
3629 if !resp.status().is_success() {
3630 let status = resp.status();
3631 let text = resp.text().await.unwrap_or_default();
3632 anyhow::bail!("Qdrant scroll failed ({status}): {text}");
3633 }
3634
3635 #[derive(serde::Deserialize)]
3636 struct ScrollPage {
3637 result: ScrollResult,
3638 }
3639 #[derive(serde::Deserialize)]
3640 struct ScrollResult {
3641 points: Vec<ScrollPoint>,
3642 #[serde(default)]
3643 next_page_offset: Option<serde_json::Value>,
3644 }
3645 #[derive(serde::Deserialize)]
3646 struct ScrollPoint {
3647 id: serde_json::Value,
3648 }
3649
3650 let page: ScrollPage = resp
3651 .json()
3652 .await
3653 .context("[system] Qdrant V3 migration: scroll page parse failed")?;
3654 let ids: Vec<serde_json::Value> = page.result.points.into_iter().map(|p| p.id).collect();
3655 if !ids.is_empty() {
3656 let set_url = format!("{base_url}/collections/{collection}/points/payload");
3657 let body = serde_json::json!({
3658 "payload": { "agent_id": QDRANT_DEFAULT_AGENT_ID },
3659 "points": ids,
3660 });
3661 let mut req = client.request(reqwest::Method::POST, &set_url);
3662 if let Some(key) = api_key {
3663 req = req.header("api-key", key);
3664 }
3665 let resp = req
3666 .header("Content-Type", "application/json")
3667 .query(&[("wait", "true")])
3668 .json(&body)
3669 .send()
3670 .await
3671 .context("[system] Qdrant V3 migration: set payload request failed")?;
3672 if !resp.status().is_success() {
3673 let status = resp.status();
3674 let text = resp.text().await.unwrap_or_default();
3675 anyhow::bail!("Qdrant set payload failed ({status}): {text}");
3676 }
3677 let batch_count = body["points"].as_array().map(|a| a.len()).unwrap_or(0);
3678 updated += batch_count;
3679 }
3680
3681 match page.result.next_page_offset {
3682 Some(offset) if !offset.is_null() => next_offset = Some(offset),
3683 _ => break,
3684 }
3685 }
3686
3687 if updated > 0 {
3688 ::zeroclaw_log::record!(
3689 INFO,
3690 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
3691 ::serde_json::json!({
3692 "collection": collection,
3693 "updated": updated,
3694 })
3695 ),
3696 "[system] Qdrant V3 migration: backfilled agent_id payload"
3697 );
3698 }
3699 Ok(updated)
3700}
3701
3702#[cfg(test)]
3703mod fs_db_migration_tests {
3704 use super::*;
3713 use rusqlite::Connection;
3714 use std::collections::BTreeSet;
3715 use std::fs;
3716 use tempfile::TempDir;
3717
3718 fn snapshot_tree(root: &Path) -> BTreeSet<(PathBuf, Vec<u8>)> {
3722 fn walk(root: &Path, dir: &Path, out: &mut BTreeSet<(PathBuf, Vec<u8>)>) {
3723 let Ok(rd) = fs::read_dir(dir) else { return };
3724 for entry in rd.flatten() {
3725 let path = entry.path();
3726 if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
3727 walk(root, &path, out);
3728 } else if let Ok(bytes) = fs::read(&path)
3729 && let Ok(rel) = path.strip_prefix(root)
3730 {
3731 out.insert((rel.to_path_buf(), bytes));
3732 }
3733 }
3734 }
3735 let mut out = BTreeSet::new();
3736 walk(root, root, &mut out);
3737 out
3738 }
3739
3740 fn seed_v2_install(install: &Path) {
3745 fs::create_dir_all(install.join("workspace")).unwrap();
3748 fs::write(
3749 install.join("workspace/devices.db"),
3750 b"pretend-paired-devices-blob",
3751 )
3752 .unwrap();
3753
3754 for fname in [
3756 "MEMORY.md",
3757 "IDENTITY.md",
3758 "SOUL.md",
3759 "USER.md",
3760 "AGENTS.md",
3761 ] {
3762 fs::write(install.join("workspace").join(fname), format!("# {fname}")).unwrap();
3763 }
3764
3765 fs::create_dir_all(install.join("workspace/sessions")).unwrap();
3767 fs::write(install.join("workspace/sessions/sessions.db"), b"sessions").unwrap();
3768
3769 fs::create_dir_all(install.join("workspace/state")).unwrap();
3771 fs::write(
3772 install.join("workspace/state/runtime-trace.jsonl"),
3773 b"trace",
3774 )
3775 .unwrap();
3776
3777 fs::create_dir_all(install.join("workspace/skills/my-skill")).unwrap();
3779 fs::write(install.join("workspace/skills/my-skill/SKILL.md"), b"skill").unwrap();
3780
3781 let mem_dir = install.join("workspace/memory");
3784 fs::create_dir_all(&mem_dir).unwrap();
3785 for sub in V2_MEMORY_DATA_NAMES {
3786 let p = mem_dir.join(sub);
3787 if *sub == "archive" {
3788 fs::create_dir_all(&p).unwrap();
3789 fs::write(p.join("old-recall.jsonl"), b"archived").unwrap();
3790 } else if (*sub).ends_with(".db") {
3791 let conn = Connection::open(&p).unwrap();
3794 if *sub == "brain.db" {
3795 conn.execute_batch(
3796 "PRAGMA foreign_keys = ON;
3797 CREATE TABLE memories (
3798 id TEXT PRIMARY KEY,
3799 key TEXT NOT NULL UNIQUE,
3800 content TEXT NOT NULL,
3801 category TEXT NOT NULL DEFAULT 'core',
3802 embedding BLOB,
3803 created_at TEXT NOT NULL,
3804 updated_at TEXT NOT NULL,
3805 session_id TEXT,
3806 namespace TEXT DEFAULT 'default',
3807 importance REAL DEFAULT 0.5,
3808 superseded_by TEXT
3809 );
3810 INSERT INTO memories (id, key, content, created_at, updated_at)
3811 VALUES ('m1', 'hello', 'world', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'),
3812 ('m2', 'foo', 'bar', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z');",
3813 )
3814 .unwrap();
3815 }
3816 } else {
3817 fs::write(&p, format!("{sub} payload").as_bytes()).unwrap();
3818 }
3819 }
3820 fs::write(
3822 mem_dir.join("2025-04-12.md"),
3823 b"# daily 2025-04-12\nhello world\n",
3824 )
3825 .unwrap();
3826 fs::write(
3827 mem_dir.join("2025-04-13.md"),
3828 b"# daily 2025-04-13\nstill here\n",
3829 )
3830 .unwrap();
3831 }
3832
3833 #[test]
3834 fn migrate_v2_install_into_v3_layout_with_real_filesystem() {
3835 let tmp = TempDir::new().unwrap();
3836 let install = tmp.path();
3837 seed_v2_install(install);
3838
3839 let legacy_snapshot = snapshot_tree(&install.join("workspace"));
3840 assert!(
3841 !legacy_snapshot.is_empty(),
3842 "fixture seed must produce content under workspace/"
3843 );
3844
3845 let report = migrate_v2_to_v3_install_filesystem(install).expect("migration must succeed");
3846 assert!(report.entries_relocated > 0);
3847 let backup_root = report.backup_dir.expect("backup dir present");
3848
3849 let backup_snapshot = snapshot_tree(&backup_root.join("legacy-workspace"));
3851 assert_eq!(
3852 backup_snapshot, legacy_snapshot,
3853 "backup must be a byte-equal copy of the pre-migration workspace"
3854 );
3855
3856 for (rel, expected_bytes) in &legacy_snapshot {
3860 let v3_path = predict_v3_path(install, rel);
3861 assert!(
3862 v3_path.exists(),
3863 "file {} should exist at predicted V3 path {}",
3864 rel.display(),
3865 v3_path.display(),
3866 );
3867 let actual_bytes = fs::read(&v3_path).unwrap_or_else(|e| {
3868 panic!(
3869 "failed to read predicted V3 path {} for legacy {}: {e}",
3870 v3_path.display(),
3871 rel.display(),
3872 )
3873 });
3874 assert_eq!(
3875 actual_bytes,
3876 *expected_bytes,
3877 "byte mismatch at {}",
3878 v3_path.display()
3879 );
3880 }
3881
3882 assert!(
3884 !install.join("workspace").exists(),
3885 "legacy workspace must be removed after a clean split"
3886 );
3887
3888 let mut roots = BTreeSet::new();
3891 for entry in fs::read_dir(install).unwrap() {
3892 let entry = entry.unwrap();
3893 let name = entry.file_name().to_string_lossy().to_string();
3894 roots.insert(name);
3895 }
3896 for name in &roots {
3897 let allowed =
3898 V3_INSTALL_ROOT_NAMES.contains(&name.as_str()) || name.starts_with("backup-");
3899 assert!(
3900 allowed,
3901 "unexpected install-root entry {name:?}; allowed: {V3_INSTALL_ROOT_NAMES:?} + backup-*"
3902 );
3903 }
3904
3905 let post_first = snapshot_tree(install);
3907 let report2 =
3908 migrate_v2_to_v3_install_filesystem(install).expect("second run must be a no-op");
3909 assert_eq!(report2.entries_relocated, 0);
3910 let post_second = snapshot_tree(install);
3911 assert_eq!(
3912 post_first, post_second,
3913 "idempotent re-run must not modify disk"
3914 );
3915
3916 let brain_path = install.join("data/memory/brain.db");
3920 assert!(
3921 brain_path.is_file(),
3922 "brain.db must have moved to data/memory/"
3923 );
3924 let conn = Connection::open(&brain_path).unwrap();
3925 conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap();
3926 migrate_sqlite_memory_to_v3(&brain_path, &conn).expect("SQLite migration must succeed");
3927
3928 let null_count: i64 = conn
3929 .query_row(
3930 "SELECT COUNT(*) FROM memories WHERE agent_id IS NULL",
3931 [],
3932 |r| r.get(0),
3933 )
3934 .unwrap();
3935 assert_eq!(
3936 null_count, 0,
3937 "all memories must have agent_id post-migration"
3938 );
3939
3940 let agent_row_count: i64 = conn
3941 .query_row(
3942 "SELECT COUNT(*) FROM agents WHERE alias = 'default'",
3943 [],
3944 |r| r.get(0),
3945 )
3946 .unwrap();
3947 assert_eq!(agent_row_count, 1, "default agent row must exist");
3948
3949 migrate_sqlite_memory_to_v3(&brain_path, &conn)
3951 .expect("SQLite migration second run must be a no-op");
3952
3953 let backup_glob: Vec<_> = fs::read_dir(install.join("data/memory"))
3955 .unwrap()
3956 .flatten()
3957 .filter(|e| {
3958 e.file_name()
3959 .to_string_lossy()
3960 .starts_with("brain.db.backup-")
3961 })
3962 .collect();
3963 assert_eq!(
3964 backup_glob.len(),
3965 1,
3966 "in-DB SQLite migration must write exactly one backup file"
3967 );
3968 }
3969
3970 fn predict_v3_path(install: &Path, rel: &Path) -> PathBuf {
3974 let mut parts = rel.components();
3975 let top = parts
3976 .next()
3977 .expect("legacy snapshot paths have at least one component");
3978 let top_name = top.as_os_str().to_string_lossy().to_string();
3979
3980 if v2_workspace_toplevel_dest(&top_name) == V2WorkspaceDest::MemorySubentryDispatch {
3982 let sub = parts.next();
3983 let Some(sub) = sub else {
3984 return workspace_toplevel_v3_path(install, &top_name);
3985 };
3986 let sub_name = sub.as_os_str().to_string_lossy().to_string();
3987 let base = memory_subentry_v3_path(install, &sub_name);
3988 let rest: PathBuf = parts.as_path().to_path_buf();
3989 if rest.as_os_str().is_empty() {
3990 base
3991 } else {
3992 base.join(rest)
3993 }
3994 } else {
3995 let top_v3 = workspace_toplevel_v3_path(install, &top_name);
3996 let rest: PathBuf = parts.as_path().to_path_buf();
3997 if rest.as_os_str().is_empty() {
3998 top_v3
3999 } else {
4000 top_v3.join(rest)
4001 }
4002 }
4003 }
4004
4005 #[test]
4006 fn fresh_install_is_noop() {
4007 let tmp = TempDir::new().unwrap();
4008 let report =
4009 migrate_v2_to_v3_install_filesystem(tmp.path()).expect("fresh install must be a no-op");
4010 assert_eq!(report.entries_relocated, 0);
4011 assert!(report.backup_dir.is_none());
4012 }
4013
4014 #[test]
4015 fn refuse_to_clobber_existing_v3_target() {
4016 let tmp = TempDir::new().unwrap();
4017 let install = tmp.path();
4018 seed_v2_install(install);
4019
4020 fs::create_dir_all(install.join("data")).unwrap();
4023 let v3_devices = workspace_toplevel_v3_path(install, "devices.db");
4024 fs::write(&v3_devices, b"operator-owned").unwrap();
4025
4026 let _ = migrate_v2_to_v3_install_filesystem(install).expect("migration must not fail");
4027
4028 let after = fs::read(&v3_devices).unwrap();
4030 assert_eq!(
4031 after, b"operator-owned",
4032 "refuse-to-clobber: operator file must survive"
4033 );
4034
4035 let legacy_still = install.join("workspace/devices.db").exists();
4038 let in_backup = fs::read_dir(install).unwrap().flatten().any(|e| {
4039 let n = e.file_name().to_string_lossy().to_string();
4040 n.starts_with("backup-") && e.path().join("legacy-workspace/devices.db").exists()
4041 });
4042 assert!(
4043 legacy_still || in_backup,
4044 "legacy devices.db must be preserved (in legacy/ or backup/)"
4045 );
4046 }
4047}