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 "twitch",
99 "lark",
100 "line",
101 "dingtalk",
102 "wecom",
103 "wecom_ws",
104 "wechat",
105 "qq",
106 "twitter",
107 "mochat",
108 "nostr",
109 "clawdtalk",
110 "reddit",
111 "bluesky",
112 "voice_call",
113 "voice_wake",
114 "voice_duplex",
115 "mqtt",
116 "amqp",
117];
118
119impl V2Config {
120 pub fn migrate(self) -> anyhow::Result<toml::Value> {
124 let V2Config {
125 schema_version: _,
126 autonomy,
127 agent,
128 swarms,
129 cron,
130 providers,
131 cost,
132 channels,
133 agents,
134 mut passthrough,
135 } = self;
136
137 if let Some(autonomy_value) = autonomy {
151 let renamed = rename_table_keys(
152 autonomy_value,
153 &[("non_cli_excluded_tools", "excluded_tools")],
154 );
155 let (risk_fields, runtime_fields) = split_autonomy_into_profile_buckets(renamed);
156 if let Some(risk_table) = risk_fields {
157 let mut risk_profiles = passthrough
158 .remove("risk_profiles")
159 .and_then(|v| v.try_into::<toml::Table>().ok())
160 .unwrap_or_default();
161 merge_into_profile_default(&mut risk_profiles, risk_table);
162 passthrough.insert(
163 "risk_profiles".to_string(),
164 toml::Value::Table(risk_profiles),
165 );
166 ::zeroclaw_log::record!(
167 INFO,
168 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
169 "[autonomy] authorization fields → [risk_profiles.default]"
170 );
171 }
172 if let Some(runtime_table) = runtime_fields {
173 let mut runtime_profiles = passthrough
174 .remove("runtime_profiles")
175 .and_then(|v| v.try_into::<toml::Table>().ok())
176 .unwrap_or_default();
177 merge_into_profile_default(&mut runtime_profiles, runtime_table);
178 passthrough.insert(
179 "runtime_profiles".to_string(),
180 toml::Value::Table(runtime_profiles),
181 );
182 ::zeroclaw_log::record!(
183 INFO,
184 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
185 "[autonomy] budget/timeout fields → [runtime_profiles.default]"
186 );
187 }
188 }
189
190 fold_security_into_risk_profile(&mut passthrough);
196
197 if let Some(toml::Value::Table(mut agent_table)) = agent {
205 let allowed_tools = agent_table.remove("allowed_tools");
206 if let Some(at_value) = allowed_tools {
207 let mut risk_profiles = passthrough
208 .remove("risk_profiles")
209 .and_then(|v| v.try_into::<toml::Table>().ok())
210 .unwrap_or_default();
211 let mut risk_default = toml::Table::new();
212 risk_default.insert("allowed_tools".to_string(), at_value);
213 merge_into_profile_default(&mut risk_profiles, risk_default);
214 passthrough.insert(
215 "risk_profiles".to_string(),
216 toml::Value::Table(risk_profiles),
217 );
218 ::zeroclaw_log::record!(
219 INFO,
220 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
221 "[agent.allowed_tools] → [risk_profiles.default.allowed_tools]"
222 );
223 }
224 if !agent_table.is_empty() {
225 let mut runtime_profiles = passthrough
226 .remove("runtime_profiles")
227 .and_then(|v| v.try_into::<toml::Table>().ok())
228 .unwrap_or_default();
229 merge_into_profile_default(&mut runtime_profiles, agent_table);
230 passthrough.insert(
231 "runtime_profiles".to_string(),
232 toml::Value::Table(runtime_profiles),
233 );
234 ::zeroclaw_log::record!(
235 INFO,
236 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
237 "[agent] → [runtime_profiles.default]"
238 );
239 }
240 }
241
242 if !swarms.is_empty() {
244 ::zeroclaw_log::record!(
245 INFO,
246 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
247 &format!("[swarms] dropped ({} entries)", swarms.len())
248 );
249 }
250
251 if let Some(toml::Value::Table(reliability_table)) = passthrough.get_mut("reliability") {
254 let dropped_fb = reliability_table.remove("fallback_providers").is_some();
255 let dropped_mf = reliability_table.remove("model_fallbacks").is_some();
256 if dropped_fb || dropped_mf {
257 ::zeroclaw_log::record!(
258 INFO,
259 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
260 "[reliability] {{fallback_providers, model_fallbacks}} dropped (provider fallback eradicated in V3)"
261 );
262 }
263 }
264
265 let mut new_providers = providers
268 .and_then(|v| match v {
269 toml::Value::Table(t) => Some(t),
270 _ => None,
271 })
272 .unwrap_or_default();
273 if new_providers.remove("fallback").is_some() {
274 ::zeroclaw_log::record!(
275 INFO,
276 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
277 "providers.fallback eradicated"
278 );
279 }
280 let mut aliased_models = alias_provider_models(new_providers.remove("models"));
281
282 fold_providers_globals_into_models(&mut new_providers, &mut aliased_models);
285
286 let cost_passthrough = if let Some(cost_value) = cost {
290 let (cost_remaining, prices) = strip_cost_prices(cost_value);
291 if !prices.is_empty() {
292 drop_cost_prices_with_logs(&prices);
293 }
294 cost_remaining
295 } else {
296 None
297 };
298 if !aliased_models.is_empty() {
299 new_providers.insert("models".to_string(), toml::Value::Table(aliased_models));
300 }
301
302 rename_route_provider_field(&mut new_providers, "model_routes");
307 rename_route_provider_field(&mut new_providers, "embedding_routes");
308 rename_route_provider_field(&mut passthrough, "model_routes");
309 rename_route_provider_field(&mut passthrough, "embedding_routes");
310
311 fold_v2_tts_into_providers(&mut passthrough, &mut new_providers);
316 fold_v2_transcription_into_providers(&mut passthrough, &mut new_providers);
317
318 let mut v3_providers = toml::Table::new();
323 if let Some(models) = new_providers.remove("models") {
324 v3_providers.insert("models".to_string(), models);
325 }
326 if let Some(tts) = new_providers.remove("tts") {
327 v3_providers.insert("tts".to_string(), tts);
328 }
329 if let Some(transcription) = new_providers.remove("transcription") {
330 v3_providers.insert("transcription".to_string(), transcription);
331 }
332 if !v3_providers.is_empty() {
333 passthrough.insert("providers".to_string(), toml::Value::Table(v3_providers));
334 }
335 if let Some(routes) = new_providers.remove("model_routes") {
336 passthrough.insert("model_routes".to_string(), routes);
337 }
338 if let Some(routes) = new_providers.remove("embedding_routes") {
339 passthrough.insert("embedding_routes".to_string(), routes);
340 }
341 if !new_providers.is_empty() {
342 ::zeroclaw_log::record!(
343 WARN,
344 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
345 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
346 &format!(
347 "[providers] residual keys dropped during V3 hoist: {:?}",
348 new_providers.keys().collect::<Vec<_>>()
349 )
350 );
351 }
352 if let Some(remaining_cost) = cost_passthrough {
353 passthrough.insert("cost".to_string(), remaining_cost);
354 }
355
356 fold_v2_storage_subsystems(&mut passthrough);
359
360 if let Some(channels_value) = channels {
367 let mut peer_groups_for_fold = match passthrough.remove("peer_groups") {
368 Some(toml::Value::Table(t)) => t,
369 _ => toml::Table::new(),
370 };
371 let new_channels = alias_wrap_channels(channels_value, &mut peer_groups_for_fold);
372 passthrough.insert("channels".to_string(), toml::Value::Table(new_channels));
373 if !peer_groups_for_fold.is_empty() {
374 passthrough.insert(
375 "peer_groups".to_string(),
376 toml::Value::Table(peer_groups_for_fold),
377 );
378 }
379 ::zeroclaw_log::record!(
380 INFO,
381 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
382 "[channels] sections alias-wrapped, discord_history folded, inbound peer-auth folded into [peer_groups.*]"
383 );
384 }
385
386 if let Some(cron_value) = cron {
387 let (new_cron, scheduler_extras) = restructure_cron(cron_value);
388 if !new_cron.is_empty() {
389 passthrough.insert("cron".to_string(), toml::Value::Table(new_cron));
390 }
391 if !scheduler_extras.is_empty() {
392 merge_into_table(&mut passthrough, "scheduler", scheduler_extras);
393 }
394 ::zeroclaw_log::record!(
395 INFO,
396 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
397 "[cron] restructured into [cron.<alias>] + [scheduler]"
398 );
399 }
400
401 let new_agents = if !agents.is_empty() {
407 synthesize_agent_brains(agents, &mut passthrough)
408 } else {
409 let synthesized = synthesize_default_agent_if_needed(&passthrough);
410 if !synthesized.is_empty() {
411 ensure_profile_entry(&mut passthrough, "risk_profiles", "default");
412 ensure_profile_entry(&mut passthrough, "runtime_profiles", "default");
413 }
414 synthesized
415 };
416 if !new_agents.is_empty() {
417 passthrough.insert("agents".to_string(), toml::Value::Table(new_agents));
418 }
419
420 lift_top_level_identity_into_agents(&mut passthrough);
424
425 backfill_heartbeat_agent(&mut passthrough);
429
430 rewrite_dangling_peer_group_agents(&mut passthrough);
435
436 rename_subkey(&mut passthrough, "tunnel", "provider", "tunnel_provider");
440 rename_subkey(
441 &mut passthrough,
442 "web_search",
443 "provider",
444 "search_provider",
445 );
446
447 passthrough.insert("schema_version".to_string(), toml::Value::Integer(3));
448
449 Ok(toml::Value::Table(passthrough))
450 }
451}
452
453fn rename_subkey(table: &mut toml::Table, parent: &str, inner: &str, replacement: &str) {
459 let Some(toml::Value::Table(parent_tbl)) = table.get_mut(parent) else {
460 return;
461 };
462 if parent_tbl.contains_key(replacement) {
463 let _ = parent_tbl.remove(inner);
467 return;
468 }
469 if let Some(value) = parent_tbl.remove(inner) {
470 parent_tbl.insert(replacement.to_string(), value);
471 ::zeroclaw_log::record!(
472 INFO,
473 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
474 ::serde_json::json!({"parent": parent, "inner": inner, "replacement": replacement})
475 ),
476 &format!(
477 "[{parent}].{inner} renamed to [{parent}].{replacement} (V3 qualified-noun rename)"
478 )
479 );
480 }
481}
482
483fn restructure_cron(cron_value: toml::Value) -> (toml::Table, toml::Table) {
485 let mut new_cron = toml::Table::new();
486 let mut scheduler_extras = toml::Table::new();
487 let mut cron_table = match cron_value {
488 toml::Value::Table(t) => t,
489 _ => return (new_cron, scheduler_extras),
490 };
491
492 if let Some(toml::Value::Array(jobs)) = cron_table.remove("jobs") {
495 for (i, job) in jobs.into_iter().enumerate() {
496 let key = job
498 .get("name")
499 .and_then(toml::Value::as_str)
500 .map(slugify)
501 .or_else(|| {
502 job.get("id")
503 .and_then(toml::Value::as_str)
504 .map(ToString::to_string)
505 })
506 .unwrap_or_else(|| format!("job_{}", i + 1));
507 let key = ensure_unique_key(&new_cron, key);
508 let stripped = match job {
509 toml::Value::Table(mut t) => {
510 t.remove("id");
511 dot_delivery_channel(&mut t);
512 toml::Value::Table(t)
513 }
514 other => other,
515 };
516 new_cron.insert(key, stripped);
517 }
518 }
519
520 for knob in ["enabled", "catch_up_on_startup", "max_run_history"] {
522 if let Some(v) = cron_table.remove(knob) {
523 scheduler_extras.insert(knob.to_string(), v);
524 }
525 }
526
527 if !cron_table.is_empty() {
530 ::zeroclaw_log::record!(
531 INFO,
532 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
533 &format!(
534 "[cron] had unmodeled keys: {:?}",
535 cron_table.keys().collect::<Vec<_>>()
536 )
537 );
538 }
539
540 (new_cron, scheduler_extras)
541}
542
543fn dot_delivery_channel(job: &mut toml::Table) {
544 let Some(toml::Value::Table(delivery)) = job.get_mut("delivery") else {
545 return;
546 };
547 let Some(toml::Value::String(channel)) = delivery.get_mut("channel") else {
548 return;
549 };
550 if !channel.contains('.') {
551 *channel = format!("{channel}.default");
552 }
553}
554
555fn normalize_provider_type(
568 raw: &str,
569 incoming_alias: &str,
570) -> (String, String, Vec<(&'static str, toml::Value)>) {
571 let mut extras: Vec<(&'static str, toml::Value)> = Vec::new();
572
573 let synonym_canonical = match raw {
575 "azure_openai" | "azure-openai" | "azure" => Some("azure"),
577 "xai" | "grok" => Some("xai"),
579 "gemini" | "google" | "google-gemini" => Some("gemini"),
581 "together" | "together-ai" => Some("together"),
583 "fireworks" | "fireworks-ai" => Some("fireworks"),
585 "vercel" | "vercel-ai" => Some("vercel"),
587 "cloudflare" | "cloudflare-ai" => Some("cloudflare"),
589 "nvidia" | "nvidia-nim" | "build.nvidia.com" => Some("nvidia"),
591 "bedrock" | "aws-bedrock" => Some("bedrock"),
593 "lmstudio" | "lm-studio" => Some("lmstudio"),
595 "litellm" | "lite-llm" => Some("litellm"),
597 "huggingface" | "hf" => Some("huggingface"),
599 "yi" | "01ai" | "lingyiwanwu" => Some("yi"),
601 "hunyuan" | "tencent" => Some("hunyuan"),
603 "qianfan" | "baidu" => Some("qianfan"),
605 "copilot" | "github-copilot" => Some("copilot"),
607 "ovhcloud" | "ovh" => Some("ovh"),
609 "opencode" | "opencode-zen" => Some("opencode"),
611 "llamacpp" | "llama.cpp" => Some("llamacpp"),
613 "deepmyst" | "deep-myst" => Some("deepmyst"),
615 "siliconflow" | "silicon-flow" => Some("siliconflow"),
617 "deepinfra" | "deep-infra" => Some("deepinfra"),
619 "ai21" | "ai21-labs" => Some("ai21"),
621 "friendli" | "friendliai" => Some("friendli"),
623 "lepton" | "lepton-ai" => Some("lepton"),
625 "lambda_ai" | "lambda-ai" => Some("lambda_ai"),
627 "github_models" | "github-models" => Some("github_models"),
629 "stepfun" | "step" => Some("stepfun"),
631 "kilocli" | "kilo-cli" => Some("kilocli"),
633 _ => None,
634 };
635
636 if let Some(canonical) = synonym_canonical {
637 return (canonical.to_string(), incoming_alias.to_string(), extras);
638 }
639
640 if raw == "opencode-go" {
642 return ("opencode".to_string(), "go".to_string(), extras);
643 }
644
645 if matches!(raw, "openai-codex" | "openai_codex" | "codex") {
647 extras.push(("wire_api", toml::Value::String("responses".to_string())));
648 extras.push(("requires_openai_auth", toml::Value::Boolean(true)));
649 return ("openai".to_string(), "codex".to_string(), extras);
650 }
651
652 if raw == "claude-code" {
655 return ("anthropic".to_string(), "claude-code".to_string(), extras);
656 }
657
658 if raw == "anthropic-custom" {
664 return ("anthropic".to_string(), "custom".to_string(), extras);
665 }
666
667 if raw == "custom" {
671 return ("custom".to_string(), incoming_alias.to_string(), extras);
672 }
673
674 if matches!(
679 raw,
680 "moonshot-intl" | "moonshot-global" | "kimi-intl" | "kimi-global"
681 ) {
682 extras.push(("endpoint", toml::Value::String("intl".to_string())));
683 return ("moonshot".to_string(), incoming_alias.to_string(), extras);
684 }
685 if matches!(raw, "moonshot" | "kimi" | "moonshot-cn" | "kimi-cn") {
686 extras.push(("endpoint", toml::Value::String("cn".to_string())));
687 return ("moonshot".to_string(), incoming_alias.to_string(), extras);
688 }
689 if matches!(raw, "kimi-code" | "kimi_coding" | "kimi_for_coding") {
690 extras.push(("endpoint", toml::Value::String("code".to_string())));
691 return ("moonshot".to_string(), incoming_alias.to_string(), extras);
692 }
693
694 if matches!(raw, "qwen-cn" | "dashscope" | "qwen" | "dashscope-cn") {
696 extras.push(("endpoint", toml::Value::String("cn".to_string())));
697 return ("qwen".to_string(), incoming_alias.to_string(), extras);
698 }
699 if matches!(
700 raw,
701 "qwen-intl" | "dashscope-intl" | "qwen-international" | "dashscope-international"
702 ) {
703 extras.push(("endpoint", toml::Value::String("intl".to_string())));
704 return ("qwen".to_string(), incoming_alias.to_string(), extras);
705 }
706 if matches!(raw, "qwen-us" | "dashscope-us") {
707 extras.push(("endpoint", toml::Value::String("us".to_string())));
708 return ("qwen".to_string(), incoming_alias.to_string(), extras);
709 }
710 if matches!(raw, "qwen-code" | "qwen-oauth" | "qwen_oauth") {
711 extras.push(("endpoint", toml::Value::String("code".to_string())));
712 extras.push(("auth_mode", toml::Value::String("oauth".to_string())));
713 return ("qwen".to_string(), incoming_alias.to_string(), extras);
714 }
715 if matches!(raw, "bailian" | "aliyun-bailian" | "aliyun") {
716 extras.push(("endpoint", toml::Value::String("cn".to_string())));
717 return ("qwen".to_string(), incoming_alias.to_string(), extras);
718 }
719
720 if matches!(raw, "glm" | "zhipu" | "glm-global" | "zhipu-global") {
722 extras.push(("endpoint", toml::Value::String("global".to_string())));
723 return ("glm".to_string(), incoming_alias.to_string(), extras);
724 }
725 if matches!(raw, "glm-cn" | "zhipu-cn" | "bigmodel") {
726 extras.push(("endpoint", toml::Value::String("cn".to_string())));
727 return ("glm".to_string(), incoming_alias.to_string(), extras);
728 }
729
730 if matches!(raw, "zai" | "z.ai" | "zai-global" | "z.ai-global") {
732 extras.push(("endpoint", toml::Value::String("global".to_string())));
733 return ("zai".to_string(), incoming_alias.to_string(), extras);
734 }
735 if matches!(raw, "zai-cn" | "z.ai-cn") {
736 extras.push(("endpoint", toml::Value::String("cn".to_string())));
737 return ("zai".to_string(), incoming_alias.to_string(), extras);
738 }
739
740 if matches!(
742 raw,
743 "minimax"
744 | "minimax-intl"
745 | "minimax-io"
746 | "minimax-global"
747 | "minimax-portal"
748 | "minimax-portal-global"
749 ) {
750 extras.push(("endpoint", toml::Value::String("intl".to_string())));
751 return ("minimax".to_string(), incoming_alias.to_string(), extras);
752 }
753 if matches!(raw, "minimax-oauth" | "minimax-oauth-global") {
754 extras.push(("endpoint", toml::Value::String("intl".to_string())));
755 extras.push(("auth_mode", toml::Value::String("oauth".to_string())));
756 return ("minimax".to_string(), incoming_alias.to_string(), extras);
757 }
758 if matches!(raw, "minimax-cn" | "minimaxi" | "minimax-portal-cn") {
759 extras.push(("endpoint", toml::Value::String("cn".to_string())));
760 return ("minimax".to_string(), incoming_alias.to_string(), extras);
761 }
762 if matches!(raw, "minimax-oauth-cn") {
763 extras.push(("endpoint", toml::Value::String("cn".to_string())));
764 extras.push(("auth_mode", toml::Value::String("oauth".to_string())));
765 return ("minimax".to_string(), incoming_alias.to_string(), extras);
766 }
767
768 if matches!(raw, "doubao" | "volcengine" | "ark" | "doubao-cn") {
770 return ("doubao".to_string(), incoming_alias.to_string(), extras);
771 }
772
773 if raw == "gemini-cli" {
775 return ("gemini_cli".to_string(), incoming_alias.to_string(), extras);
776 }
777
778 if matches!(raw, "stepfun-intl" | "step-intl") {
780 extras.push((
781 "uri",
782 toml::Value::String("https://api.stepfun.com/intl/v1".to_string()),
783 ));
784 return ("stepfun".to_string(), incoming_alias.to_string(), extras);
785 }
786
787 (raw.to_string(), incoming_alias.to_string(), extras)
792}
793
794fn alias_provider_models(models: Option<toml::Value>) -> toml::Table {
795 let flat = match models {
796 Some(toml::Value::Table(t)) => t,
797 _ => return toml::Table::new(),
798 };
799 let mut aliased = toml::Table::new();
800 for (provider_id, mut config) in flat {
801 let (raw_type, url) = split_colon_url_provider(&provider_id);
804 if let Some(url) = url
805 && let toml::Value::Table(t) = &mut config
806 {
807 t.entry("uri".to_string())
808 .or_insert(toml::Value::String(url));
809 }
810
811 if let toml::Value::Table(t) = &mut config {
818 fold_base_url_api_path_into_uri(t);
819 }
820
821 let (provider_type, alias, extras) = normalize_provider_type(&raw_type, "default");
822
823 if let toml::Value::Table(t) = &mut config {
827 for (field, value) in extras {
828 t.entry(field.to_string()).or_insert(value);
829 }
830 }
831
832 let entry = aliased
833 .entry(provider_type)
834 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
835 if let toml::Value::Table(entry_table) = entry {
836 entry_table.insert(alias, config);
837 }
838 }
839 aliased
840}
841
842fn fold_providers_globals_into_models(
864 new_providers: &mut toml::Table,
865 aliased_models: &mut toml::Table,
866) {
867 let g_api_key = new_providers.remove("api_key");
868 let g_api_url = new_providers.remove("api_url");
869 let g_api_path = new_providers.remove("api_path");
870 let g_default_provider = new_providers.remove("default_provider");
871 let g_default_model = new_providers.remove("default_model");
872 let g_default_temperature = new_providers.remove("default_temperature");
873 let g_provider_timeout_secs = new_providers.remove("provider_timeout_secs");
874 let g_provider_max_tokens = new_providers.remove("provider_max_tokens");
875 let g_extra_headers = new_providers.remove("extra_headers");
876
877 let any_value_globals = g_api_key.is_some()
878 || g_api_url.is_some()
879 || g_api_path.is_some()
880 || g_default_model.is_some()
881 || g_default_temperature.is_some()
882 || g_provider_timeout_secs.is_some()
883 || g_provider_max_tokens.is_some()
884 || g_extra_headers.is_some();
885
886 if !any_value_globals && g_default_provider.is_none() {
887 return;
888 }
889
890 let (target_type, target_alias, colon_url, normalized_extras) =
902 match g_default_provider.as_ref().and_then(toml::Value::as_str) {
903 Some(s) => {
904 let (raw_type, url) = split_colon_url_provider(s);
905 let (canonical, alias, extras) = normalize_provider_type(&raw_type, "default");
906 (canonical, alias, url, extras)
907 }
908 None => match aliased_models.keys().next() {
909 Some(k) => (k.clone(), "default".to_string(), None, Vec::new()),
910 None => (
911 "openrouter".to_string(),
912 "default".to_string(),
913 None,
914 Vec::new(),
915 ),
916 },
917 };
918
919 let provider_value = aliased_models
920 .entry(target_type.clone())
921 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
922 let provider_table = match provider_value.as_table_mut() {
923 Some(t) => t,
924 None => return,
925 };
926 let alias_value = provider_table
927 .entry(target_alias.clone())
928 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
929 let alias_table = match alias_value.as_table_mut() {
930 Some(t) => t,
931 None => return,
932 };
933
934 let base_url_source = colon_url.map(toml::Value::String).or(g_api_url);
941 let uri_source = match (base_url_source, g_api_path) {
942 (Some(toml::Value::String(b)), Some(toml::Value::String(p))) => {
943 let trimmed_b = b.trim_end_matches('/');
944 let suffix = if p.starts_with('/') {
945 p
946 } else {
947 format!("/{p}")
948 };
949 Some(toml::Value::String(format!("{trimmed_b}{suffix}")))
950 }
951 (Some(b), _) => Some(b),
952 (None, _) => None,
954 };
955
956 for (target_key, source) in [
958 ("api_key", g_api_key),
959 ("uri", uri_source),
960 ("model", g_default_model),
961 ("temperature", g_default_temperature),
962 ("timeout_secs", g_provider_timeout_secs),
963 ("max_tokens", g_provider_max_tokens),
964 ("extra_headers", g_extra_headers),
965 ] {
966 if let Some(value) = source
967 && !alias_table.contains_key(target_key)
968 {
969 alias_table.insert(target_key.to_string(), value);
970 }
971 }
972
973 for (field, value) in normalized_extras {
977 if !alias_table.contains_key(field) {
978 alias_table.insert(field.to_string(), value);
979 }
980 }
981
982 if any_value_globals {
983 ::zeroclaw_log::record!(
984 INFO,
985 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
986 ::serde_json::json!({"target_type": target_type, "target_alias": target_alias})
987 ),
988 "[providers] globals folded onto model_providers.."
989 );
990 }
991}
992
993fn strip_cost_prices(cost_value: toml::Value) -> (Option<toml::Value>, toml::Table) {
997 let mut cost_table = match cost_value {
998 toml::Value::Table(t) => t,
999 other => return (Some(other), toml::Table::new()),
1000 };
1001 let prices = match cost_table.remove("prices") {
1002 Some(toml::Value::Table(p)) => p,
1003 Some(other) => {
1004 cost_table.insert("prices".to_string(), other);
1006 return (Some(toml::Value::Table(cost_table)), toml::Table::new());
1007 }
1008 None => toml::Table::new(),
1009 };
1010 let cost_passthrough = if cost_table.is_empty() {
1011 None
1012 } else {
1013 Some(toml::Value::Table(cost_table))
1014 };
1015 (cost_passthrough, prices)
1016}
1017
1018fn drop_cost_prices_with_logs(prices: &toml::Table) {
1025 for (model_id, price) in prices {
1026 let (input, output) = match price.as_table() {
1027 Some(t) => (
1028 t.get("input").and_then(toml::Value::as_float),
1029 t.get("output").and_then(toml::Value::as_float),
1030 ),
1031 None => (None, None),
1032 };
1033 ::zeroclaw_log::record!(
1034 INFO,
1035 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
1036 ::serde_json::json!({"model_id": model_id, "input": format!("{:?}", input), "output": format!("{:?}", output)})
1037 ),
1038 &format!(
1039 "[cost.prices.{model_id}] dropped (V3 puts pricing on each \
1040 [model_providers.<type>.<alias>] block); last-known rates: \
1041 input={input:?} output={output:?}"
1042 )
1043 );
1044 }
1045}
1046
1047fn synthesize_peer_group_from_allowlist(
1067 peer_groups: &mut toml::Table,
1068 channel_type: &str,
1069 channel_alias: &str,
1070 field_name: &str,
1071 allowed: toml::Value,
1072) {
1073 let toml::Value::Array(allowed) = allowed else {
1074 return;
1075 };
1076 let usernames: Vec<String> = allowed
1077 .iter()
1078 .filter_map(|v| v.as_str())
1079 .map(str::trim)
1080 .filter(|s| !s.is_empty() && *s != "*")
1081 .map(str::to_string)
1082 .collect();
1083 if usernames.is_empty() {
1084 return;
1085 }
1086 let group_name = format!("{channel_type}_{channel_alias}");
1087 if peer_groups.contains_key(&group_name) {
1088 return;
1090 }
1091 let mut group_entry = toml::Table::new();
1092 group_entry.insert(
1094 "channel".to_string(),
1095 toml::Value::String(channel_type.to_string()),
1096 );
1097 group_entry.insert(
1099 "agents".to_string(),
1100 toml::Value::Array(vec![toml::Value::String("default".to_string())]),
1101 );
1102 let external_peers: Vec<toml::Value> = usernames.into_iter().map(toml::Value::String).collect();
1103 group_entry.insert(
1104 "external_peers".to_string(),
1105 toml::Value::Array(external_peers),
1106 );
1107 peer_groups.insert(group_name, toml::Value::Table(group_entry));
1108 ::zeroclaw_log::record!(
1109 INFO,
1110 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
1111 ::serde_json::json!({"channel_type": channel_type, "channel_alias": channel_alias, "field_name": field_name})
1112 ),
1113 &format!(
1114 "channels.{channel_type}.{channel_alias}.{field_name} folded into [peer_groups.{channel_type}_{channel_alias}]"
1115 )
1116 );
1117}
1118
1119fn alias_wrap_channels(channels_value: toml::Value, peer_groups: &mut toml::Table) -> toml::Table {
1134 let mut channels_table = match channels_value {
1135 toml::Value::Table(t) => t,
1136 _ => return toml::Table::new(),
1137 };
1138 let mut new_channels = toml::Table::new();
1139
1140 if let Some(cli) = channels_table.remove("cli") {
1142 new_channels.insert("cli".to_string(), cli);
1143 }
1144
1145 fold_discord_history(&mut channels_table);
1148
1149 let stashed_feishu_v2 = strip_feishu_block(&mut channels_table);
1157
1158 for ct in V3_CHANNEL_TYPES {
1161 let Some(value) = channels_table.remove(*ct) else {
1162 continue;
1163 };
1164 let mut instance = match value {
1165 toml::Value::Table(t) => t,
1166 other => {
1167 let mut wrapped = toml::Table::new();
1171 wrapped.insert("default".to_string(), other);
1172 new_channels.insert((*ct).to_string(), toml::Value::Table(wrapped));
1173 continue;
1174 }
1175 };
1176 apply_v2_to_v3_channel_folds(ct, &mut instance);
1177 fold_channel_peer_auth_into_peer_groups(ct, &mut instance, peer_groups);
1178 let mut wrapped = toml::Table::new();
1183 wrapped.insert("default".to_string(), toml::Value::Table(instance));
1184 new_channels.insert((*ct).to_string(), toml::Value::Table(wrapped));
1185 }
1186
1187 if !channels_table.is_empty() {
1189 let leftover_keys: Vec<String> = channels_table.keys().cloned().collect();
1190 ::zeroclaw_log::record!(
1191 INFO,
1192 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1193 &format!(
1194 "[channels] passthrough for unmodeled keys: {:?}",
1195 leftover_keys
1196 )
1197 );
1198 for (k, v) in channels_table {
1199 new_channels.insert(k, v);
1200 }
1201 }
1202
1203 inject_feishu_as_lark_alias(&mut new_channels, stashed_feishu_v2);
1209
1210 new_channels
1211}
1212
1213fn strip_feishu_block(channels: &mut toml::Table) -> Option<toml::Table> {
1217 let feishu_value = channels.remove("feishu")?;
1218 match feishu_value {
1219 toml::Value::Table(t) => Some(t),
1220 _ => {
1221 ::zeroclaw_log::record!(
1222 WARN,
1223 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1224 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1225 "[channels.feishu] is not a table; dropping during fold to lark"
1226 );
1227 None
1228 }
1229 }
1230}
1231
1232fn inject_feishu_as_lark_alias(new_channels: &mut toml::Table, feishu_table: Option<toml::Table>) {
1243 let Some(mut feishu_table) = feishu_table else {
1244 return;
1245 };
1246
1247 feishu_table.insert("use_feishu".to_string(), toml::Value::Boolean(true));
1248
1249 let lark_entry = new_channels
1250 .entry("lark".to_string())
1251 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1252 let Some(lark_aliases) = lark_entry.as_table_mut() else {
1253 ::zeroclaw_log::record!(
1254 WARN,
1255 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1256 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1257 "[channels.lark] is not a table; cannot inject feishu alias"
1258 );
1259 return;
1260 };
1261
1262 if lark_aliases.contains_key("feishu") {
1263 ::zeroclaw_log::record!(
1264 WARN,
1265 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1266 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1267 "[channels.lark.feishu] already exists; the V2 [channels.feishu] \
1268 block was dropped to avoid clobbering it. Recover the dropped \
1269 value from the pre-migration <config>.backup if needed."
1270 );
1271 return;
1272 }
1273
1274 lark_aliases.insert("feishu".to_string(), toml::Value::Table(feishu_table));
1275 ::zeroclaw_log::record!(
1276 INFO,
1277 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1278 "[channels.feishu] folded into [channels.lark.feishu] (use_feishu=true)"
1279 );
1280}
1281
1282fn fold_discord_history(channels: &mut toml::Table) {
1295 let history_value = match channels.remove("discord_history") {
1296 Some(v) => v,
1297 None => return,
1298 };
1299
1300 let discord_bot_token = channels
1302 .get("discord")
1303 .and_then(toml::Value::as_table)
1304 .and_then(|t| t.get("bot_token"))
1305 .and_then(toml::Value::as_str)
1306 .map(ToString::to_string);
1307 let history_bot_token = history_value
1308 .as_table()
1309 .and_then(|t| t.get("bot_token"))
1310 .and_then(toml::Value::as_str)
1311 .map(ToString::to_string);
1312 let bot_token_conflict = match (&discord_bot_token, &history_bot_token) {
1313 (Some(d), Some(h)) => d != h,
1314 _ => false,
1315 };
1316
1317 let history_enabled = history_value
1318 .as_table()
1319 .and_then(|t| t.get("enabled"))
1320 .and_then(toml::Value::as_bool)
1321 .unwrap_or(false);
1322 let discord_enabled = channels
1323 .get("discord")
1324 .and_then(toml::Value::as_table)
1325 .and_then(|t| t.get("enabled"))
1326 .and_then(toml::Value::as_bool)
1327 .unwrap_or(false);
1328 let effective_enabled = discord_enabled || history_enabled;
1329
1330 let discord_entry = channels
1331 .entry("discord".to_string())
1332 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1333 if let Some(discord_table) = discord_entry.as_table_mut() {
1334 discord_table.insert("archive".to_string(), toml::Value::Boolean(true));
1335 if let toml::Value::Table(history_table) = history_value {
1336 for (k, v) in history_table {
1337 if k == "enabled" {
1338 continue;
1340 }
1341 discord_table.entry(k).or_insert(v);
1342 }
1343 }
1344 discord_table.insert(
1345 "enabled".to_string(),
1346 toml::Value::Boolean(effective_enabled),
1347 );
1348 }
1349 ::zeroclaw_log::record!(
1350 INFO,
1351 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1352 .with_attrs(::serde_json::json!({"effective_enabled": effective_enabled})),
1353 "[channels.discord_history] folded into [channels.discord] (archive=true, effective enabled=)"
1354 );
1355 if bot_token_conflict {
1356 ::zeroclaw_log::record!(
1357 WARN,
1358 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1359 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1360 "[channels.discord_history].bot_token differed from [channels.discord].bot_token; \
1361 the discord_history token was dropped and the discord token survives. \
1362 Two-bot deployments must reconfigure manually — recover the dropped value \
1363 from the pre-migration <config>.backup file adjacent to the migrated config."
1364 );
1365 }
1366}
1367
1368fn apply_v2_to_v3_channel_folds(channel_type: &str, instance: &mut toml::Table) {
1373 use crate::migration::fold_string_into_array;
1374 match channel_type {
1375 "discord" if fold_string_into_array(instance, "guild_id", "guild_ids") => {
1376 ::zeroclaw_log::record!(
1377 INFO,
1378 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1379 "channels.discord.guild_id folded into channels.discord.guild_ids[]"
1380 );
1381 }
1382 "mattermost" if fold_string_into_array(instance, "channel_id", "channel_ids") => {
1383 ::zeroclaw_log::record!(
1384 INFO,
1385 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1386 "channels.mattermost.channel_id folded into channels.mattermost.channel_ids[]"
1387 );
1388 }
1389 "reddit" if fold_string_into_array(instance, "subreddit", "subreddits") => {
1390 ::zeroclaw_log::record!(
1391 INFO,
1392 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1393 "channels.reddit.subreddit folded into channels.reddit.subreddits[]"
1394 );
1395 }
1396 "signal" => {
1397 if let Some(toml::Value::String(group_id)) = instance.remove("group_id")
1401 && !group_id.is_empty()
1402 {
1403 if group_id == "dm" {
1404 instance.insert("dm_only".to_string(), toml::Value::Boolean(true));
1405 ::zeroclaw_log::record!(
1406 INFO,
1407 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1408 "channels.signal.group_id=\"dm\" → channels.signal.dm_only=true"
1409 );
1410 } else {
1411 let entry = instance
1412 .entry("group_ids".to_string())
1413 .or_insert_with(|| toml::Value::Array(Vec::new()));
1414 if let Some(arr) = entry.as_array_mut() {
1415 let already = arr.iter().any(|v| v.as_str() == Some(group_id.as_str()));
1416 if !already {
1417 arr.push(toml::Value::String(group_id));
1418 }
1419 }
1420 ::zeroclaw_log::record!(
1421 INFO,
1422 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1423 "channels.signal.group_id folded into channels.signal.group_ids[]"
1424 );
1425 }
1426 }
1427 }
1428 _ => {}
1429 }
1430}
1431
1432fn fold_channel_peer_auth_into_peer_groups(
1451 channel_type: &str,
1452 instance: &mut toml::Table,
1453 peer_groups: &mut toml::Table,
1454) {
1455 let Some(field_name) = (match channel_type {
1456 "telegram" | "discord" | "slack" | "mattermost" | "matrix" | "nextcloud_talk" | "irc"
1457 | "lark" | "line" | "feishu" | "dingtalk" | "wecom" | "wechat" | "qq" | "twitter"
1458 | "mochat" => Some("allowed_users"),
1459 "imessage" => Some("allowed_contacts"),
1460 "signal" => Some("allowed_from"),
1461 "whatsapp" | "wati" => Some("allowed_numbers"),
1462 "linq" | "email" | "gmail_push" => Some("allowed_senders"),
1463 "nostr" => Some("allowed_pubkeys"),
1464 _ => None,
1465 }) else {
1466 return;
1467 };
1468 if let Some(allowed) = instance.remove(field_name) {
1469 synthesize_peer_group_from_allowlist(
1470 peer_groups,
1471 channel_type,
1472 "default",
1473 field_name,
1474 allowed,
1475 );
1476 }
1477}
1478
1479fn synthesize_agent_brains(
1500 agents: HashMap<String, toml::Value>,
1501 passthrough: &mut toml::Table,
1502) -> toml::Table {
1503 let mut new_agents = toml::Table::new();
1504 for (alias, agent_value) in agents {
1505 let mut agent_table = match agent_value {
1506 toml::Value::Table(t) => t,
1507 other => {
1508 new_agents.insert(alias, other);
1509 continue;
1510 }
1511 };
1512
1513 let provider = agent_table.remove("provider");
1518 let model = agent_table.remove("model");
1519 let api_key = agent_table.remove("api_key");
1520 let temperature = agent_table.remove("temperature");
1521 let provider_timeout_secs = extract_provider_timeout_secs(&mut agent_table);
1522 if let Some(toml::Value::String(raw_provider)) = provider {
1523 let (provider_type, colon_url) = split_colon_url_provider(&raw_provider);
1528 let provider_alias = format!("agent_{}", alias);
1529 let mut entry = toml::Table::new();
1530 if let Some(url) = colon_url {
1531 entry.insert("uri".to_string(), toml::Value::String(url));
1532 }
1533 if let Some(m) = model {
1534 entry.insert("model".to_string(), m);
1535 }
1536 if let Some(k) = api_key {
1537 entry.insert("api_key".to_string(), k);
1538 }
1539 if let Some(t) = temperature {
1540 entry.insert("temperature".to_string(), t);
1541 }
1542 if let Some(t) = provider_timeout_secs {
1543 entry.insert("timeout_secs".to_string(), t);
1544 }
1545 let providers_value = passthrough
1548 .entry("providers".to_string())
1549 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1550 if let Some(providers_table) = providers_value.as_table_mut() {
1551 let models_value = providers_table
1552 .entry("models".to_string())
1553 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1554 if let Some(models_table) = models_value.as_table_mut() {
1555 let provider_value = models_table
1556 .entry(provider_type.clone())
1557 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1558 if let Some(provider_table) = provider_value.as_table_mut() {
1559 provider_table.insert(provider_alias.clone(), toml::Value::Table(entry));
1560 }
1561 }
1562 }
1563 agent_table.insert(
1564 "model_provider".to_string(),
1565 toml::Value::String(format!("{provider_type}.{provider_alias}")),
1566 );
1567 ::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..");
1568 } else {
1569 if provider_timeout_secs.is_some() {
1573 ::zeroclaw_log::record!(
1574 WARN,
1575 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1576 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1577 .with_attrs(::serde_json::json!({"alias": alias})),
1578 "agents..timeout_secs dropped: V3 stores it on \
1579 [model_providers.<type>.<alias>] and this agent has no \
1580 inline provider to fold it onto. Set it manually after \
1581 migration."
1582 );
1583 }
1584 if let Some(other) = provider {
1585 agent_table.insert("provider".to_string(), other);
1586 }
1587 }
1588
1589 let max_iterations = agent_table
1592 .remove("max_iterations")
1593 .or_else(|| agent_table.remove("max_tool_iterations"));
1594
1595 let allowed_tools = agent_table.remove("allowed_tools");
1601 let agentic_flag = agent_table.remove("agentic");
1602 let max_depth = agent_table.remove("max_depth");
1603 let agentic_timeout_secs = extract_agentic_timeout_secs(&mut agent_table);
1604
1605 let profile_alias = format!("agent_{}", alias);
1606
1607 if let Some(at_value) = allowed_tools {
1608 let mut overrides = toml::Table::new();
1609 overrides.insert("allowed_tools".to_string(), at_value);
1610 install_profile_entry(passthrough, "risk_profiles", &profile_alias, overrides);
1611 agent_table
1612 .entry("risk_profile".to_string())
1613 .or_insert_with(|| toml::Value::String(profile_alias.clone()));
1614 ::zeroclaw_log::record!(
1615 INFO,
1616 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1617 .with_attrs(
1618 ::serde_json::json!({"alias": alias, "profile_alias": profile_alias})
1619 ),
1620 "agents..allowed_tools → risk_profiles..allowed_tools"
1621 );
1622 }
1623
1624 if agentic_flag.is_some()
1625 || max_depth.is_some()
1626 || agentic_timeout_secs.is_some()
1627 || max_iterations.is_some()
1628 {
1629 let mut overrides = toml::Table::new();
1630 if let Some(v) = agentic_flag {
1631 overrides.insert("agentic".to_string(), v);
1632 }
1633 if let Some(d) = max_depth {
1634 overrides.insert("max_delegation_depth".to_string(), d);
1635 }
1636 if let Some(t) = agentic_timeout_secs {
1637 overrides.insert("agentic_timeout_secs".to_string(), t);
1638 }
1639 if let Some(mi) = max_iterations {
1640 overrides.insert("max_tool_iterations".to_string(), mi);
1641 }
1642 install_profile_entry(passthrough, "runtime_profiles", &profile_alias, overrides);
1643 agent_table
1644 .entry("runtime_profile".to_string())
1645 .or_insert_with(|| toml::Value::String(profile_alias.clone()));
1646 ::zeroclaw_log::record!(
1647 INFO,
1648 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1649 .with_attrs(
1650 ::serde_json::json!({"alias": alias, "profile_alias": profile_alias})
1651 ),
1652 "agents.: agentic/max_depth/agentic_timeout_secs/max_iterations → runtime_profiles."
1653 );
1654 }
1655
1656 if let Some(toml::Value::String(skills_dir)) = agent_table.remove("skills_directory")
1665 && !skills_dir.is_empty()
1666 {
1667 let bundle_alias = format!("agent_{}", alias);
1668 let mut bundle_entry = toml::Table::new();
1669 let trimmed = skills_dir.trim().trim_start_matches("./");
1670 let stays_inside_shared = !std::path::Path::new(trimmed).is_absolute()
1671 && (trimmed == "shared" || trimmed.starts_with("shared/"));
1672 if stays_inside_shared {
1673 bundle_entry.insert("directory".to_string(), toml::Value::String(skills_dir));
1674 } else {
1675 ::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 \
1676 <install>/shared/. V3 confines skill-bundles to \
1677 <install>/shared/skills/<alias>/; the path was dropped and the bundle \
1678 falls back to the default. Copy the V2 skill files into \
1679 <install>/shared/skills// to restore them.");
1680 }
1681 install_profile_entry(passthrough, "skill_bundles", &bundle_alias, bundle_entry);
1682 let existing = agent_table
1685 .remove("skill_bundles")
1686 .and_then(|v| match v {
1687 toml::Value::Array(a) => Some(a),
1688 _ => None,
1689 })
1690 .unwrap_or_default();
1691 let mut new_list = existing;
1692 let already = new_list
1693 .iter()
1694 .any(|v| v.as_str() == Some(bundle_alias.as_str()));
1695 if !already {
1696 new_list.push(toml::Value::String(bundle_alias.clone()));
1697 }
1698 agent_table.insert("skill_bundles".to_string(), toml::Value::Array(new_list));
1699 ::zeroclaw_log::record!(
1700 INFO,
1701 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1702 .with_attrs(
1703 ::serde_json::json!({"alias": alias, "bundle_alias": bundle_alias})
1704 ),
1705 "agents..skills_directory → [skill_bundles.] (referenced \
1706 from agents..skill_bundles)"
1707 );
1708 }
1709
1710 let agent_risk = agent_table
1715 .get("risk_profile")
1716 .and_then(toml::Value::as_str)
1717 .map(ToString::to_string)
1718 .filter(|s| !s.is_empty());
1719 let risk_alias = agent_risk.unwrap_or_else(|| "default".to_string());
1720 ensure_profile_entry(passthrough, "risk_profiles", &risk_alias);
1721 agent_table.insert("risk_profile".to_string(), toml::Value::String(risk_alias));
1722
1723 let agent_runtime = agent_table
1724 .get("runtime_profile")
1725 .and_then(toml::Value::as_str)
1726 .map(ToString::to_string)
1727 .filter(|s| !s.is_empty());
1728 let runtime_alias = agent_runtime.unwrap_or_else(|| "default".to_string());
1729 ensure_profile_entry(passthrough, "runtime_profiles", &runtime_alias);
1730 agent_table.insert(
1731 "runtime_profile".to_string(),
1732 toml::Value::String(runtime_alias),
1733 );
1734
1735 agent_table.remove("memory_namespace");
1741
1742 new_agents.insert(alias, toml::Value::Table(agent_table));
1743 }
1744 new_agents
1745}
1746
1747fn extract_agentic_timeout_secs(agent: &mut toml::Table) -> Option<toml::Value> {
1751 agent.remove("agentic_timeout_secs")
1752}
1753
1754fn extract_provider_timeout_secs(agent: &mut toml::Table) -> Option<toml::Value> {
1757 agent.remove("timeout_secs")
1758}
1759
1760fn install_profile_entry(
1763 passthrough: &mut toml::Table,
1764 section: &str,
1765 alias: &str,
1766 fields: toml::Table,
1767) {
1768 let section_value = passthrough
1769 .entry(section.to_string())
1770 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1771 if let Some(section_table) = section_value.as_table_mut() {
1772 let alias_value = section_table
1773 .entry(alias.to_string())
1774 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1775 if let Some(alias_table) = alias_value.as_table_mut() {
1776 for (k, v) in fields {
1777 alias_table.entry(k).or_insert(v);
1778 }
1779 }
1780 }
1781}
1782
1783fn merge_into_table(top: &mut toml::Table, section: &str, extras: toml::Table) {
1787 let entry = top
1788 .entry(section.to_string())
1789 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1790 if let Some(section_table) = entry.as_table_mut() {
1791 for (k, v) in extras {
1792 section_table.insert(k, v);
1793 }
1794 }
1795}
1796
1797fn fold_base_url_api_path_into_uri(entry: &mut toml::Table) {
1803 if entry.contains_key("uri") {
1804 entry.remove("base_url");
1807 entry.remove("api_path");
1808 return;
1809 }
1810 let base = match entry.remove("base_url") {
1811 Some(toml::Value::String(s)) if !s.is_empty() => s,
1812 _ => {
1813 entry.remove("api_path");
1815 return;
1816 }
1817 };
1818 let path = match entry.remove("api_path") {
1819 Some(toml::Value::String(p)) if !p.is_empty() => Some(p),
1820 _ => None,
1821 };
1822 let uri = match path {
1823 Some(p) => {
1824 let trimmed = base.trim_end_matches('/');
1825 let suffix = if p.starts_with('/') {
1826 p
1827 } else {
1828 format!("/{p}")
1829 };
1830 format!("{trimmed}{suffix}")
1831 }
1832 None => base,
1833 };
1834 entry.insert("uri".to_string(), toml::Value::String(uri));
1835}
1836
1837fn rewrite_dangling_peer_group_agents(passthrough: &mut toml::Table) {
1856 let replacement_alias = {
1857 let Some(agents_table) = passthrough.get("agents").and_then(toml::Value::as_table) else {
1858 return;
1859 };
1860 if agents_table.is_empty() || agents_table.contains_key("default") {
1861 return;
1862 }
1863 let Some(alias) = agents_table.keys().next().cloned() else {
1864 return;
1865 };
1866 alias
1867 };
1868
1869 let mut rewritten_channel_types: Vec<String> = Vec::new();
1870 {
1871 let Some(toml::Value::Table(peer_groups)) = passthrough.get_mut("peer_groups") else {
1872 return;
1873 };
1874 for (group_name, group_value) in peer_groups.iter_mut() {
1875 let Some(group_table) = group_value.as_table_mut() else {
1876 continue;
1877 };
1878 let Some(toml::Value::Array(agents_arr)) = group_table.get("agents") else {
1879 continue;
1880 };
1881 let only_default = agents_arr.len() == 1 && agents_arr[0].as_str() == Some("default");
1882 if !only_default {
1883 continue;
1884 }
1885 group_table.insert(
1886 "agents".to_string(),
1887 toml::Value::Array(vec![toml::Value::String(replacement_alias.clone())]),
1888 );
1889 if let Some(toml::Value::String(channel_ref)) = group_table.get("channel") {
1890 rewritten_channel_types.push(channel_ref.clone());
1891 }
1892 ::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)");
1893 }
1894 }
1895
1896 if rewritten_channel_types.is_empty() {
1897 return;
1898 }
1899
1900 let mut resolved_refs: Vec<String> = Vec::new();
1906 if let Some(toml::Value::Table(channels_table)) = passthrough.get("channels") {
1907 for channel_type in &rewritten_channel_types {
1908 let aliases = channels_table
1909 .get(channel_type)
1910 .and_then(toml::Value::as_table)
1911 .map(|t| t.keys().cloned().collect::<Vec<_>>())
1912 .unwrap_or_default();
1913 for alias in aliases {
1914 let dotted = format!("{channel_type}.{alias}");
1915 if !resolved_refs.contains(&dotted) {
1916 resolved_refs.push(dotted);
1917 }
1918 }
1919 }
1920 }
1921 if resolved_refs.is_empty() {
1922 return;
1923 }
1924
1925 let Some(toml::Value::Table(agents_table)) = passthrough.get_mut("agents") else {
1926 return;
1927 };
1928 let Some(toml::Value::Table(agent_entry)) = agents_table.get_mut(&replacement_alias) else {
1929 return;
1930 };
1931 let channels_array = agent_entry
1932 .entry("channels".to_string())
1933 .or_insert_with(|| toml::Value::Array(Vec::new()));
1934 let Some(channels_arr) = channels_array.as_array_mut() else {
1935 return;
1936 };
1937 let mut added: Vec<String> = Vec::new();
1938 for ch in &resolved_refs {
1939 let present = channels_arr.iter().any(|v| v.as_str() == Some(ch.as_str()));
1940 if !present {
1941 channels_arr.push(toml::Value::String(ch.clone()));
1942 added.push(ch.clone());
1943 }
1944 }
1945 if !added.is_empty() {
1946 ::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");
1947 }
1948}
1949
1950fn backfill_heartbeat_agent(passthrough: &mut toml::Table) {
1956 let needs_backfill = passthrough
1957 .get("heartbeat")
1958 .and_then(toml::Value::as_table)
1959 .is_some_and(|hb| {
1960 let enabled = hb
1961 .get("enabled")
1962 .and_then(toml::Value::as_bool)
1963 .unwrap_or(false);
1964 let agent_set = hb
1965 .get("agent")
1966 .and_then(toml::Value::as_str)
1967 .is_some_and(|s| !s.trim().is_empty());
1968 enabled && !agent_set
1969 });
1970 if !needs_backfill {
1971 return;
1972 }
1973 let alias = passthrough
1974 .get("agents")
1975 .and_then(toml::Value::as_table)
1976 .and_then(|agents| {
1977 if agents.contains_key("default") {
1978 Some("default".to_string())
1979 } else {
1980 agents.keys().next().cloned()
1981 }
1982 });
1983 let Some(alias) = alias else {
1984 return;
1985 };
1986 if let Some(toml::Value::Table(hb)) = passthrough.get_mut("heartbeat") {
1987 hb.insert("agent".to_string(), toml::Value::String(alias.clone()));
1988 ::zeroclaw_log::record!(
1989 INFO,
1990 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1991 .with_attrs(::serde_json::json!({"alias": format!("{:?}", alias)})),
1992 &format!(
1993 "heartbeat.agent unset with heartbeat.enabled = true → backfilled to {alias:?}"
1994 )
1995 );
1996 }
1997}
1998
1999fn ensure_profile_entry(passthrough: &mut toml::Table, section: &str, alias: &str) {
2003 let entry = passthrough
2004 .entry(section.to_string())
2005 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2006 if let Some(section_table) = entry.as_table_mut() {
2007 section_table
2008 .entry(alias.to_string())
2009 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2010 }
2011}
2012
2013fn lift_top_level_identity_into_agents(passthrough: &mut toml::Table) {
2021 let Some(identity_value) = passthrough.remove("identity") else {
2022 return;
2023 };
2024 let Some(agents_value) = passthrough.get_mut("agents") else {
2025 ::zeroclaw_log::record!(
2026 WARN,
2027 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2028 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
2029 "[identity] dropped during V2->V3 (no [agents] table to attach to)"
2030 );
2031 return;
2032 };
2033 let Some(agents_table) = agents_value.as_table_mut() else {
2034 return;
2035 };
2036 if agents_table.is_empty() {
2037 ::zeroclaw_log::record!(
2038 WARN,
2039 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2040 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
2041 "[identity] dropped during V2->V3 (agents map empty after fold)"
2042 );
2043 return;
2044 }
2045 let aliases: Vec<String> = agents_table.keys().cloned().collect();
2046 let mut folded = 0usize;
2047 for alias in &aliases {
2048 let Some(agent_table) = agents_table
2049 .get_mut(alias)
2050 .and_then(toml::Value::as_table_mut)
2051 else {
2052 continue;
2053 };
2054 if agent_table.contains_key("identity") {
2055 continue;
2056 }
2057 agent_table.insert("identity".to_string(), identity_value.clone());
2058 folded += 1;
2059 }
2060 ::zeroclaw_log::record!(
2061 INFO,
2062 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2063 .with_attrs(::serde_json::json!({"folded": folded})),
2064 &format!("[identity] lifted into [agents.<alias>.identity] ({folded} agent(s))")
2065 );
2066}
2067
2068fn synthesize_default_agent_if_needed(passthrough: &toml::Table) -> toml::Table {
2077 let models = match passthrough
2080 .get("providers")
2081 .and_then(toml::Value::as_table)
2082 .and_then(|providers| providers.get("models"))
2083 .and_then(toml::Value::as_table)
2084 {
2085 Some(t) => t,
2086 None => return toml::Table::new(),
2087 };
2088 let first_alias = models.iter().find_map(|(provider_type, value)| {
2089 let inner = value.as_table()?;
2090 let alias = inner.keys().next()?;
2091 Some(format!("{provider_type}.{alias}"))
2092 });
2093 let alias_ref = match first_alias {
2094 Some(s) => s,
2095 None => return toml::Table::new(),
2096 };
2097
2098 let mut default_agent = toml::Table::new();
2099 default_agent.insert("model_provider".to_string(), toml::Value::String(alias_ref));
2100 default_agent.insert(
2101 "risk_profile".to_string(),
2102 toml::Value::String("default".into()),
2103 );
2104 default_agent.insert(
2105 "runtime_profile".to_string(),
2106 toml::Value::String("default".into()),
2107 );
2108
2109 let mut agents = toml::Table::new();
2110 agents.insert("default".to_string(), toml::Value::Table(default_agent));
2111 ::zeroclaw_log::record!(
2112 INFO,
2113 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2114 "synthesized [agents.default] from V1/V2 implicit single-agent semantics"
2115 );
2116 agents
2117}
2118
2119const V3_TTS_TYPES: &[&str] = &["openai", "elevenlabs", "google", "edge", "piper"];
2122
2123fn fold_v2_tts_into_providers(passthrough: &mut toml::Table, new_providers: &mut toml::Table) {
2133 let Some(toml::Value::Table(tts_table)) = passthrough.get_mut("tts") else {
2134 return;
2135 };
2136
2137 let mut tts_aliased = toml::Table::new();
2138 for ty in V3_TTS_TYPES {
2139 if let Some(mut value) = tts_table.remove(*ty) {
2140 if *ty == "elevenlabs"
2144 && let Some(t) = value.as_table_mut()
2145 && let Some(v) = t.remove("model_id")
2146 {
2147 t.entry("model".to_string()).or_insert(v);
2148 ::zeroclaw_log::record!(
2149 INFO,
2150 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2151 "tts.elevenlabs.model_id renamed to tts.elevenlabs.model"
2152 );
2153 }
2154 let mut wrapped = toml::Table::new();
2155 wrapped.insert("default".to_string(), value);
2156 tts_aliased.insert((*ty).to_string(), toml::Value::Table(wrapped));
2157 }
2158 }
2159
2160 if tts_table.remove("default_provider").is_some() {
2161 ::zeroclaw_log::record!(
2162 INFO,
2163 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2164 "[tts].default_provider dropped (V3 has no global default-provider; set agent.<X>.tts_provider instead)"
2165 );
2166 }
2167
2168 if !tts_aliased.is_empty() {
2169 new_providers.insert("tts".to_string(), toml::Value::Table(tts_aliased));
2170 ::zeroclaw_log::record!(
2171 INFO,
2172 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2173 "[tts.<type>] sub-blocks promoted to [tts_providers.<type>.default]"
2174 );
2175 }
2176}
2177
2178fn fold_v2_transcription_into_providers(
2192 passthrough: &mut toml::Table,
2193 new_providers: &mut toml::Table,
2194) {
2195 let Some(toml::Value::Table(transcription_table)) = passthrough.get_mut("transcription") else {
2196 return;
2197 };
2198
2199 let mut transcription_aliased = toml::Table::new();
2200
2201 const V3_TRANSCRIPTION_FAMILIES: &[&str] = &[
2203 "openai",
2204 "deepgram",
2205 "assemblyai",
2206 "google",
2207 "local_whisper",
2208 ];
2209 for family in V3_TRANSCRIPTION_FAMILIES {
2210 if let Some(value) = transcription_table.remove(*family) {
2211 let mut wrapped = toml::Table::new();
2212 wrapped.insert("default".to_string(), value);
2213 transcription_aliased.insert((*family).to_string(), toml::Value::Table(wrapped));
2214 }
2215 }
2216
2217 let mut groq_entry = toml::Table::new();
2223 for groq_field in &["api_key", "api_url", "model", "language", "initial_prompt"] {
2224 if let Some(v) = transcription_table.remove(*groq_field) {
2225 groq_entry.insert((*groq_field).to_string(), v);
2226 }
2227 }
2228 if !groq_entry.is_empty() {
2229 let mut wrapped = toml::Table::new();
2230 wrapped.insert("default".to_string(), toml::Value::Table(groq_entry));
2231 transcription_aliased.insert("groq".to_string(), toml::Value::Table(wrapped));
2232 ::zeroclaw_log::record!(
2233 INFO,
2234 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2235 "[transcription] Groq fields promoted to [transcription_providers.groq.default]"
2236 );
2237 }
2238
2239 for legacy_default in &[
2243 "default_provider",
2244 "default_model_provider",
2245 "default_transcription_provider",
2246 ] {
2247 if transcription_table.remove(*legacy_default).is_some() {
2248 ::zeroclaw_log::record!(
2249 INFO,
2250 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2251 .with_attrs(::serde_json::json!({"legacy_default": legacy_default})),
2252 &format!(
2253 "[transcription].{legacy_default} dropped (V3 has no global default-provider; set agent.<X>.transcription_provider instead)"
2254 )
2255 );
2256 }
2257 }
2258
2259 if !transcription_aliased.is_empty() {
2260 let providers_transcription = new_providers
2263 .entry("transcription".to_string())
2264 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2265 if let Some(existing) = providers_transcription.as_table_mut() {
2266 for (family, value) in transcription_aliased {
2267 existing.entry(family).or_insert(value);
2268 }
2269 }
2270 ::zeroclaw_log::record!(
2271 INFO,
2272 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2273 "[transcription.<family>] sub-blocks promoted to [transcription_providers.<family>.default]"
2274 );
2275 }
2276}
2277
2278fn rename_route_provider_field(new_providers: &mut toml::Table, routes_key: &str) {
2284 let Some(toml::Value::Array(routes)) = new_providers.get_mut(routes_key) else {
2285 return;
2286 };
2287 let mut renamed = 0usize;
2288 let mut promoted = 0usize;
2289 for entry in routes.iter_mut() {
2290 let toml::Value::Table(t) = entry else {
2291 continue;
2292 };
2293 if t.contains_key("model_provider") {
2294 t.remove("provider");
2298 } else if let Some(value) = t.remove("provider") {
2299 t.insert("model_provider".to_string(), value);
2300 renamed += 1;
2301 }
2302 if let Some(toml::Value::String(s)) = t.get_mut("model_provider")
2307 && !s.is_empty()
2308 && !s.contains('.')
2309 {
2310 *s = format!("{s}.default");
2311 promoted += 1;
2312 }
2313 }
2314 if renamed > 0 {
2315 ::zeroclaw_log::record!(
2316 INFO,
2317 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2318 .with_attrs(::serde_json::json!({"routes_key": routes_key, "renamed": renamed})),
2319 "[providers.] entry/entries: `provider` field renamed to `model_provider`"
2320 );
2321 }
2322 if promoted > 0 {
2323 ::zeroclaw_log::record!(
2324 INFO,
2325 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2326 .with_attrs(::serde_json::json!({"routes_key": routes_key, "promoted": promoted})),
2327 "[providers.] entry/entries: bare `model_provider` promoted to dotted `<type>.default` form"
2328 );
2329 }
2330}
2331
2332fn fold_v2_storage_subsystems(passthrough: &mut toml::Table) {
2350 let (memory_qdrant, memory_postgres, memory_sqlite_timeout) = match passthrough
2351 .get_mut("memory")
2352 .and_then(toml::Value::as_table_mut)
2353 {
2354 Some(memory) => (
2355 memory.remove("qdrant"),
2356 memory.remove("postgres"),
2357 memory.remove("sqlite_open_timeout_secs"),
2358 ),
2359 None => (None, None, None),
2360 };
2361
2362 let storage_provider = match passthrough
2363 .get_mut("storage")
2364 .and_then(toml::Value::as_table_mut)
2365 {
2366 Some(storage) => storage.remove("provider"),
2367 None => None,
2368 };
2369
2370 if memory_qdrant.is_none()
2371 && memory_postgres.is_none()
2372 && memory_sqlite_timeout.is_none()
2373 && storage_provider.is_none()
2374 {
2375 return;
2376 }
2377
2378 let storage_entry = passthrough
2379 .entry("storage".to_string())
2380 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2381 let Some(storage_table) = storage_entry.as_table_mut() else {
2382 return;
2383 };
2384
2385 if let Some(toml::Value::Table(qdrant_data)) = memory_qdrant {
2386 merge_storage_default(storage_table, "qdrant", qdrant_data);
2387 ::zeroclaw_log::record!(
2388 INFO,
2389 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2390 "[memory.qdrant] promoted to [storage.qdrant.default]"
2391 );
2392 }
2393 if let Some(timeout_value) = memory_sqlite_timeout {
2394 let mut sqlite_fields = toml::Table::new();
2395 sqlite_fields.insert("open_timeout_secs".to_string(), timeout_value);
2396 merge_storage_default(storage_table, "sqlite", sqlite_fields);
2397 ::zeroclaw_log::record!(
2398 INFO,
2399 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2400 "memory.sqlite_open_timeout_secs → [storage.sqlite.default].open_timeout_secs"
2401 );
2402 }
2403 if let Some(toml::Value::Table(postgres_vector_data)) = memory_postgres {
2404 merge_storage_default(storage_table, "postgres", postgres_vector_data);
2405 ::zeroclaw_log::record!(
2406 INFO,
2407 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2408 "[memory.postgres] vector fields promoted to [storage.postgres.default]"
2409 );
2410 }
2411
2412 if let Some(provider_section_value) = storage_provider {
2413 let config_table = match provider_section_value {
2418 toml::Value::Table(mut section) => {
2419 if let Some(toml::Value::Table(inner)) = section.remove("config") {
2420 inner
2421 } else {
2422 section
2423 }
2424 }
2425 _ => {
2426 drop_empty_subsystem_blocks(passthrough);
2427 return;
2428 }
2429 };
2430 if config_table.is_empty() {
2431 drop_empty_subsystem_blocks(passthrough);
2432 return;
2433 }
2434
2435 let (provider_type, mut adapted_fields) = adapt_storage_provider_config(config_table);
2436 if !adapted_fields.is_empty() {
2437 merge_storage_default(
2441 storage_table,
2442 &provider_type,
2443 std::mem::take(&mut adapted_fields),
2444 );
2445 ::zeroclaw_log::record!(
2446 INFO,
2447 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2448 .with_attrs(::serde_json::json!({"provider_type": provider_type})),
2449 "[storage.provider.config provider=] promoted to [storage..default]"
2450 );
2451 }
2452 }
2453
2454 drop_empty_subsystem_blocks(passthrough);
2455}
2456
2457fn drop_empty_subsystem_blocks(passthrough: &mut toml::Table) {
2463 for key in ["memory", "storage"] {
2464 if let Some(toml::Value::Table(t)) = passthrough.get(key)
2465 && t.is_empty()
2466 {
2467 passthrough.remove(key);
2468 }
2469 }
2470}
2471
2472fn adapt_storage_provider_config(mut config: toml::Table) -> (String, toml::Table) {
2476 let provider_type = config
2477 .remove("provider")
2478 .and_then(|v| match v {
2479 toml::Value::String(s) if !s.is_empty() => Some(s),
2480 _ => None,
2481 })
2482 .unwrap_or_else(|| "sqlite".to_string());
2483
2484 match provider_type.as_str() {
2485 "sqlite" => {
2486 let mut out = toml::Table::new();
2487 if let Some(toml::Value::String(db_url)) = config.remove("db_url") {
2489 let path = db_url
2490 .strip_prefix("sqlite://")
2491 .or_else(|| db_url.strip_prefix("sqlite:"))
2492 .map(ToString::to_string)
2493 .unwrap_or(db_url);
2494 if !path.is_empty() {
2495 out.insert("path".to_string(), toml::Value::String(path));
2496 }
2497 }
2498 if let Some(v) = config.remove("connect_timeout_secs") {
2500 out.insert("open_timeout_secs".to_string(), v);
2501 }
2502 (provider_type, out)
2504 }
2505 "postgres" => {
2506 (provider_type, config)
2508 }
2509 "qdrant" => {
2510 let mut out = toml::Table::new();
2511 if let Some(v) = config.remove("db_url") {
2512 out.insert("url".to_string(), v);
2513 }
2514 (provider_type, out)
2516 }
2517 _ => {
2518 ::zeroclaw_log::record!(
2519 INFO,
2520 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2521 .with_attrs(
2522 ::serde_json::json!({"provider_type": format!("{:?}", provider_type)})
2523 ),
2524 "[storage.provider.config] unknown provider type ; passthrough as-is"
2525 );
2526 (provider_type, config)
2527 }
2528 }
2529}
2530
2531fn merge_storage_default(storage_table: &mut toml::Table, backend_type: &str, fields: toml::Table) {
2534 let backend_entry = storage_table
2535 .entry(backend_type.to_string())
2536 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2537 if let Some(backend_table) = backend_entry.as_table_mut() {
2538 let default_entry = backend_table
2539 .entry("default".to_string())
2540 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2541 if let Some(default_table) = default_entry.as_table_mut() {
2542 for (k, v) in fields {
2543 default_table.entry(k).or_insert(v);
2544 }
2545 }
2546 }
2547}
2548
2549fn fold_security_into_risk_profile(passthrough: &mut toml::Table) {
2567 let (sandbox, resources) = {
2568 let security_table = match passthrough
2569 .get_mut("security")
2570 .and_then(toml::Value::as_table_mut)
2571 {
2572 Some(t) => t,
2573 None => return,
2574 };
2575 (
2576 security_table.remove("sandbox"),
2577 security_table.remove("resources"),
2578 )
2579 };
2580 if sandbox.is_none() && resources.is_none() {
2581 return;
2582 }
2583
2584 if let Some(toml::Value::Table(resources_table)) = resources
2585 && !resources_table.is_empty()
2586 {
2587 let dropped: Vec<String> = resources_table
2588 .iter()
2589 .map(|(k, v)| format!("{k}={v}"))
2590 .collect();
2591 ::zeroclaw_log::record!(
2592 WARN,
2593 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2594 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
2595 &format!(
2596 "[security.resources] dropped during V2→V3 migration (no V3 enforcement \
2597 codepath existed; sandbox backends own resource budgets): {}",
2598 dropped.join(", ")
2599 )
2600 );
2601 }
2602
2603 let Some(toml::Value::Table(sandbox_table)) = sandbox else {
2604 return;
2605 };
2606 if sandbox_table.is_empty() {
2607 return;
2608 }
2609
2610 let risk_profiles = passthrough
2611 .entry("risk_profiles".to_string())
2612 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2613 let Some(risk_profiles_table) = risk_profiles.as_table_mut() else {
2614 return;
2615 };
2616 let default_entry = risk_profiles_table
2617 .entry("default".to_string())
2618 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2619 let Some(default_profile) = default_entry.as_table_mut() else {
2620 return;
2621 };
2622
2623 for (k, v) in sandbox_table {
2624 let target_key = match k.as_str() {
2625 "enabled" => "sandbox_enabled",
2626 "backend" => "sandbox_backend",
2627 "firejail_args" => "firejail_args",
2628 _ => continue,
2629 };
2630 default_profile.entry(target_key.to_string()).or_insert(v);
2631 }
2632 ::zeroclaw_log::record!(
2633 INFO,
2634 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2635 "[security.sandbox] folded into [risk_profiles.default]"
2636 );
2637}
2638
2639fn split_autonomy_into_profile_buckets(
2648 value: toml::Value,
2649) -> (Option<toml::Table>, Option<toml::Table>) {
2650 let Ok(table) = value.try_into::<toml::Table>() else {
2651 return (None, None);
2652 };
2653 const RUNTIME_FIELDS: &[&str] = &[
2654 "max_actions_per_hour",
2655 "max_cost_per_day_cents",
2656 "shell_timeout_secs",
2657 "max_delegation_depth",
2658 "delegation_timeout_secs",
2659 "agentic_timeout_secs",
2660 ];
2661 let mut risk = toml::Table::new();
2662 let mut runtime = toml::Table::new();
2663 for (k, v) in table {
2664 if RUNTIME_FIELDS.contains(&k.as_str()) {
2665 runtime.insert(k, v);
2666 } else {
2667 risk.insert(k, v);
2668 }
2669 }
2670 let risk = (!risk.is_empty()).then_some(risk);
2671 let runtime = (!runtime.is_empty()).then_some(runtime);
2672 (risk, runtime)
2673}
2674
2675fn merge_into_profile_default(profiles: &mut toml::Table, fields: toml::Table) {
2678 let default_entry = profiles
2679 .entry("default".to_string())
2680 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
2681 let Some(default_table) = default_entry.as_table_mut() else {
2682 return;
2683 };
2684 for (k, v) in fields {
2685 default_table.entry(k).or_insert(v);
2686 }
2687}
2688
2689fn rename_table_keys(value: toml::Value, renames: &[(&str, &str)]) -> toml::Value {
2693 let mut table = match value {
2694 toml::Value::Table(t) => t,
2695 other => return other,
2696 };
2697 for (old, new) in renames {
2698 if let Some(v) = table.remove(*old)
2699 && !table.contains_key(*new)
2700 {
2701 table.insert((*new).to_string(), v);
2702 }
2703 }
2704 toml::Value::Table(table)
2705}
2706
2707fn slugify(s: &str) -> String {
2709 let mut out = String::with_capacity(s.len());
2710 let mut prev_underscore = false;
2711 for c in s.chars() {
2712 if c.is_alphanumeric() {
2713 out.push(c.to_ascii_lowercase());
2714 prev_underscore = false;
2715 } else if !prev_underscore {
2716 out.push('_');
2717 prev_underscore = true;
2718 }
2719 }
2720 out.trim_matches('_').to_string()
2721}
2722
2723fn ensure_unique_key(existing: &toml::Table, key: String) -> String {
2725 if !existing.contains_key(&key) {
2726 return key;
2727 }
2728 let mut n = 2;
2729 loop {
2730 let candidate = format!("{key}_{n}");
2731 if !existing.contains_key(&candidate) {
2732 return candidate;
2733 }
2734 n += 1;
2735 }
2736}
2737
2738use anyhow::{Context as MigContext, Result as MigResult};
2748use rusqlite::{Connection, OptionalExtension, params};
2749use std::path::{Path, PathBuf};
2750
2751#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2754pub enum V2WorkspaceDest {
2755 DataDir,
2757 SharedDir,
2759 AgentDefault,
2761 MemorySubentryDispatch,
2766}
2767
2768pub const V2_WORKSPACE_TOPLEVEL_DISPATCH: &[(&str, V2WorkspaceDest)] = &[
2776 ("memory", V2WorkspaceDest::MemorySubentryDispatch),
2777 ("sessions", V2WorkspaceDest::DataDir),
2778 ("state", V2WorkspaceDest::DataDir),
2779 ("skills", V2WorkspaceDest::SharedDir),
2780 ("devices.db", V2WorkspaceDest::DataDir),
2785];
2786
2787pub const V2_MEMORY_DATA_NAMES: &[&str] = &[
2795 "brain.db",
2796 "audit.db",
2797 "response_cache.db",
2798 "MEMORY_SNAPSHOT.md",
2799 "archive",
2800];
2801
2802pub const V3_INSTALL_ROOT_NAMES: &[&str] = &["data", "shared", "agents"];
2806
2807pub fn v2_workspace_toplevel_dest(name: &str) -> V2WorkspaceDest {
2809 V2_WORKSPACE_TOPLEVEL_DISPATCH
2810 .iter()
2811 .copied()
2812 .find(|(n, _)| *n == name)
2813 .map(|(_, d)| d)
2814 .unwrap_or(V2WorkspaceDest::AgentDefault)
2815}
2816
2817pub fn workspace_toplevel_v3_path(install: &Path, name: &str) -> PathBuf {
2823 match v2_workspace_toplevel_dest(name) {
2824 V2WorkspaceDest::DataDir | V2WorkspaceDest::MemorySubentryDispatch => {
2825 install.join("data").join(name)
2826 }
2827 V2WorkspaceDest::SharedDir => install.join("shared").join(name),
2828 V2WorkspaceDest::AgentDefault => install
2829 .join("agents")
2830 .join("default")
2831 .join("workspace")
2832 .join(name),
2833 }
2834}
2835
2836pub fn memory_subentry_v3_path(install: &Path, sub_name: &str) -> PathBuf {
2838 if V2_MEMORY_DATA_NAMES.contains(&sub_name) {
2839 install.join("data").join("memory").join(sub_name)
2840 } else {
2841 install
2842 .join("agents")
2843 .join("default")
2844 .join("workspace")
2845 .join("memory")
2846 .join(sub_name)
2847 }
2848}
2849
2850#[derive(Debug, Clone)]
2852pub struct FilesystemMigrationReport {
2853 pub backup_dir: Option<PathBuf>,
2856 pub entries_relocated: usize,
2858}
2859
2860pub fn migrate_v2_to_v3_install_filesystem(
2876 install_root: &Path,
2877) -> MigResult<FilesystemMigrationReport> {
2878 let legacy = install_root.join("workspace");
2879 let agent_default = install_root
2880 .join("agents")
2881 .join("default")
2882 .join("workspace");
2883
2884 if !legacy.is_dir() {
2885 relocate_default_agent_skills_to_shared(install_root)?;
2886 return Ok(FilesystemMigrationReport {
2887 backup_dir: None,
2888 entries_relocated: 0,
2889 });
2890 }
2891
2892 let data_target = install_root.join("data");
2893 let data_populated = data_target
2894 .is_dir()
2895 .then(|| std::fs::read_dir(&data_target).ok())
2896 .flatten()
2897 .is_some_and(|mut it| it.next().is_some());
2898 let agent_populated = agent_default
2899 .is_dir()
2900 .then(|| std::fs::read_dir(&agent_default).ok())
2901 .flatten()
2902 .is_some_and(|mut it| it.next().is_some());
2903 if data_populated && agent_populated {
2904 ::zeroclaw_log::record!(
2905 INFO,
2906 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
2907 ::serde_json::json!({
2908 "data_target": data_target.display().to_string(),
2909 "agent_target": agent_default.display().to_string(),
2910 "legacy": legacy.display().to_string(),
2911 })
2912 ),
2913 "[system] filesystem migration: targets already populated; skipping split"
2914 );
2915 relocate_default_agent_skills_to_shared(install_root)?;
2916 return Ok(FilesystemMigrationReport {
2917 backup_dir: None,
2918 entries_relocated: 0,
2919 });
2920 }
2921
2922 let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%S").to_string();
2923 let backup_dir = install_root
2924 .join(format!("backup-{timestamp}"))
2925 .join("legacy-workspace");
2926 std::fs::create_dir_all(&backup_dir).with_context(|| {
2927 format!(
2928 "[system] failed to create migration backup dir at {}",
2929 backup_dir.display()
2930 )
2931 })?;
2932 copy_dir_recursive(&legacy, &backup_dir).with_context(|| {
2933 format!(
2934 "[system] failed to back up legacy workspace from {} to {}",
2935 legacy.display(),
2936 backup_dir.display()
2937 )
2938 })?;
2939 ::zeroclaw_log::record!(
2940 INFO,
2941 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
2942 ::serde_json::json!({
2943 "backup": backup_dir.display().to_string(),
2944 })
2945 ),
2946 "[system] filesystem migration: legacy workspace backed up"
2947 );
2948
2949 let entries_relocated = relocate_workspace_toplevel(&legacy, install_root, &backup_dir)
2950 .with_context(|| {
2951 format!(
2952 "[system] failed during workspace top-level relocation under {}",
2953 install_root.display()
2954 )
2955 })?;
2956
2957 if std::fs::read_dir(&legacy)
2958 .map(|mut it| it.next().is_none())
2959 .unwrap_or(false)
2960 {
2961 let _ = std::fs::remove_dir(&legacy);
2962 }
2963
2964 relocate_default_agent_skills_to_shared(install_root)?;
2965
2966 ::zeroclaw_log::record!(
2967 INFO,
2968 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
2969 ::serde_json::json!({
2970 "backup": backup_dir.display().to_string(),
2971 "entries_relocated": entries_relocated,
2972 })
2973 ),
2974 "[system] filesystem migration: legacy workspace split into V3 layout"
2975 );
2976
2977 Ok(FilesystemMigrationReport {
2978 backup_dir: Some(backup_dir.parent().unwrap_or(&backup_dir).to_path_buf()),
2979 entries_relocated,
2980 })
2981}
2982
2983fn relocate_workspace_toplevel(
2987 legacy: &Path,
2988 install_root: &Path,
2989 backup_dir: &Path,
2990) -> MigResult<usize> {
2991 let mut count = 0usize;
2992 for entry in std::fs::read_dir(legacy).with_context(|| {
2993 format!(
2994 "[system] failed to enumerate legacy workspace at {}",
2995 legacy.display()
2996 )
2997 })? {
2998 let entry = entry?;
2999 let name = entry.file_name();
3000 let Some(name_str) = name.to_str() else {
3001 ::zeroclaw_log::record!(
3002 WARN,
3003 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3004 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
3005 .with_attrs(::serde_json::json!({
3006 "legacy": legacy.display().to_string(),
3007 })),
3008 "[system] filesystem migration: skipping non-UTF-8 entry"
3009 );
3010 continue;
3011 };
3012 let src = entry.path();
3013
3014 match v2_workspace_toplevel_dest(name_str) {
3015 V2WorkspaceDest::MemorySubentryDispatch => {
3016 count += relocate_memory_subentries(&src, install_root, backup_dir)?;
3017 }
3018 _ => {
3019 let dst = workspace_toplevel_v3_path(install_root, name_str);
3020 if move_with_refuse_to_clobber(&src, &dst)? {
3021 count += 1;
3022 }
3023 }
3024 }
3025 }
3026 Ok(count)
3027}
3028
3029fn relocate_memory_subentries(
3032 legacy_memory_dir: &Path,
3033 install_root: &Path,
3034 _backup_dir: &Path,
3035) -> MigResult<usize> {
3036 if !legacy_memory_dir.is_dir() {
3037 return Ok(0);
3038 }
3039 let mut count = 0usize;
3040 for entry in std::fs::read_dir(legacy_memory_dir).with_context(|| {
3041 format!(
3042 "[system] failed to enumerate {} during memory sub-dispatch",
3043 legacy_memory_dir.display()
3044 )
3045 })? {
3046 let entry = entry?;
3047 let name = entry.file_name();
3048 let Some(name_str) = name.to_str() else {
3049 ::zeroclaw_log::record!(
3050 WARN,
3051 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3052 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
3053 .with_attrs(::serde_json::json!({
3054 "legacy_memory_dir": legacy_memory_dir.display().to_string(),
3055 })),
3056 "[system] filesystem migration: skipping non-UTF-8 entry under memory"
3057 );
3058 continue;
3059 };
3060 let src = entry.path();
3061 let dst = memory_subentry_v3_path(install_root, name_str);
3062 if move_with_refuse_to_clobber(&src, &dst)? {
3063 count += 1;
3064 }
3065 }
3066 if std::fs::read_dir(legacy_memory_dir)
3068 .map(|mut it| it.next().is_none())
3069 .unwrap_or(false)
3070 {
3071 let _ = std::fs::remove_dir(legacy_memory_dir);
3072 }
3073 Ok(count)
3074}
3075
3076fn move_with_refuse_to_clobber(src: &Path, dst: &Path) -> MigResult<bool> {
3082 if dst.exists() {
3083 ::zeroclaw_log::record!(
3084 WARN,
3085 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3086 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
3087 .with_attrs(::serde_json::json!({
3088 "source": src.display().to_string(),
3089 "target": dst.display().to_string(),
3090 })),
3091 "[system] filesystem migration: target already exists; refusing to clobber"
3092 );
3093 return Ok(false);
3094 }
3095 if let Some(parent) = dst.parent() {
3096 std::fs::create_dir_all(parent).with_context(|| {
3097 format!("[system] failed to create parent dir {}", parent.display())
3098 })?;
3099 }
3100 if std::fs::rename(src, dst).is_ok() {
3101 return Ok(true);
3102 }
3103 if src.is_dir() {
3105 copy_dir_recursive(src, dst).with_context(|| {
3106 format!(
3107 "[system] failed to copy {} to {}",
3108 src.display(),
3109 dst.display()
3110 )
3111 })?;
3112 std::fs::remove_dir_all(src)
3113 .with_context(|| format!("[system] failed to remove {} after copy", src.display()))?;
3114 } else {
3115 std::fs::copy(src, dst).with_context(|| {
3116 format!(
3117 "[system] failed to copy {} to {}",
3118 src.display(),
3119 dst.display()
3120 )
3121 })?;
3122 std::fs::remove_file(src)
3123 .with_context(|| format!("[system] failed to remove {} after copy", src.display()))?;
3124 }
3125 Ok(true)
3126}
3127
3128pub fn relocate_default_agent_skills_to_shared(install_root: &Path) -> MigResult<bool> {
3132 let src = install_root
3133 .join("agents")
3134 .join("default")
3135 .join("workspace")
3136 .join("skills");
3137 let dst = install_root.join("shared").join("skills");
3138 if !src.is_dir() {
3139 return Ok(false);
3140 }
3141 let dst_populated = dst
3142 .is_dir()
3143 .then(|| std::fs::read_dir(&dst).ok())
3144 .flatten()
3145 .is_some_and(|mut it| it.next().is_some());
3146 if dst_populated {
3147 return Ok(false);
3148 }
3149 if let Some(parent) = dst.parent() {
3150 std::fs::create_dir_all(parent).with_context(|| {
3151 format!(
3152 "[system] failed to create shared workspace parent {}",
3153 parent.display()
3154 )
3155 })?;
3156 }
3157 if std::fs::rename(&src, &dst).is_err() {
3158 copy_dir_recursive(&src, &dst).with_context(|| {
3159 format!(
3160 "[system] failed to copy {} to {}",
3161 src.display(),
3162 dst.display()
3163 )
3164 })?;
3165 std::fs::remove_dir_all(&src)
3166 .with_context(|| format!("[system] failed to remove {} after copy", src.display()))?;
3167 }
3168 ::zeroclaw_log::record!(
3169 INFO,
3170 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
3171 ::serde_json::json!({
3172 "from": src.display().to_string(),
3173 "to": dst.display().to_string(),
3174 })
3175 ),
3176 "[system] filesystem migration: lifted default-agent skills into shared/"
3177 );
3178 Ok(true)
3179}
3180
3181fn copy_dir_recursive(src: &Path, dst: &Path) -> MigResult<()> {
3182 std::fs::create_dir_all(dst)?;
3183 for entry in std::fs::read_dir(src)? {
3184 let entry = entry?;
3185 let from = entry.path();
3186 let to = dst.join(entry.file_name());
3187 let ft = entry.file_type()?;
3188 if ft.is_dir() {
3189 copy_dir_recursive(&from, &to)?;
3190 } else if ft.is_symlink() {
3191 #[cfg(unix)]
3192 {
3193 let target = std::fs::read_link(&from)?;
3194 std::os::unix::fs::symlink(&target, &to)?;
3195 }
3196 #[cfg(not(unix))]
3197 {
3198 std::fs::copy(&from, &to)?;
3199 }
3200 } else {
3201 std::fs::copy(&from, &to)?;
3202 }
3203 }
3204 Ok(())
3205}
3206
3207pub const SQLITE_MEMORY_SCHEMA_VERSION: i64 = 1;
3215
3216#[cfg(feature = "memory-postgres")]
3217fn postgres_memory_schema_version() -> MigResult<i32> {
3218 i32::try_from(SQLITE_MEMORY_SCHEMA_VERSION)
3219 .context("Postgres memory schema version exceeds INTEGER range")
3220}
3221
3222pub fn migrate_sqlite_memory_to_v3(db_path: &Path, conn: &Connection) -> MigResult<()> {
3235 if sqlite_memories_agent_id_is_not_null(conn)? && sqlite_memories_has_unique_agent_key(conn)? {
3236 return Ok(());
3237 }
3238
3239 if sqlite_memories_row_count(conn)? > 0 && db_path.exists() {
3240 backup_sqlite_for_multi_agent_migration(db_path)?;
3241 }
3242
3243 conn.execute_batch("BEGIN IMMEDIATE; PRAGMA defer_foreign_keys = ON;")?;
3244 let result = (|| -> MigResult<()> {
3245 conn.execute_batch(
3246 "CREATE TABLE IF NOT EXISTS agents (
3247 id TEXT PRIMARY KEY,
3248 alias TEXT NOT NULL UNIQUE,
3249 created_at TEXT NOT NULL
3250 );",
3251 )?;
3252 let default_uuid = sqlite_ensure_default_agent_uuid(conn)?;
3253
3254 if !sqlite_memories_has_agent_id_column(conn)? {
3255 conn.execute_batch("ALTER TABLE memories ADD COLUMN agent_id TEXT;")?;
3256 }
3257 conn.execute(
3258 "UPDATE memories SET agent_id = ?1 WHERE agent_id IS NULL",
3259 params![default_uuid],
3260 )?;
3261
3262 conn.execute_batch(
3263 "DROP TRIGGER IF EXISTS memories_ai;
3264 DROP TRIGGER IF EXISTS memories_ad;
3265 DROP TRIGGER IF EXISTS memories_au;
3266 DROP TABLE IF EXISTS memories_fts;
3267
3268 CREATE TABLE memories_new (
3269 id TEXT PRIMARY KEY,
3270 key TEXT NOT NULL,
3271 content TEXT NOT NULL,
3272 category TEXT NOT NULL DEFAULT 'core',
3273 embedding BLOB,
3274 created_at TEXT NOT NULL,
3275 updated_at TEXT NOT NULL,
3276 session_id TEXT,
3277 namespace TEXT DEFAULT 'default',
3278 importance REAL DEFAULT 0.5,
3279 superseded_by TEXT,
3280 agent_id TEXT NOT NULL REFERENCES agents(id),
3281 UNIQUE (agent_id, key)
3282 );
3283
3284 INSERT INTO memories_new (
3285 id, key, content, category, embedding, created_at, updated_at,
3286 session_id, namespace, importance, superseded_by, agent_id
3287 )
3288 SELECT
3289 id, key, content, category, embedding, created_at, updated_at,
3290 session_id, namespace, importance, superseded_by, agent_id
3291 FROM memories;
3292
3293 DROP TABLE memories;
3294 ALTER TABLE memories_new RENAME TO memories;
3295
3296 CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(category);
3297 CREATE INDEX IF NOT EXISTS idx_memories_key ON memories(key);
3298 CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id);
3299 CREATE INDEX IF NOT EXISTS idx_memories_namespace ON memories(namespace);
3300 CREATE INDEX IF NOT EXISTS idx_memories_agent_id ON memories(agent_id);
3301
3302 CREATE VIRTUAL TABLE memories_fts USING fts5(
3303 key, content, content=memories, content_rowid=rowid
3304 );
3305 INSERT INTO memories_fts(memories_fts) VALUES('rebuild');
3306
3307 CREATE TRIGGER memories_ai AFTER INSERT ON memories BEGIN
3308 INSERT INTO memories_fts(rowid, key, content)
3309 VALUES (new.rowid, new.key, new.content);
3310 END;
3311 CREATE TRIGGER memories_ad AFTER DELETE ON memories BEGIN
3312 INSERT INTO memories_fts(memories_fts, rowid, key, content)
3313 VALUES ('delete', old.rowid, old.key, old.content);
3314 END;
3315 CREATE TRIGGER memories_au AFTER UPDATE ON memories BEGIN
3316 INSERT INTO memories_fts(memories_fts, rowid, key, content)
3317 VALUES ('delete', old.rowid, old.key, old.content);
3318 INSERT INTO memories_fts(rowid, key, content)
3319 VALUES (new.rowid, new.key, new.content);
3320 END;",
3321 )?;
3322
3323 sqlite_ensure_schema_version_table(conn)?;
3324 conn.execute(
3325 "INSERT OR REPLACE INTO schema_version (component, version, applied_at) \
3326 VALUES ('memories', ?1, ?2)",
3327 params![
3328 SQLITE_MEMORY_SCHEMA_VERSION,
3329 chrono::Utc::now().to_rfc3339()
3330 ],
3331 )?;
3332 Ok(())
3333 })();
3334
3335 match result {
3336 Ok(()) => {
3337 conn.execute_batch("COMMIT;")?;
3338 Ok(())
3339 }
3340 Err(e) => {
3341 let _ = conn.execute_batch("ROLLBACK;");
3342 Err(e)
3343 }
3344 }
3345}
3346
3347fn sqlite_ensure_schema_version_table(conn: &Connection) -> MigResult<()> {
3348 conn.execute_batch(
3349 "CREATE TABLE IF NOT EXISTS schema_version (
3350 component TEXT PRIMARY KEY,
3351 version INTEGER NOT NULL,
3352 applied_at TEXT NOT NULL
3353 );",
3354 )?;
3355 Ok(())
3356}
3357
3358fn sqlite_memories_agent_id_is_not_null(conn: &Connection) -> MigResult<bool> {
3359 let mut stmt = conn.prepare("PRAGMA table_info(memories)")?;
3360 let agent_id_notnull: Option<bool> = stmt
3361 .query_map([], |row| {
3362 let name: String = row.get(1)?;
3363 let notnull: i64 = row.get(3)?;
3364 Ok((name, notnull != 0))
3365 })?
3366 .filter_map(Result::ok)
3367 .find(|(name, _)| name == "agent_id")
3368 .map(|(_, notnull)| notnull);
3369
3370 let Some(true) = agent_id_notnull else {
3371 return Ok(false);
3372 };
3373
3374 let mut fk_stmt = conn.prepare("PRAGMA foreign_key_list(memories)")?;
3375 let has_fk = fk_stmt
3376 .query_map([], |row| {
3377 let target_table: String = row.get(2)?;
3378 let from_col: String = row.get(3)?;
3379 Ok((target_table, from_col))
3380 })?
3381 .filter_map(Result::ok)
3382 .any(|(target, from)| target == "agents" && from == "agent_id");
3383 Ok(has_fk)
3384}
3385
3386fn sqlite_memories_has_agent_id_column(conn: &Connection) -> MigResult<bool> {
3387 let mut stmt = conn.prepare("PRAGMA table_info(memories)")?;
3388 Ok(stmt
3389 .query_map([], |row| row.get::<_, String>(1))?
3390 .filter_map(Result::ok)
3391 .any(|name| name == "agent_id"))
3392}
3393
3394fn sqlite_memories_has_unique_agent_key(conn: &Connection) -> MigResult<bool> {
3400 let mut idx_stmt = conn.prepare("PRAGMA index_list(memories)")?;
3404 let index_names: Vec<(String, bool)> = idx_stmt
3405 .query_map([], |row| {
3406 let name: String = row.get(1)?;
3407 let unique: i64 = row.get(2)?;
3408 Ok((name, unique != 0))
3409 })?
3410 .filter_map(Result::ok)
3411 .collect();
3412
3413 for (idx_name, is_unique) in index_names {
3414 if !is_unique {
3415 continue;
3416 }
3417 let pragma = format!("PRAGMA index_info(\"{}\")", idx_name.replace('"', "\"\""));
3421 let mut info_stmt = conn.prepare(&pragma)?;
3422 let cols: Vec<String> = info_stmt
3423 .query_map([], |row| row.get::<_, String>(2))?
3424 .filter_map(Result::ok)
3425 .collect();
3426 if cols.len() == 2
3427 && cols.contains(&"agent_id".to_string())
3428 && cols.contains(&"key".to_string())
3429 {
3430 return Ok(true);
3431 }
3432 }
3433 Ok(false)
3434}
3435
3436fn sqlite_memories_row_count(conn: &Connection) -> MigResult<i64> {
3437 let table_exists: bool = conn
3438 .query_row(
3439 "SELECT 1 FROM sqlite_master WHERE type='table' AND name='memories' LIMIT 1",
3440 [],
3441 |_| Ok(()),
3442 )
3443 .optional()?
3444 .is_some();
3445 if !table_exists {
3446 return Ok(0);
3447 }
3448 let count: i64 = conn.query_row("SELECT COUNT(*) FROM memories", [], |row| row.get(0))?;
3449 Ok(count)
3450}
3451
3452pub fn sqlite_ensure_default_agent_uuid(conn: &Connection) -> MigResult<String> {
3456 sqlite_ensure_agent_uuid(conn, "default")
3457}
3458
3459pub fn sqlite_ensure_agent_uuid(conn: &Connection, alias: &str) -> MigResult<String> {
3463 let new_id = uuid::Uuid::new_v4().to_string();
3464 let now = chrono::Utc::now().to_rfc3339();
3465 conn.execute(
3466 "INSERT OR IGNORE INTO agents (id, alias, created_at) VALUES (?1, ?2, ?3)",
3467 params![new_id, alias, now],
3468 )?;
3469 let final_id: String = conn.query_row(
3470 "SELECT id FROM agents WHERE alias = ?1 LIMIT 1",
3471 params![alias],
3472 |row| row.get(0),
3473 )?;
3474 Ok(final_id)
3475}
3476
3477fn backup_sqlite_for_multi_agent_migration(db_path: &Path) -> MigResult<()> {
3478 let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%S").to_string();
3479 let backup_path = db_path.with_file_name(format!(
3480 "{}.backup-{timestamp}",
3481 db_path
3482 .file_name()
3483 .map(|n| n.to_string_lossy().into_owned())
3484 .unwrap_or_else(|| "brain.db".to_string()),
3485 ));
3486 std::fs::copy(db_path, &backup_path).with_context(|| {
3487 format!(
3488 "failed to copy {} to {} before multi-agent migration",
3489 db_path.display(),
3490 backup_path.display(),
3491 )
3492 })?;
3493 ::zeroclaw_log::record!(
3494 INFO,
3495 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
3496 ::serde_json::json!({
3497 "backup": backup_path.display().to_string(),
3498 })
3499 ),
3500 "multi-agent migration: backed up SQLite memory DB before adding agents table"
3501 );
3502 Ok(())
3503}
3504
3505#[cfg(feature = "memory-postgres")]
3521pub fn migrate_postgres_memory_to_v3(
3522 client: &mut postgres::Client,
3523 schema_ident: &str,
3524 qualified_table: &str,
3525) -> MigResult<()> {
3526 let qualified_agents = format!("{schema_ident}.agents");
3527
3528 client.batch_execute(&format!(
3529 "CREATE TABLE IF NOT EXISTS {qualified_agents} (
3530 id TEXT PRIMARY KEY,
3531 alias TEXT NOT NULL UNIQUE,
3532 created_at TIMESTAMPTZ NOT NULL
3533 );"
3534 ))?;
3535
3536 let candidate_uuid = uuid::Uuid::new_v4().to_string();
3537 client.execute(
3538 &format!(
3539 "INSERT INTO {qualified_agents} (id, alias, created_at)
3540 VALUES ($1, 'default', NOW())
3541 ON CONFLICT (alias) DO NOTHING"
3542 ),
3543 &[&candidate_uuid],
3544 )?;
3545 let default_uuid: String = client
3546 .query_one(
3547 &format!("SELECT id FROM {qualified_agents} WHERE alias = 'default' LIMIT 1"),
3548 &[],
3549 )?
3550 .get(0);
3551
3552 client.batch_execute(&format!(
3553 "ALTER TABLE {qualified_table} ADD COLUMN IF NOT EXISTS agent_id TEXT;
3554 CREATE INDEX IF NOT EXISTS idx_memories_agent_id ON {qualified_table}(agent_id);"
3555 ))?;
3556 client.execute(
3557 &format!("UPDATE {qualified_table} SET agent_id = $1 WHERE agent_id IS NULL"),
3558 &[&default_uuid],
3559 )?;
3560
3561 client.batch_execute(&format!(
3562 "
3563 DO $$
3564 BEGIN
3565 IF NOT EXISTS (
3566 SELECT 1 FROM pg_constraint
3567 WHERE conname = 'memories_agent_id_notnull_chk'
3568 ) THEN
3569 ALTER TABLE {qualified_table}
3570 ADD CONSTRAINT memories_agent_id_notnull_chk
3571 CHECK (agent_id IS NOT NULL) NOT VALID;
3572 END IF;
3573 END$$;
3574 ALTER TABLE {qualified_table} VALIDATE CONSTRAINT memories_agent_id_notnull_chk;
3575 ALTER TABLE {qualified_table} ALTER COLUMN agent_id SET NOT NULL;
3576 DO $$
3577 BEGIN
3578 IF NOT EXISTS (
3579 SELECT 1 FROM pg_constraint
3580 WHERE conname = 'memories_agent_id_fk'
3581 ) THEN
3582 ALTER TABLE {qualified_table}
3583 ADD CONSTRAINT memories_agent_id_fk
3584 FOREIGN KEY (agent_id) REFERENCES {qualified_agents}(id) NOT VALID;
3585 END IF;
3586 END$$;
3587 ALTER TABLE {qualified_table} VALIDATE CONSTRAINT memories_agent_id_fk;
3588 -- Swap the legacy key-only uniqueness for composite (agent_id, key)
3589 -- so two agents may hold rows under the same caller-chosen key.
3590 ALTER TABLE {qualified_table} DROP CONSTRAINT IF EXISTS memories_key_key;
3591 DO $$
3592 BEGIN
3593 IF NOT EXISTS (
3594 SELECT 1 FROM pg_constraint
3595 WHERE conname = 'memories_agent_key_uniq'
3596 ) THEN
3597 ALTER TABLE {qualified_table}
3598 ADD CONSTRAINT memories_agent_key_uniq UNIQUE (agent_id, key);
3599 END IF;
3600 END$$;
3601 "
3602 ))?;
3603
3604 client.batch_execute(&format!(
3605 "CREATE TABLE IF NOT EXISTS {schema_ident}.schema_version (
3606 component TEXT PRIMARY KEY,
3607 version INTEGER NOT NULL,
3608 applied_at TIMESTAMPTZ NOT NULL
3609 );"
3610 ))?;
3611 let memory_schema_version = postgres_memory_schema_version()?;
3612 client.execute(
3613 &format!(
3614 "INSERT INTO {schema_ident}.schema_version (component, version, applied_at) \
3615 VALUES ('memories', $1, NOW()) \
3616 ON CONFLICT (component) DO UPDATE SET version = EXCLUDED.version, applied_at = EXCLUDED.applied_at"
3617 ),
3618 &[&memory_schema_version],
3619 )?;
3620 Ok(())
3621}
3622
3623pub const QDRANT_DEFAULT_AGENT_ID: &str = "default";
3636
3637pub async fn migrate_qdrant_collection_to_v3(
3648 client: &reqwest::Client,
3649 base_url: &str,
3650 collection: &str,
3651 api_key: Option<&str>,
3652) -> MigResult<usize> {
3653 let base_url = base_url.trim_end_matches('/');
3654 let mut next_offset: Option<serde_json::Value> = None;
3655 let mut updated = 0usize;
3656
3657 loop {
3658 let mut scroll_body = serde_json::json!({
3659 "limit": 1000,
3660 "with_payload": true,
3661 "with_vector": false,
3662 "filter": {
3666 "must": [{ "is_empty": { "key": "agent_id" } }]
3667 }
3668 });
3669 if let Some(ref offset) = next_offset {
3670 scroll_body["offset"] = offset.clone();
3671 }
3672
3673 let url = format!("{base_url}/collections/{collection}/points/scroll");
3674 let mut req = client.request(reqwest::Method::POST, &url);
3675 if let Some(key) = api_key {
3676 req = req.header("api-key", key);
3677 }
3678 let resp = req
3679 .header("Content-Type", "application/json")
3680 .json(&scroll_body)
3681 .send()
3682 .await
3683 .context("[system] Qdrant V3 migration: scroll request failed")?;
3684 if !resp.status().is_success() {
3685 let status = resp.status();
3686 let text = resp.text().await.unwrap_or_default();
3687 anyhow::bail!("Qdrant scroll failed ({status}): {text}");
3688 }
3689
3690 #[derive(serde::Deserialize)]
3691 struct ScrollPage {
3692 result: ScrollResult,
3693 }
3694 #[derive(serde::Deserialize)]
3695 struct ScrollResult {
3696 points: Vec<ScrollPoint>,
3697 #[serde(default)]
3698 next_page_offset: Option<serde_json::Value>,
3699 }
3700 #[derive(serde::Deserialize)]
3701 struct ScrollPoint {
3702 id: serde_json::Value,
3703 }
3704
3705 let page: ScrollPage = resp
3706 .json()
3707 .await
3708 .context("[system] Qdrant V3 migration: scroll page parse failed")?;
3709 let ids: Vec<serde_json::Value> = page.result.points.into_iter().map(|p| p.id).collect();
3710 if !ids.is_empty() {
3711 let set_url = format!("{base_url}/collections/{collection}/points/payload");
3712 let body = serde_json::json!({
3713 "payload": { "agent_id": QDRANT_DEFAULT_AGENT_ID },
3714 "points": ids,
3715 });
3716 let mut req = client.request(reqwest::Method::POST, &set_url);
3717 if let Some(key) = api_key {
3718 req = req.header("api-key", key);
3719 }
3720 let resp = req
3721 .header("Content-Type", "application/json")
3722 .query(&[("wait", "true")])
3723 .json(&body)
3724 .send()
3725 .await
3726 .context("[system] Qdrant V3 migration: set payload request failed")?;
3727 if !resp.status().is_success() {
3728 let status = resp.status();
3729 let text = resp.text().await.unwrap_or_default();
3730 anyhow::bail!("Qdrant set payload failed ({status}): {text}");
3731 }
3732 let batch_count = body["points"].as_array().map(|a| a.len()).unwrap_or(0);
3733 updated += batch_count;
3734 }
3735
3736 match page.result.next_page_offset {
3737 Some(offset) if !offset.is_null() => next_offset = Some(offset),
3738 _ => break,
3739 }
3740 }
3741
3742 if updated > 0 {
3743 ::zeroclaw_log::record!(
3744 INFO,
3745 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
3746 ::serde_json::json!({
3747 "collection": collection,
3748 "updated": updated,
3749 })
3750 ),
3751 "[system] Qdrant V3 migration: backfilled agent_id payload"
3752 );
3753 }
3754 Ok(updated)
3755}
3756
3757#[cfg(test)]
3758mod fs_db_migration_tests {
3759 use super::*;
3768 use rusqlite::Connection;
3769 use std::collections::BTreeSet;
3770 use std::fs;
3771 use tempfile::TempDir;
3772
3773 fn snapshot_tree(root: &Path) -> BTreeSet<(PathBuf, Vec<u8>)> {
3777 fn walk(root: &Path, dir: &Path, out: &mut BTreeSet<(PathBuf, Vec<u8>)>) {
3778 let Ok(rd) = fs::read_dir(dir) else { return };
3779 for entry in rd.flatten() {
3780 let path = entry.path();
3781 if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
3782 walk(root, &path, out);
3783 } else if let Ok(bytes) = fs::read(&path)
3784 && let Ok(rel) = path.strip_prefix(root)
3785 {
3786 out.insert((rel.to_path_buf(), bytes));
3787 }
3788 }
3789 }
3790 let mut out = BTreeSet::new();
3791 walk(root, root, &mut out);
3792 out
3793 }
3794
3795 fn seed_v2_install(install: &Path) {
3800 fs::create_dir_all(install.join("workspace")).unwrap();
3803 fs::write(
3804 install.join("workspace/devices.db"),
3805 b"pretend-paired-devices-blob",
3806 )
3807 .unwrap();
3808
3809 for fname in [
3811 "MEMORY.md",
3812 "IDENTITY.md",
3813 "SOUL.md",
3814 "USER.md",
3815 "AGENTS.md",
3816 ] {
3817 fs::write(install.join("workspace").join(fname), format!("# {fname}")).unwrap();
3818 }
3819
3820 fs::create_dir_all(install.join("workspace/sessions")).unwrap();
3822 fs::write(install.join("workspace/sessions/sessions.db"), b"sessions").unwrap();
3823
3824 fs::create_dir_all(install.join("workspace/state")).unwrap();
3826 fs::write(
3827 install.join("workspace/state/runtime-trace.jsonl"),
3828 b"trace",
3829 )
3830 .unwrap();
3831
3832 fs::create_dir_all(install.join("workspace/skills/my-skill")).unwrap();
3834 fs::write(install.join("workspace/skills/my-skill/SKILL.md"), b"skill").unwrap();
3835
3836 let mem_dir = install.join("workspace/memory");
3839 fs::create_dir_all(&mem_dir).unwrap();
3840 for sub in V2_MEMORY_DATA_NAMES {
3841 let p = mem_dir.join(sub);
3842 if *sub == "archive" {
3843 fs::create_dir_all(&p).unwrap();
3844 fs::write(p.join("old-recall.jsonl"), b"archived").unwrap();
3845 } else if (*sub).ends_with(".db") {
3846 let conn = Connection::open(&p).unwrap();
3849 if *sub == "brain.db" {
3850 conn.execute_batch(
3851 "PRAGMA foreign_keys = ON;
3852 CREATE TABLE memories (
3853 id TEXT PRIMARY KEY,
3854 key TEXT NOT NULL UNIQUE,
3855 content TEXT NOT NULL,
3856 category TEXT NOT NULL DEFAULT 'core',
3857 embedding BLOB,
3858 created_at TEXT NOT NULL,
3859 updated_at TEXT NOT NULL,
3860 session_id TEXT,
3861 namespace TEXT DEFAULT 'default',
3862 importance REAL DEFAULT 0.5,
3863 superseded_by TEXT
3864 );
3865 INSERT INTO memories (id, key, content, created_at, updated_at)
3866 VALUES ('m1', 'hello', 'world', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'),
3867 ('m2', 'foo', 'bar', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z');",
3868 )
3869 .unwrap();
3870 }
3871 } else {
3872 fs::write(&p, format!("{sub} payload").as_bytes()).unwrap();
3873 }
3874 }
3875 fs::write(
3877 mem_dir.join("2025-04-12.md"),
3878 b"# daily 2025-04-12\nhello world\n",
3879 )
3880 .unwrap();
3881 fs::write(
3882 mem_dir.join("2025-04-13.md"),
3883 b"# daily 2025-04-13\nstill here\n",
3884 )
3885 .unwrap();
3886 }
3887
3888 #[test]
3889 fn migrate_v2_install_into_v3_layout_with_real_filesystem() {
3890 let tmp = TempDir::new().unwrap();
3891 let install = tmp.path();
3892 seed_v2_install(install);
3893
3894 let legacy_snapshot = snapshot_tree(&install.join("workspace"));
3895 assert!(
3896 !legacy_snapshot.is_empty(),
3897 "fixture seed must produce content under workspace/"
3898 );
3899
3900 let report = migrate_v2_to_v3_install_filesystem(install).expect("migration must succeed");
3901 assert!(report.entries_relocated > 0);
3902 let backup_root = report.backup_dir.expect("backup dir present");
3903
3904 let backup_snapshot = snapshot_tree(&backup_root.join("legacy-workspace"));
3906 assert_eq!(
3907 backup_snapshot, legacy_snapshot,
3908 "backup must be a byte-equal copy of the pre-migration workspace"
3909 );
3910
3911 for (rel, expected_bytes) in &legacy_snapshot {
3915 let v3_path = predict_v3_path(install, rel);
3916 assert!(
3917 v3_path.exists(),
3918 "file {} should exist at predicted V3 path {}",
3919 rel.display(),
3920 v3_path.display(),
3921 );
3922 let actual_bytes = fs::read(&v3_path).unwrap_or_else(|e| {
3923 panic!(
3924 "failed to read predicted V3 path {} for legacy {}: {e}",
3925 v3_path.display(),
3926 rel.display(),
3927 )
3928 });
3929 assert_eq!(
3930 actual_bytes,
3931 *expected_bytes,
3932 "byte mismatch at {}",
3933 v3_path.display()
3934 );
3935 }
3936
3937 assert!(
3939 !install.join("workspace").exists(),
3940 "legacy workspace must be removed after a clean split"
3941 );
3942
3943 let mut roots = BTreeSet::new();
3946 for entry in fs::read_dir(install).unwrap() {
3947 let entry = entry.unwrap();
3948 let name = entry.file_name().to_string_lossy().to_string();
3949 roots.insert(name);
3950 }
3951 for name in &roots {
3952 let allowed =
3953 V3_INSTALL_ROOT_NAMES.contains(&name.as_str()) || name.starts_with("backup-");
3954 assert!(
3955 allowed,
3956 "unexpected install-root entry {name:?}; allowed: {V3_INSTALL_ROOT_NAMES:?} + backup-*"
3957 );
3958 }
3959
3960 let post_first = snapshot_tree(install);
3962 let report2 =
3963 migrate_v2_to_v3_install_filesystem(install).expect("second run must be a no-op");
3964 assert_eq!(report2.entries_relocated, 0);
3965 let post_second = snapshot_tree(install);
3966 assert_eq!(
3967 post_first, post_second,
3968 "idempotent re-run must not modify disk"
3969 );
3970
3971 let brain_path = install.join("data/memory/brain.db");
3975 assert!(
3976 brain_path.is_file(),
3977 "brain.db must have moved to data/memory/"
3978 );
3979 let conn = Connection::open(&brain_path).unwrap();
3980 conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap();
3981 migrate_sqlite_memory_to_v3(&brain_path, &conn).expect("SQLite migration must succeed");
3982
3983 let null_count: i64 = conn
3984 .query_row(
3985 "SELECT COUNT(*) FROM memories WHERE agent_id IS NULL",
3986 [],
3987 |r| r.get(0),
3988 )
3989 .unwrap();
3990 assert_eq!(
3991 null_count, 0,
3992 "all memories must have agent_id post-migration"
3993 );
3994
3995 let agent_row_count: i64 = conn
3996 .query_row(
3997 "SELECT COUNT(*) FROM agents WHERE alias = 'default'",
3998 [],
3999 |r| r.get(0),
4000 )
4001 .unwrap();
4002 assert_eq!(agent_row_count, 1, "default agent row must exist");
4003
4004 migrate_sqlite_memory_to_v3(&brain_path, &conn)
4006 .expect("SQLite migration second run must be a no-op");
4007
4008 let backup_glob: Vec<_> = fs::read_dir(install.join("data/memory"))
4010 .unwrap()
4011 .flatten()
4012 .filter(|e| {
4013 e.file_name()
4014 .to_string_lossy()
4015 .starts_with("brain.db.backup-")
4016 })
4017 .collect();
4018 assert_eq!(
4019 backup_glob.len(),
4020 1,
4021 "in-DB SQLite migration must write exactly one backup file"
4022 );
4023 }
4024
4025 #[test]
4031 fn migrate_sqlite_memory_to_v3_adds_unique_constraint_when_missing() {
4032 use rusqlite::Connection;
4033 let dir = tempfile::tempdir().unwrap();
4034 let db_path = dir.path().join("brain.db");
4035 let conn = Connection::open(&db_path).unwrap();
4036 conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap();
4037
4038 conn.execute_batch(
4041 "CREATE TABLE agents (
4042 id TEXT PRIMARY KEY,
4043 alias TEXT NOT NULL UNIQUE,
4044 created_at TEXT NOT NULL
4045 );
4046 INSERT INTO agents VALUES ('uuid-1','default','2025-01-01T00:00:00Z');
4047
4048 CREATE TABLE memories (
4049 id TEXT PRIMARY KEY,
4050 key TEXT NOT NULL,
4051 content TEXT NOT NULL,
4052 category TEXT NOT NULL DEFAULT 'core',
4053 embedding BLOB,
4054 created_at TEXT NOT NULL,
4055 updated_at TEXT NOT NULL,
4056 session_id TEXT,
4057 namespace TEXT DEFAULT 'default',
4058 importance REAL DEFAULT 0.5,
4059 superseded_by TEXT,
4060 agent_id TEXT NOT NULL REFERENCES agents(id)
4061 -- intentionally NO UNIQUE (agent_id, key)
4062 );
4063 INSERT INTO memories VALUES (
4064 'mid-1','test-key','test-content','core',NULL,
4065 '2025-01-01T00:00:00Z','2025-01-01T00:00:00Z',
4066 NULL,'default',0.5,NULL,'uuid-1'
4067 );",
4068 )
4069 .unwrap();
4070
4071 migrate_sqlite_memory_to_v3(&db_path, &conn)
4073 .expect("migration must succeed on partially-migrated DB");
4074
4075 migrate_sqlite_memory_to_v3(&db_path, &conn).expect("second migration run must be a no-op");
4077
4078 let has_unique = sqlite_memories_has_unique_agent_key(&conn).unwrap();
4080 assert!(
4081 has_unique,
4082 "UNIQUE (agent_id, key) must be present after migration"
4083 );
4084
4085 let count: i64 = conn
4087 .query_row(
4088 "SELECT COUNT(*) FROM memories WHERE key='test-key'",
4089 [],
4090 |r| r.get(0),
4091 )
4092 .unwrap();
4093 assert_eq!(count, 1, "existing memory row must survive the migration");
4094 }
4095
4096 #[cfg(feature = "memory-postgres")]
4097 #[test]
4098 fn memory_schema_version_binds_to_postgres_int4() {
4099 use postgres::types::{ToSql, Type};
4100
4101 fn accepts_int4<T: ToSql>(_: &T) -> bool {
4102 T::accepts(&Type::INT4)
4103 }
4104
4105 let version = postgres_memory_schema_version().expect("version fits Postgres INTEGER");
4106 assert_eq!(i64::from(version), SQLITE_MEMORY_SCHEMA_VERSION);
4107 assert!(accepts_int4(&version));
4108 }
4109
4110 fn predict_v3_path(install: &Path, rel: &Path) -> PathBuf {
4114 let mut parts = rel.components();
4115 let top = parts
4116 .next()
4117 .expect("legacy snapshot paths have at least one component");
4118 let top_name = top.as_os_str().to_string_lossy().to_string();
4119
4120 if v2_workspace_toplevel_dest(&top_name) == V2WorkspaceDest::MemorySubentryDispatch {
4122 let sub = parts.next();
4123 let Some(sub) = sub else {
4124 return workspace_toplevel_v3_path(install, &top_name);
4125 };
4126 let sub_name = sub.as_os_str().to_string_lossy().to_string();
4127 let base = memory_subentry_v3_path(install, &sub_name);
4128 let rest: PathBuf = parts.as_path().to_path_buf();
4129 if rest.as_os_str().is_empty() {
4130 base
4131 } else {
4132 base.join(rest)
4133 }
4134 } else {
4135 let top_v3 = workspace_toplevel_v3_path(install, &top_name);
4136 let rest: PathBuf = parts.as_path().to_path_buf();
4137 if rest.as_os_str().is_empty() {
4138 top_v3
4139 } else {
4140 top_v3.join(rest)
4141 }
4142 }
4143 }
4144
4145 #[test]
4146 fn fresh_install_is_noop() {
4147 let tmp = TempDir::new().unwrap();
4148 let report =
4149 migrate_v2_to_v3_install_filesystem(tmp.path()).expect("fresh install must be a no-op");
4150 assert_eq!(report.entries_relocated, 0);
4151 assert!(report.backup_dir.is_none());
4152 }
4153
4154 #[test]
4155 fn refuse_to_clobber_existing_v3_target() {
4156 let tmp = TempDir::new().unwrap();
4157 let install = tmp.path();
4158 seed_v2_install(install);
4159
4160 fs::create_dir_all(install.join("data")).unwrap();
4163 let v3_devices = workspace_toplevel_v3_path(install, "devices.db");
4164 fs::write(&v3_devices, b"operator-owned").unwrap();
4165
4166 let _ = migrate_v2_to_v3_install_filesystem(install).expect("migration must not fail");
4167
4168 let after = fs::read(&v3_devices).unwrap();
4170 assert_eq!(
4171 after, b"operator-owned",
4172 "refuse-to-clobber: operator file must survive"
4173 );
4174
4175 let legacy_still = install.join("workspace/devices.db").exists();
4178 let in_backup = fs::read_dir(install).unwrap().flatten().any(|e| {
4179 let n = e.file_name().to_string_lossy().to_string();
4180 n.starts_with("backup-") && e.path().join("legacy-workspace/devices.db").exists()
4181 });
4182 assert!(
4183 legacy_still || in_backup,
4184 "legacy devices.db must be preserved (in legacy/ or backup/)"
4185 );
4186 }
4187}