Skip to main content

zeroclaw_config/schema/
v2.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4/// V1/V2 supported a "colon-URL" provider string form (e.g.
5/// `"anthropic-custom:https://api.z.ai/api/anthropic"`) where the URL was
6/// embedded inline. V3 uses a typed `uri` field on the per-provider
7/// alias entry. This helper splits the colon-URL form into `(type, url)`
8/// so the migration can use `type` as the V3 provider key and store the
9/// URL in `uri` on the alias entry. Returns `(type_key, Some(url))`
10/// for colon-URL forms; otherwise `(raw.to_string(), None)`.
11fn 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/// V2 partial typed lens. Everything not explicitly named flows through
25/// `passthrough` unchanged.
26#[derive(Debug, Default, Deserialize, Serialize)]
27pub struct V2Config {
28    #[serde(default = "default_v2_schema_version")]
29    pub schema_version: u32,
30
31    /// V3 synthesizes `risk_profiles` from this block.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub autonomy: Option<toml::Value>,
34
35    /// V3 synthesizes `runtime_profiles` from this block.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub agent: Option<toml::Value>,
38
39    /// V3 dropped swarms entirely.
40    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
41    pub swarms: HashMap<String, toml::Value>,
42
43    /// V3 restructures cron: `[cron.<alias>] = CronJobDecl`; subsystem knobs
44    /// (`enabled`, `catch_up_on_startup`, `max_run_history`) move to `[scheduler]`.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub cron: Option<toml::Value>,
47
48    /// V3 restructures providers: drops `fallback`, aliases `models`, adds `tts`.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub providers: Option<toml::Value>,
51
52    /// V3 drops `cost.prices`; pricing moves inline onto each model provider.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub cost: Option<toml::Value>,
55
56    /// V3 wraps each channel section in `HashMap<String, T>` (alias-keyed) and
57    /// folds `discord_history` into `discord.<alias>.archive = true`.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub channels: Option<toml::Value>,
60
61    /// V3 replaces inline brain fields on each agent with model-provider
62    /// alias references; brain fields surface as new entries under
63    /// `model_providers.<provider>.agent_<id>`.
64    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
65    pub agents: HashMap<String, toml::Value>,
66
67    /// Everything else passes through unchanged.
68    #[serde(flatten)]
69    pub passthrough: toml::Table,
70}
71
72fn default_v2_schema_version() -> u32 {
73    2
74}
75
76/// Channel section keys subject to V3 alias-wrapping. A missing entry
77/// here sends its V2 `[channels.<type>]` block through the passthrough
78/// branch, which leaves it flat instead of `<type>.default`-shaped and
79/// the V3 deserializer then chokes on the typed `HashMap<String, T>`
80/// slot. Tests cross-check this list against the typed channel slots
81/// on `ChannelsConfig` to catch silent drift.
82pub 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    /// Returns a V3-shaped `toml::Value`. The caller deserializes it
121    /// into `Config` — that round-trip is the gate that catches any
122    /// structural mismatch.
123    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        // autonomy → risk_profiles.default + runtime_profiles.default.
138        //
139        // Authorization fields (allowlists, sandbox, approval gates,
140        // env passthrough) land on the risk profile. Budget caps
141        // (`max_actions_per_hour`, `max_cost_per_day_cents`,
142        // `shell_timeout_secs`) and recursion/timeout fields
143        // (`max_delegation_depth`, `delegation_timeout_secs`,
144        // `agentic_timeout_secs`) land on the runtime profile because
145        // they are operational tuning enforced with subagent
146        // parent-subset discipline, not authorization decisions.
147        //
148        // V2 `non_cli_excluded_tools` renames to V3 `excluded_tools`
149        // (broader scope, same shape).
150        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        // V3 RiskProfileConfig absorbed [security.sandbox]; the
191        // [security.resources] block is dropped (max_memory_mb,
192        // max_cpu_time_seconds, max_subprocesses, memory_monitoring
193        // were never wired to any enforcement codepath; sandbox
194        // backends carry their own resource budgets).
195        fold_security_into_risk_profile(&mut passthrough);
196
197        // agent → runtime_profiles.default + risk_profiles.default.
198        //
199        // Most agent-section fields are operational tuning and land on
200        // the runtime profile. `allowed_tools` is the one authorization
201        // field on V2's `[agent]` block (which tools may the agent
202        // call), so it moves to `[risk_profiles.default.allowed_tools]`
203        // alongside `allowed_commands`.
204        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        // V3 dropped swarms.
243        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        // V3 eradicated provider fallback. Strip the V2 reliability
252        // fields that referenced it; the rest of [reliability] stays.
253        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        // Restructure providers: drop fallback, alias-wrap models,
266        // fold V2 [providers] globals down to per-provider entries.
267        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        // V3 ModelProviderConfig absorbed the V2 [providers] globals
283        // (api_key, default_model, etc.) inline; fold them down.
284        fold_providers_globals_into_models(&mut new_providers, &mut aliased_models);
285
286        // V3 dropped cost.prices: the V2 keys ("<provider>/<model>")
287        // don't carry the V3 alias path, so remapping is fragile.
288        // Log each entry's last-known rates for manual reinstatement.
289        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        // V3 renamed the route field `provider` → `model_provider` to
303        // disambiguate from TTS/transcription providers. Apply to both
304        // the [providers.<routes>] nested form and the bare top-level
305        // [[model_routes]] / [[embedding_routes]] arrays.
306        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        // Promote V2 [tts.<type>] / [transcription.<family>] sub-blocks
312        // into V3 [<kind>_providers.<type>.default]. Global
313        // default_provider keys are dropped — V3 has no such concept;
314        // each agent declares its own provider.
315        fold_v2_tts_into_providers(&mut passthrough, &mut new_providers);
316        fold_v2_transcription_into_providers(&mut passthrough, &mut new_providers);
317
318        // V3 collapses model/tts/transcription providers under a single
319        // top-level `[providers]` table, with one sub-key per category.
320        // Hoist providers.{models,tts,transcription} into a shared
321        // `providers` table; *_routes stay top-level.
322        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        // V2 [memory.qdrant], [memory.postgres], and [storage.provider.config]
357        // all collapse into V3 [storage.<backend>.<alias>].
358        fold_v2_storage_subsystems(&mut passthrough);
359
360        // Alias-wrap each [channels.<type>], fold discord_history into
361        // [channels.discord.<alias>].archive, and lift per-channel
362        // inbound peer-auth fields (allowed_users, allowed_contacts,
363        // allowed_from, allowed_numbers, allowed_senders, allowed_pubkeys)
364        // into synthesized [peer_groups.<type>_default] entries. The
365        // peer_groups sink is additive — operator entries survive.
366        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        // V3 makes agents explicit — V1/V2 had an implicit single-agent
402        // model. Strip inline brain fields onto provider aliases; if no
403        // [agents] blocks but brain config exists, synthesize a default
404        // agent (with the profile entries it references) so the upgrade
405        // has at least one runnable agent.
406        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        // V3 demoted [identity] to per-agent. Lift the V2 top-level block
421        // into each declared [agents.<alias>.identity]. Runs after the
422        // agents fold so synthesized and pre-existing agents both get it.
423        lift_top_level_identity_into_agents(&mut passthrough);
424
425        // V3 requires heartbeat.agent to be set when enabled=true.
426        // V2 fell through to the implicit single agent; point this at
427        // the synthesized (or first preserved) agent.
428        backfill_heartbeat_agent(&mut passthrough);
429
430        // peer_groups synthesized in the channels step used the bridge
431        // alias "default". If named agents won out (no agents.default),
432        // rewrite each peer_groups.<X>.agents = ["default"] to the
433        // surviving agent alias.
434        rewrite_dangling_peer_group_agents(&mut passthrough);
435
436        // V3 renamed `provider` to a domain-qualified noun on a few
437        // tables. Without this rewrite V3 errors with `missing field
438        // <noun>_provider`.
439        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
453/// Rename `inner` to `replacement` inside the `[<parent>]` table when both
454/// the parent and the inner key are present. No-op if either is absent or
455/// if `replacement` already exists (operator wins; their explicit V3 key is
456/// the source of truth). Used for V3 schema field renames where the
457/// migration just needs to rewrite a flat scalar in place.
458fn 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        // Operator already wrote the V3 key; nothing to do. If they ALSO
464        // wrote the V2 key, drop the stale one so the deserializer doesn't
465        // see a stray field on a `#[serde(deny_unknown_fields)]` struct.
466        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
483/// Split V2 `[cron]` into V3 `[cron.<alias>]` and `[scheduler]` extras.
484fn 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    // V2 had `[[cron.jobs]]` array; V3 keys each job by its HashMap
493    // alias, which makes the V2 `id: String` field redundant. Strip it.
494    if let Some(toml::Value::Array(jobs)) = cron_table.remove("jobs") {
495        for (i, job) in jobs.into_iter().enumerate() {
496            // Pick alias key: name slug → id → fallback `job_N`.
497            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    // Subsystem knobs move to [scheduler].
521    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    // Anything left was unknown to V2 cron; surface but don't drop silently —
528    // dropped fields are visible in INFO logs instead.
529    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
555/// Normalize a V2 provider type string to its V3 canonical name plus the
556/// extras that the typed family config requires (region endpoint, auth_mode,
557/// alias rename, family-specific fields).
558///
559/// Returns `(canonical_type, alias_key, extras_to_inject)`. `extras_to_inject`
560/// is a vec of `(field_name, toml::Value)` pairs that the migration writes
561/// onto the alias entry table — typically `endpoint = "cn"` for regional
562/// collapses, `auth_mode = "oauth"` for oauth-mode collapses, `wire_api =
563/// "responses"` + `requires_openai_auth = true` for the openai_codex fold.
564///
565/// The alias spellings here mirror the V2 registry's match arms in
566/// `crates/zeroclaw-providers/src/lib.rs` (`is_<vendor>_alias` functions).
567fn 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    // Vendor-canonical collapses (synonym kills only; alias unchanged).
574    let synonym_canonical = match raw {
575        // Azure: vendor name; was azure_openai|azure-openai|azure
576        "azure_openai" | "azure-openai" | "azure" => Some("azure"),
577        // xAI: was xai|grok
578        "xai" | "grok" => Some("xai"),
579        // Gemini: vendor product name; was gemini|google|google-gemini
580        "gemini" | "google" | "google-gemini" => Some("gemini"),
581        // Together: was together|together-ai
582        "together" | "together-ai" => Some("together"),
583        // Fireworks: was fireworks|fireworks-ai
584        "fireworks" | "fireworks-ai" => Some("fireworks"),
585        // Vercel AI Gateway: was vercel|vercel-ai
586        "vercel" | "vercel-ai" => Some("vercel"),
587        // Cloudflare AI Gateway: was cloudflare|cloudflare-ai
588        "cloudflare" | "cloudflare-ai" => Some("cloudflare"),
589        // NVIDIA: was nvidia|nvidia-nim|build.nvidia.com
590        "nvidia" | "nvidia-nim" | "build.nvidia.com" => Some("nvidia"),
591        // Bedrock: was bedrock|aws-bedrock
592        "bedrock" | "aws-bedrock" => Some("bedrock"),
593        // LMStudio: was lmstudio|lm-studio
594        "lmstudio" | "lm-studio" => Some("lmstudio"),
595        // LiteLLM: was litellm|lite-llm
596        "litellm" | "lite-llm" => Some("litellm"),
597        // HuggingFace: was huggingface|hf
598        "huggingface" | "hf" => Some("huggingface"),
599        // Yi: was yi|01ai|lingyiwanwu
600        "yi" | "01ai" | "lingyiwanwu" => Some("yi"),
601        // Hunyuan: was hunyuan|tencent
602        "hunyuan" | "tencent" => Some("hunyuan"),
603        // Qianfan/Baidu: was qianfan|baidu
604        "qianfan" | "baidu" => Some("qianfan"),
605        // Copilot: was copilot|github-copilot
606        "copilot" | "github-copilot" => Some("copilot"),
607        // OVH: was ovhcloud|ovh
608        "ovhcloud" | "ovh" => Some("ovh"),
609        // OpenCode: was opencode|opencode-zen, opencode-go folded as alias=go
610        "opencode" | "opencode-zen" => Some("opencode"),
611        // llama.cpp: was llamacpp|llama.cpp (dot in key drops)
612        "llamacpp" | "llama.cpp" => Some("llamacpp"),
613        // DeepMyst: was deepmyst|deep-myst
614        "deepmyst" | "deep-myst" => Some("deepmyst"),
615        // SiliconFlow: was siliconflow|silicon-flow
616        "siliconflow" | "silicon-flow" => Some("siliconflow"),
617        // DeepInfra: was deepinfra|deep-infra
618        "deepinfra" | "deep-infra" => Some("deepinfra"),
619        // AI21: was ai21|ai21-labs
620        "ai21" | "ai21-labs" => Some("ai21"),
621        // Friendli: was friendli|friendliai
622        "friendli" | "friendliai" => Some("friendli"),
623        // Lepton: was lepton|lepton-ai
624        "lepton" | "lepton-ai" => Some("lepton"),
625        // Lambda AI: lambda_ai|lambda-ai
626        "lambda_ai" | "lambda-ai" => Some("lambda_ai"),
627        // GitHub Models: github_models|github-models
628        "github_models" | "github-models" => Some("github_models"),
629        // Stepfun: was stepfun|step (stepfun-intl handled below as variant)
630        "stepfun" | "step" => Some("stepfun"),
631        // KiloCli: was kilocli|kilo-cli
632        "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    // opencode-go folds under opencode as alias=go
641    if raw == "opencode-go" {
642        return ("opencode".to_string(), "go".to_string(), extras);
643    }
644
645    // OpenAI Codex folds under openai with wire_api=responses + requires_openai_auth=true
646    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    // claude-code folds under anthropic.claude-code (preserved from prior
653    // migration; the canonical name for Anthropic's CLI variant).
654    if raw == "claude-code" {
655        return ("anthropic".to_string(), "claude-code".to_string(), extras);
656    }
657
658    // anthropic-custom is the V1/V2 colon-URL form for "Anthropic-API at
659    // a custom URL" (the URL was already split out into `uri` above by
660    // `alias_provider_models`). Folds under anthropic with alias "custom"
661    // so a stock `anthropic.default` entry and an `anthropic-custom:URL`
662    // entry both migrate cleanly without clobbering each other.
663    if raw == "anthropic-custom" {
664        return ("anthropic".to_string(), "custom".to_string(), extras);
665    }
666
667    // `custom` (the bare V2 placeholder for "user-supplied URL") folds
668    // under the dedicated `custom` typed slot. Preserves the colon-URL
669    // form's URI on the alias entry.
670    if raw == "custom" {
671        return ("custom".to_string(), incoming_alias.to_string(), extras);
672    }
673
674    // Regional + OAuth collapse for Chinese-vendor families. Each block
675    // mirrors the upstream/master V2 alias-detector functions verbatim.
676
677    // Moonshot/Kimi
678    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    // Qwen / DashScope / Bailian
695    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    // GLM / Zhipu
721    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    // Z.AI
731    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    // Minimax (cn/intl + oauth)
741    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    // Doubao / Volcengine
769    if matches!(raw, "doubao" | "volcengine" | "ark" | "doubao-cn") {
770        return ("doubao".to_string(), incoming_alias.to_string(), extras);
771    }
772
773    // gemini-cli stays as a separate slot (subprocess runtime, not a synonym)
774    if raw == "gemini-cli" {
775        return ("gemini_cli".to_string(), incoming_alias.to_string(), extras);
776    }
777
778    // stepfun-intl folds into stepfun with a different uri
779    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    // Unknown/passthrough: keep the raw key. Silent drop will happen at V3
788    // deserialize if it doesn't match any typed slot — that's the migration's
789    // accountability gap, intentional per #6273. Operators with truly novel
790    // names (a forked custom backend) need a slot defined for it.
791    (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        // Colon-URL form like `"anthropic-custom:https://..."`: split the URL
802        // out into `uri` and use only the prefix as the seed for normalization.
803        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        // V2 per-block `base_url` + optional `api_path` → V3 `uri` (full
812        // endpoint URL). Matches the same concatenation
813        // `fold_providers_globals_into_models` applies to V2 top-level
814        // globals — without this, per-block [model_providers.<id>] entries
815        // would survive into V3 with the unknown `base_url`/`api_path`
816        // keys, and V3 deserialize silently drops them.
817        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        // Inject family-specific extras (endpoint, auth_mode, wire_api,
824        // requires_openai_auth, uri) onto the alias entry table — overrides
825        // by the operator's own config win via .or_insert.
826        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
842/// Fold V2 `[providers]` global fields (which lived directly on `ProvidersConfig`)
843/// onto the V3 per-provider `ModelProviderConfig` entry.
844///
845/// Field renames applied during the fold:
846/// - `api_url` (+ optional `api_path` suffix) → `uri` (matches V3 `ModelProviderConfig.uri`)
847/// - `default_model` → `model`
848/// - `default_temperature` → `temperature`
849/// - `provider_timeout_secs` → `timeout_secs`
850/// - `provider_max_tokens` → `max_tokens`
851///
852/// Target entry resolution:
853/// - If `default_provider` is a string and matches a key in `aliased_models`, fold there.
854/// - Otherwise, if `aliased_models` already has at least one entry, fold onto its
855///   first entry's `default` alias (this matches V1 `[model_providers.<id>]` blocks
856///   that had no separate `default_provider` declaration).
857/// - Otherwise, synthesize a fresh `<default_provider | "openrouter">.default`
858///   entry to hold the globals (matches V1's documented default provider).
859///
860/// `claude-code` continues to map under `anthropic.claude-code` per the V3 fold.
861///
862/// Per-provider explicit fields take precedence: globals only fill in missing slots.
863fn 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    // Determine target (provider_type, alias). For colon-URL forms like
891    // `"anthropic-custom:https://..."`, split the URL out of the type key so
892    // the V3 reference grammar (`<type>.<alias>`) doesn't tokenize at a URL
893    // dot. The URL is folded into `uri` below.
894    //
895    // Then run the V2-EOL provider name through `normalize_provider_type` so
896    // synonym kills + regional/oauth collapses + claude_code/openai_codex
897    // folds happen here too — same canonical-naming gate as
898    // `alias_provider_models`. Without this, an operator with
899    // `default_provider = "grok"` would land in a `grok` slot that doesn't
900    // exist on V3 ModelProviders and silently disappear.
901    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    // The colon-URL form's URL portion (split from default_provider) takes
935    // precedence over the global `api_url` field — both originate from V2's
936    // top-level providers block, but the colon-URL form was the more specific
937    // hint when the user wrote `default_provider = "anthropic-custom:<url>"`.
938    // V3's `uri` field is the full endpoint URL — concatenate any V2 `api_path`
939    // suffix onto it, since `api_path` no longer exists separately.
940    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        // api_path alone, without a base, has nowhere to live in V3 — drop.
953        (None, _) => None,
954    };
955
956    // Per-provider entries take precedence: only fill missing slots.
957    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    // Inject family-specific extras (endpoint, auth_mode, wire_api,
974    // requires_openai_auth, uri) from the normalize_provider_type call
975    // above. Operator-set fields win — only fill missing slots.
976    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
993/// Pull `prices` (a per-model HashMap) out of a V2 `[cost]` block.
994/// Returns `(cost_passthrough, prices)`. `prices` keys are model identifiers;
995/// values are `ModelPricing` tables.
996fn 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            // Unexpected shape — reinsert and skip the fold.
1005            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
1018/// Drop V2 `[cost.prices.*]` entries. V2 keyed pricing by composite
1019/// `"<provider>/<model>"` identifiers that don't carry the V3
1020/// `<provider_type>.<alias>` path, so any automatic remap is fragile.
1021/// Operators paste the rates manually under the right V3
1022/// `[model_providers.<type>.<alias>].pricing` block; the INFO log per
1023/// entry names the model id and last-known input/output rates.
1024fn 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
1047/// Synthesize one `[peer_groups.<channel_type>_<alias>]` entry from a
1048/// V2 channel's inbound peer-auth allow-list, and emit an INFO log.
1049/// The per-channel arms in [`apply_v2_to_v3_channel_folds`] each:
1050///
1051///   1. `instance.remove("<field>")` (V3 has no slot for the field —
1052///      strip regardless of whether the fold synthesizes a group).
1053///   2. Call this helper with the removed array and the channel's V3
1054///      `<type>.<alias>` ref so the synthesized group lands in
1055///      `peer_groups`.
1056///
1057/// Skip rules: empty arrays and any list containing `"*"` produce no
1058/// group (a peer group can't express "anyone"). Collisions with an
1059/// operator-authored `[peer_groups.<type>_<alias>]` are left
1060/// untouched.
1061///
1062/// V1/V2 had implicit single-agent semantics, so the synthesized
1063/// group always binds the migration-bridge `default` agent. That is
1064/// the *only* legitimate `default` usage in the V2→V3 fold path —
1065/// post-migration the operator owns peer_group membership.
1066fn 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        // Operator-authored group with the synthesized name wins.
1089        return;
1090    }
1091    let mut group_entry = toml::Table::new();
1092    // Channel type only (peer-groups bind to the type, not an alias).
1093    group_entry.insert(
1094        "channel".to_string(),
1095        toml::Value::String(channel_type.to_string()),
1096    );
1097    // V1/V2 single-agent semantics — bridge alias `default`.
1098    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
1119/// Wrap V2 `Option<T>` channel sections into V3 `HashMap<String, T>` keyed
1120/// by `"default"`. Applies, per channel instance:
1121///
1122/// - **discord_history fold**: `[channels.discord_history]` →
1123///   `[channels.discord]` with `archive = true`. Effective `enabled` is
1124///   the OR of both sides so a user with only
1125///   `discord_history.enabled = true` still ends up with an enabled
1126///   merged discord block.
1127/// - Singular→plural fold per channel type (`discord.guild_id` →
1128///   `guild_ids[]`, `mattermost.channel_id` → `channel_ids[]`,
1129///   `reddit.subreddit` → `subreddits[]`, `signal.group_id` →
1130///   `group_ids[]` or `dm_only=true` for the `"dm"` sentinel).
1131///
1132/// `cli: bool` is preserved at the top-level `channels.cli`, not aliased.
1133fn 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    // CLI is a top-level bool, not aliased.
1141    if let Some(cli) = channels_table.remove("cli") {
1142        new_channels.insert("cli".to_string(), cli);
1143    }
1144
1145    // Fold discord_history into discord BEFORE the enabled filter so a
1146    // discord_history-only user with `enabled=true` survives into V3.
1147    fold_discord_history(&mut channels_table);
1148
1149    // V3 collapses Feishu and Lark to one channel type — they share the same
1150    // bot framework, only the API endpoint differs (Feishu = open.feishu.cn
1151    // for China, Lark = open.larksuite.com for international). Stash the V2
1152    // [channels.feishu] block here so the alias-wrap loop processes the V2
1153    // [channels.lark] block normally; the stash is re-injected after the loop
1154    // as [channels.lark.feishu] (NOT lark.default) so two-bot deployments
1155    // survive without operator intervention.
1156    let stashed_feishu_v2 = strip_feishu_block(&mut channels_table);
1157
1158    // Per-channel-type: singular→plural fold, peer-auth lift into
1159    // [peer_groups.<type>_default], then alias-wrap as <type>.default.
1160    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                // Unexpected shape — wrap raw value under "default" without
1168                // any of the V3 transforms. This preserves data; V3
1169                // deserialize will surface the type error.
1170                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        // V3 keeps the `enabled` field on every channel config — V2's
1179        // boolean ports through verbatim and the orchestrator gates on
1180        // it at registration time. Missing `enabled` deserializes to
1181        // `false` via `#[serde(default)]`, matching V2 semantics.
1182        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    // Unmodeled channel-section keys: pass through under their original key.
1188    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    // Re-inject the stashed V2 [channels.feishu] block as [channels.lark.feishu]
1204    // with use_feishu = true. The alias name is "feishu" — not "default" — so a
1205    // two-bot deployment with both [channels.lark] (international) AND
1206    // [channels.feishu] (CN) survives as [channels.lark.default] +
1207    // [channels.lark.feishu]; both bots remain reachable post-migration.
1208    inject_feishu_as_lark_alias(&mut new_channels, stashed_feishu_v2);
1209
1210    new_channels
1211}
1212
1213/// Pre-alias-wrap: remove the V2 `[channels.feishu]` block from `channels`
1214/// (so the alias-wrap loop doesn't process it) and return its body for
1215/// post-wrap injection as `[channels.lark.feishu]`.
1216fn 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
1232/// Post-alias-wrap: insert the stashed V2 feishu block as
1233/// `[channels.lark.feishu]` with `use_feishu = true`. The alias name is
1234/// `feishu` (not `default`) so a two-bot V2 deployment with both
1235/// `[channels.lark]` (international) AND `[channels.feishu]` (CN) survives as
1236/// two distinct V3 aliases — `lark.default` and `lark.feishu` — without
1237/// losing data or requiring operator intervention.
1238///
1239/// If a `lark.feishu` alias already exists in `new_channels` (impossible
1240/// from V2 input but cheap to defend), we do not overwrite — the existing
1241/// entry wins and a WARN names the dropped source.
1242fn 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
1282/// Fold V2 `[channels.discord_history]` into `[channels.discord]` in place.
1283/// Sets `archive = true`. Effective `enabled` = `discord.enabled` OR
1284/// `discord_history.enabled`. Existing discord keys win over history keys
1285/// for non-`enabled` fields (so a user-set discord.bot_token isn't
1286/// overwritten by history's bot_token).
1287///
1288/// When both blocks have a `bot_token` and the values **differ**, emit
1289/// one `WARN` line naming the source block whose token was dropped
1290/// (`[channels.discord_history].bot_token`) and the surviving block
1291/// (`[channels.discord]`). The dropped value itself is **not** logged
1292/// — operators recover from the pre-migration `<config>.backup`.
1293/// Two-bot deployments must reconfigure manually.
1294fn 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    // Capture the conflict signal BEFORE the merge mutates either side.
1301    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                    // Handled explicitly via effective_enabled below.
1339                    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
1368/// Apply V2→V3 singular→plural folds:
1369/// `discord.guild_id` → `guild_ids[]`, `mattermost.channel_id` → `channel_ids[]`,
1370/// `reddit.subreddit` → `subreddits[]`, and `signal.group_id` → `group_ids[]`
1371/// (with the `"dm"` sentinel mapped to `dm_only=true` instead).
1372fn 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            // Special: V2 group_id="dm" was a sentinel meaning "DMs only".
1398            // V3 splits that into a typed dm_only bool. Other group_id
1399            // values fold into group_ids[] like the simpler renames.
1400            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
1432/// V2 → V3 inbound peer-auth fold per channel. Each channel that had
1433/// a user-allowlist field in V2 strips it from the instance and
1434/// synthesizes the V3 peer_group binding `default` agent to this
1435/// channel. Field name varies per platform; helper handles wildcard
1436/// / empty / collision skip rules uniformly.
1437///
1438/// Field-name table (the only place this list lives):
1439///
1440/// - Most channels: `allowed_users`
1441/// - iMessage:      `allowed_contacts`
1442/// - Signal:        `allowed_from`
1443/// - WhatsApp/Wati: `allowed_numbers`
1444/// - Linq/Email/GmailPush: `allowed_senders`
1445/// - Nostr:         `allowed_pubkeys`
1446///
1447/// Channels with no inbound peer-auth concept (Webhook, Reddit,
1448/// Bluesky, MQTT, voice_*, ClawdTalk, CLI) return `None` and the
1449/// function is a no-op.
1450fn 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
1479/// Strip V2-specific fields from each agent and synthesize the V3 alias
1480/// references / per-agent profile overrides. Specifically:
1481///
1482/// - Inline brain fields (`provider`/`model`/`api_key`/`temperature`)
1483///   fold into a synthesized `model_providers.<provider>.agent_<id>`
1484///   entry; the agent gets `model_provider = "<provider>.agent_<id>"`.
1485/// - `max_iterations` is renamed to `max_tool_iterations` inline.
1486/// - `agentic` / `allowed_tools` / `timeout_secs` / `agentic_timeout_secs`
1487///   lift into a synthesized `runtime_profiles.agent_<id>`.
1488/// - `max_depth` lifts into a synthesized
1489///   `risk_profiles.agent_<id>.max_delegation_depth`.
1490/// - `skills_directory` lifts into a synthesized
1491///   `skill_bundles.agent_<id>.directory` and the alias is appended
1492///   to the agent's `skill_bundles` array.
1493/// - `memory_namespace` is dropped — V3 isolates memory under
1494///   `[agents.<alias>.memory]` instead.
1495/// - Every agent ends with `risk_profile` and `runtime_profile` set
1496///   to either a synthesized `agent_<id>` alias or `default`, with
1497///   the referenced profile entries guaranteed to exist (V3
1498///   validation rejects dangling profile refs).
1499fn 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        // Brain fold: provider/model/api_key/temperature/timeout_secs →
1514        // model-provider alias. V2's per-agent `timeout_secs` was the HTTP
1515        // timeout for LLM calls; V3 hangs it off the model_provider entry,
1516        // not the agent.
1517        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            // Colon-URL form: split the URL out so the V3 outer key stays
1524            // dot-free and the URL lands in `uri`. Without this,
1525            // `split_once('.')` would tokenize at a URL dot like the one
1526            // inside `api.z.ai`.
1527            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            // V3 keeps every provider category under `[providers]`:
1546            // `[providers.models.<type>.<alias>]` is the destination.
1547            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            // No provider declared but operator still set timeout_secs;
1570            // drop it rather than silently storing on the agent block,
1571            // since V3 has no agent-level slot for it.
1572            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        // max_iterations lifts into the synthesized per-agent runtime
1590        // profile as max_tool_iterations.
1591        let max_iterations = agent_table
1592            .remove("max_iterations")
1593            .or_else(|| agent_table.remove("max_tool_iterations"));
1594
1595        // V2 per-agent overrides split into authorization (risk) and
1596        // operational (runtime) buckets, matching the V3 profile shape:
1597        //   risk: allowed_tools
1598        //   runtime: agentic, max_delegation_depth (from V2 max_depth),
1599        //            agentic_timeout_secs
1600        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        // skills_directory → synthesize a per-agent skill_bundle and
1657        // append its alias to agent.skill_bundles. V3 confines bundle
1658        // directories to `<install>/shared/skills/<bundle_alias>/`, so
1659        // V2 paths inside `shared/` survive verbatim; everything else
1660        // (absolute paths, paths above `shared/`) drops the explicit
1661        // directory and falls back to the default. The operator's
1662        // V2 skills need to be copied into the new location after
1663        // migration — surface a warning naming what was dropped.
1664        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            // V3 AliasedAgentConfig.skill_bundles is Vec<String> of aliases.
1683            // Append our synthesized bundle alias (preserve any user-set list).
1684            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        // Every V3 agent must reference a configured risk_profile and
1711        // runtime_profile. For agents that didn't trigger the
1712        // per-agent synthesis above, fall back to "default" and ensure
1713        // both entries exist (V3 rejects dangling profile refs).
1714        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        // V3 retired the V2 `memory_namespace` field on agents (and the
1736        // top-level [memory_namespaces.<alias>] section it referenced)
1737        // when per-agent memory backends landed under
1738        // [agents.<alias>.memory]. Drop the V2 key so it doesn't carry
1739        // through to the V3 deserialization step.
1740        agent_table.remove("memory_namespace");
1741
1742        new_agents.insert(alias, toml::Value::Table(agent_table));
1743    }
1744    new_agents
1745}
1746
1747/// Pull V2 `[agents.<alias>].agentic_timeout_secs` off the agent table
1748/// and hand it to the caller for routing onto the synthesized
1749/// `runtime_profiles.agent_<alias>.agentic_timeout_secs`.
1750fn extract_agentic_timeout_secs(agent: &mut toml::Table) -> Option<toml::Value> {
1751    agent.remove("agentic_timeout_secs")
1752}
1753
1754/// Pull V2 `[agents.<alias>].timeout_secs` off the agent table; the
1755/// caller folds this into the agent's resolved model_provider entry.
1756fn extract_provider_timeout_secs(agent: &mut toml::Table) -> Option<toml::Value> {
1757    agent.remove("timeout_secs")
1758}
1759
1760/// Insert (or merge) a profile entry at `passthrough.<section>.<alias>`.
1761/// Existing keys win — `fields` only fills in missing slots.
1762fn 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
1783/// Insert `(key, value)` pairs from `extras` into a sub-table at `top.<section>`.
1784/// Creates the sub-table if missing; overwrites individual keys but preserves
1785/// other existing keys in the section.
1786fn 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
1797/// Fold V2 `base_url` (+ optional `api_path`) into V3 `uri` on a single
1798/// `[model_providers.<type>.<alias>]` entry table. No-op when `uri` is
1799/// already set (operator wins) or when `base_url` is absent. Matches the
1800/// top-level-globals fold so both V1/V2 entry points produce the same
1801/// V3 shape.
1802fn fold_base_url_api_path_into_uri(entry: &mut toml::Table) {
1803    if entry.contains_key("uri") {
1804        // Operator-set V3 key wins; drop stale V2 spellings so V3
1805        // deserialize doesn't see unknown fields.
1806        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            // No base_url to fold. api_path alone has nowhere to live.
1814            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
1837/// Rewrite any `peer_groups.<X>.agents = ["default"]` entries to point at
1838/// a real agent alias when `agents.default` doesn't exist. Step 7
1839/// synthesizes peer_groups with the bridge alias `"default"` before
1840/// step 8 decides what the actual agent map looks like; this post-pass
1841/// patches up the dangling reference in the multi-agent V2 case where
1842/// `agents.default` is never created.
1843///
1844/// Also injects the peer_group's channel ref into the chosen agent's
1845/// `channels` list. V3 validation rejects an agent listed in a peer_group
1846/// for a channel it doesn't own (`agents.<X>.channels` must contain the
1847/// peer_group's channel); V2 had no per-agent channel binding, so the
1848/// migration extends the chosen agent's reach to cover what V2's implicit
1849/// single-agent semantics expected.
1850///
1851/// No-op when `agents.default` exists (the bridge alias is valid) or
1852/// when the agents map is empty (no fix possible — the operator will
1853/// hit a different validation error). Operator-authored peer_groups
1854/// whose agents list isn't exactly `["default"]` are left untouched.
1855fn 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    // Resolve each bare channel type back to the full set of
1901    // `<type>.<alias>` ChannelRefs that exist in `[channels.<type>.*]`.
1902    // peer_groups now bind to a type only, but agents.<X>.channels
1903    // requires dotted form. The V1/V2 single-agent fold assigned every
1904    // alias of that type to the bridge agent.
1905    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
1950/// V2 → V3 backfill: when `[heartbeat] enabled = true` and `agent` is
1951/// unset/empty, set `agent` to a configured agent alias. Picks `"default"`
1952/// when present (matching the synthesized-default-agent path), otherwise
1953/// the first agent in the table. No-op when `agents` is empty or
1954/// `heartbeat.agent` is already set (operator wins).
1955fn 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
1999/// Ensure `[<section>.<alias>]` exists in `passthrough` as at least an
2000/// empty table. Used when synthesizing the default agent so the agent's
2001/// alias references resolve under V3 dangling-reference validation.
2002fn 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
2013/// Lift the top-level `[identity]` table into each `[agents.<alias>.identity]`
2014/// during V2 → V3. V3 demoted identity to a per-agent block; leaving the
2015/// V2 top-level key intact would surface as an unknown field on the V3
2016/// deserializer. Operators who already wrote a per-agent identity block
2017/// keep it (no clobber). If no agents are present after the fold, the
2018/// top-level block is dropped with a warn (lossy but intentional — V3
2019/// has no other slot for it).
2020fn 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
2068/// If no agents were declared in V2 input but the V2→V3 fold synthesized at
2069/// least one provider model entry, emit a single `agents.default` referencing
2070/// the first provider-alias. This preserves V1/V2 implicit single-agent
2071/// semantics: the V1 user with `default_provider = "openai"` and a brain
2072/// configured globally gets a working V3 default agent automatically.
2073///
2074/// `passthrough` is read (not mutated) — the synthesized agent is returned so
2075/// the caller decides whether to install it under `agents`.
2076fn synthesize_default_agent_if_needed(passthrough: &toml::Table) -> toml::Table {
2077    // V3 keeps every provider category under `[providers]`:
2078    // `[providers.models.<type>.<alias>]`. Walk in via the new path.
2079    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
2119/// V3 TTS provider type keys. Matches the V2 `TtsConfig` per-provider
2120/// option fields.
2121const V3_TTS_TYPES: &[&str] = &["openai", "elevenlabs", "google", "edge", "piper"];
2122
2123/// Promote V2 `[tts.<type>]` per-provider sub-blocks into V3's unified
2124/// `[tts_providers.<type>.default]` alias map.
2125///
2126/// V2 `TtsConfig` had a separate `Option<*TtsConfig>` field per provider
2127/// (`openai`, `elevenlabs`, `google`, `edge`, `piper`); V3 keys them all
2128/// by `<type>.<alias>` like the model providers. `[tts]` top-level
2129/// scalars (`enabled`, `default_voice`, `default_format`,
2130/// `max_text_length`) stay on `[tts]`; `default_provider` is dropped —
2131/// V3 has no global default TTS provider.
2132fn 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            // V2 ElevenLabsTtsConfig.model_id → V3 TtsProviderConfig.model.
2141            // Other V2 sub-types (OpenAi, Google, Edge, Piper) used field
2142            // names that survive into V3's unified TtsProviderConfig as-is.
2143            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
2178/// Fold V2 `[transcription]` flat block + per-family sub-blocks into V3's
2179/// typed `[transcription_providers.<family>.<alias>]` shape. The Groq
2180/// fields lived directly on `[transcription]` in V2 (api_key, api_url,
2181/// model, language, initial_prompt) — they migrate to
2182/// `[transcription_providers.groq.default]`. Per-family sub-blocks
2183/// (`[transcription.openai]`, etc.) migrate to
2184/// `[transcription_providers.<family>.default]`.
2185///
2186/// Behavior fields (`enabled`, `transcribe_non_ptt_audio`,
2187/// `max_duration_secs`) stay on `[transcription]`. Legacy default-provider
2188/// keys (`default_provider`, `default_model_provider`,
2189/// `default_transcription_provider`) are dropped — V3 has no global
2190/// default; per-agent `transcription_provider` is the only selector.
2191fn 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    // Per-family sub-blocks: move to transcription_providers.<family>.default.
2202    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    // Groq lived directly on [transcription] in V2. Extract its fields into
2218    // [transcription_providers.groq.default] so V3 can find it via the typed
2219    // family slot. Pulled fields: api_key, api_url, model, language,
2220    // initial_prompt. Behavior fields (enabled, transcribe_non_ptt_audio,
2221    // max_duration_secs) stay on [transcription].
2222    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    // Drop legacy default-provider keys — V3 has no global default-provider
2240    // field. Operators select transcription per agent
2241    // (`agent.<X>.transcription_provider`).
2242    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        // Merge into existing providers.transcription if any (operator may
2261        // have written V3-style entries already).
2262        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
2278/// Rename each route entry's V2 `provider` field to V3 `model_provider`.
2279/// Applies to `[providers.<routes_key>]` for `model_routes` and
2280/// `embedding_routes`. Bare provider names get promoted to the V3 dotted
2281/// form (`"openai"` → `"openai.default"`) so the dangling-reference
2282/// validator sees a real `model_providers.<type>.<alias>` reference.
2283fn 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            // Already V3-shaped (operator wrote `model_provider` directly,
2295            // or migration ran twice). Drop a stray `provider` if present
2296            // so downstream serde doesn't trip on an unknown field.
2297            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        // V3's `model_provider` is a dotted alias (`<type>.<alias>`). V2
2303        // wrote a bare provider type (e.g. `"openai"`); promote it to
2304        // `"openai.default"` so V3 deserialize and the dangling-reference
2305        // validator both see a real `model_providers.<type>.<alias>` ref.
2306        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
2332/// Fold V2 `[memory.qdrant]`, `[memory.postgres]`, and
2333/// `[storage.provider.config]` into V3 `[storage.<backend>.<alias>]`. V3
2334/// unified V2's three storage sources under one typed map per backend:
2335///
2336/// - `[memory.qdrant]` → `[storage.qdrant.default]` (same field names).
2337/// - `[memory.postgres]` contributes only `vector_enabled` and
2338///   `vector_dimensions`; the remaining `db_url`, `schema`, `table`
2339///   come from `[storage.provider.config]` when the operator set
2340///   `provider = "postgres"` there.
2341/// - `[storage.provider.config]`'s `provider` field selects the V3
2342///   backend; remaining fields are adapted per-backend (sqlite extracts
2343///   path from a `sqlite://...` URL; qdrant maps `db_url` → `url`;
2344///   postgres maps directly).
2345/// - `[memory].sqlite_open_timeout_secs` lifts onto
2346///   `[storage.sqlite.default].open_timeout_secs`.
2347///
2348/// Operator-authored V3-shaped entries take precedence over the fold.
2349fn 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        // V2 had two layouts: `[storage.provider.config]` (nested) or
2414        // `storage.provider = { provider = "...", db_url = "..." }` (inline).
2415        // Both produce the same parsed structure: a Table with a `config`
2416        // sub-table. Flatten that here.
2417        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            // sqlite_open_timeout_secs from [memory] (already removed above)
2438            // wasn't re-injected, but we previously moved memory.qdrant /
2439            // memory.postgres in here, so fields stay separate per backend.
2440            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
2457/// Drop top-level blocks that the storage fold emptied. `[memory]` requires
2458/// `backend` and `[storage]` requires at least one backend instance, so an
2459/// empty table at either path would fail V3 schema validation. The default
2460/// at parse time (struct Default for both) is the correct fallback when the
2461/// operator hadn't authored anything beyond the now-lifted subentries.
2462fn 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
2472/// Adapt a V2 `StorageProviderConfig` (flat `{provider, db_url, schema,
2473/// table, connect_timeout_secs}`) to the V3 backend-specific shape. Returns
2474/// the chosen backend type and the adapted field table.
2475fn 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            // V2 db_url for sqlite was typically "sqlite:///path" — extract path.
2488            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            // V2 connect_timeout_secs maps to V3 SqliteStorageConfig.open_timeout_secs.
2499            if let Some(v) = config.remove("connect_timeout_secs") {
2500                out.insert("open_timeout_secs".to_string(), v);
2501            }
2502            // schema/table not applicable to sqlite — drop.
2503            (provider_type, out)
2504        }
2505        "postgres" => {
2506            // db_url, schema, table, connect_timeout_secs all map directly.
2507            (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            // schema/table not applicable to qdrant — drop.
2515            (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
2531/// Merge `fields` into `storage_table.<backend>.default`, creating the
2532/// nested tables if missing. Existing keys win — `fields` only fills gaps.
2533fn 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
2549/// Fold V2 `[security.sandbox]` into `risk_profiles.default` and drop
2550/// `[security.resources]`.
2551///
2552/// Field renames during the sandbox fold:
2553/// - `security.sandbox.enabled` → `risk_profiles.default.sandbox_enabled`
2554/// - `security.sandbox.backend` → `risk_profiles.default.sandbox_backend`
2555/// - `security.sandbox.firejail_args` → `risk_profiles.default.firejail_args`
2556///
2557/// `[security.resources]` (max_memory_mb, max_cpu_time_seconds,
2558/// max_subprocesses, memory_monitoring) is dropped: V2 carried the fields
2559/// but no enforcement codepath ever consumed them. Sandbox backends
2560/// (firejail/landlock) own the actual resource budgets they enforce.
2561/// A WARN-level log names the dropped values so an operator who set
2562/// them can reconfigure the equivalent in their sandbox backend.
2563///
2564/// Existing values on the V3 profile take precedence — sandbox globals
2565/// only fill in missing slots.
2566fn 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
2639/// Split a V2 `[autonomy]` block (already key-renamed where applicable)
2640/// into the V3 risk-profile and runtime-profile field sets. The risk
2641/// bucket holds authorization fields; the runtime bucket holds budget
2642/// caps and other operational tuning that the V3 `RuntimeProfileConfig`
2643/// now owns.
2644///
2645/// Returns `(risk_fields, runtime_fields)` as optional tables — `None`
2646/// when the bucket is empty so callers can skip the destination block.
2647fn 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
2675/// Merge a field set into `<profile_kind>.default`, preserving values
2676/// that already exist on the destination (`entry().or_insert`).
2677fn 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
2689/// Rename top-level keys inside a `toml::Value::Table` according to a list of
2690/// `(old, new)` pairs. Non-tables are returned unchanged. Existing values at
2691/// the new key are not overwritten — the rename is best-effort.
2692fn 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
2707/// Lowercase, replace non-alphanumeric runs with underscores, trim underscores.
2708fn 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
2723/// If `key` already exists in `existing`, suffix `_2`, `_3`, … until unique.
2724fn 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
2738// =============================================================================
2739// V2 → V3 filesystem & memory-backend migration
2740// =============================================================================
2741//
2742// One source of truth for every V2→V3 disk move and backend agent_id backfill.
2743// The dispatch tables below drive both production migration and the e2e test;
2744// adding a new legacy entry is one row, picked up by both sides without further
2745// edits.
2746
2747use anyhow::{Context as MigContext, Result as MigResult};
2748use rusqlite::{Connection, OptionalExtension, params};
2749use std::path::{Path, PathBuf};
2750
2751/// Destination class for a top-level entry under the legacy
2752/// `<install>/workspace/` directory.
2753#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2754pub enum V2WorkspaceDest {
2755    /// Wholesale relocation into `<install>/data/<name>`.
2756    DataDir,
2757    /// Wholesale relocation into `<install>/shared/<name>`.
2758    SharedDir,
2759    /// Wholesale relocation into `<install>/agents/default/workspace/<name>`.
2760    AgentDefault,
2761    /// `workspace/memory/` has mixed contents: shared DBs / archive /
2762    /// snapshot stay in `data/memory/`; markdown daily files belong to
2763    /// the agent. The orchestrator iterates subentries and dispatches
2764    /// via [`V2_MEMORY_DATA_NAMES`].
2765    MemorySubentryDispatch,
2766}
2767
2768/// Single canonical V2 → V3 top-level workspace dispatch.
2769///
2770/// Anything not in this list falls through to
2771/// [`V2WorkspaceDest::AgentDefault`].
2772///
2773/// Adding a new entry here is the ONLY edit needed to extend coverage —
2774/// the orchestrator and the e2e test both iterate this table.
2775pub const V2_WORKSPACE_TOPLEVEL_DISPATCH: &[(&str, V2WorkspaceDest)] = &[
2776    ("memory", V2WorkspaceDest::MemorySubentryDispatch),
2777    ("sessions", V2WorkspaceDest::DataDir),
2778    ("state", V2WorkspaceDest::DataDir),
2779    ("skills", V2WorkspaceDest::SharedDir),
2780    // Top-level instance-state file. The DeviceRegistry reader at
2781    // `api_pairing.rs:40` opens `<data_dir>/devices.db`, so unlike
2782    // per-agent files this has to land in `data/`, not in the agent
2783    // workspace where the default-branch would otherwise send it.
2784    ("devices.db", V2WorkspaceDest::DataDir),
2785];
2786
2787/// Subentries of legacy `<install>/workspace/memory/` that belong to the
2788/// shared instance memory dir (`<install>/data/memory/`).
2789///
2790/// Anything else under `workspace/memory/` (notably markdown daily files
2791/// like `2025-04-12.md`) goes to
2792/// `<install>/agents/default/workspace/memory/<name>` so the per-agent
2793/// markdown backend (which reads from the agent workspace) can find them.
2794pub const V2_MEMORY_DATA_NAMES: &[&str] = &[
2795    "brain.db",
2796    "audit.db",
2797    "response_cache.db",
2798    "MEMORY_SNAPSHOT.md",
2799    "archive",
2800];
2801
2802/// V3 root directories that should exist after a successful migration.
2803/// The e2e test asserts every entry under `<install>` is either one of
2804/// these, the post-migration `config.toml(.backup)?`, or a `backup-*/`.
2805pub const V3_INSTALL_ROOT_NAMES: &[&str] = &["data", "shared", "agents"];
2806
2807/// Dispatch a top-level legacy entry name to its V2WorkspaceDest class.
2808pub 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
2817/// V3 destination path for a top-level entry under legacy `workspace/`.
2818///
2819/// For `MemorySubentryDispatch` entries the returned path is the
2820/// `data/<name>` prefix; the caller iterates the entry's subdir and uses
2821/// [`memory_subentry_v3_path`] per subentry.
2822pub 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
2836/// V3 destination path for a subentry under legacy `workspace/memory/`.
2837pub 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/// Result of a successful filesystem migration.
2851#[derive(Debug, Clone)]
2852pub struct FilesystemMigrationReport {
2853    /// Timestamped backup directory (e.g. `<install>/backup-20260516T140530`).
2854    /// Empty when no migration ran.
2855    pub backup_dir: Option<PathBuf>,
2856    /// Number of top-level entries relocated.
2857    pub entries_relocated: usize,
2858}
2859
2860/// V2 → V3 install-root filesystem migration.
2861///
2862/// 1. Back up the entire legacy `<install>/workspace/` tree under
2863///    `<install>/backup-<ts>/legacy-workspace/` (copy-not-rename so a
2864///    partial failure leaves the legacy data untouched).
2865/// 2. Iterate legacy top-level entries; for each, look up the V3
2866///    destination via [`workspace_toplevel_v3_path`] (or the
2867///    [`memory_subentry_v3_path`] sub-dispatch for `memory/`) and move it.
2868/// 3. Heal intermediate installs that landed under the old layout by relocating
2869///    `agents/default/workspace/skills/` to `shared/skills/`.
2870///
2871/// Idempotent: on a fresh install or an already-migrated install the
2872/// function is a no-op. Refuses to clobber an existing target —
2873/// surfacing a WARN and leaving the legacy entry in place rather than
2874/// overwriting operator data.
2875pub 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
2983/// Iterate `legacy/` top-level entries and relocate each via the
2984/// dispatch tables. Returns the count of entries successfully moved
2985/// (entries already at the target are counted as moved).
2986fn 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
3029/// Iterate `legacy/memory/`'s subentries and route each per
3030/// [`memory_subentry_v3_path`].
3031fn 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    // Remove the now-empty memory dir (best-effort).
3067    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
3076/// Move `src` to `dst`, creating intermediate dirs and falling back to
3077/// copy+remove for cross-filesystem moves. Returns `Ok(true)` if the
3078/// move ran, `Ok(false)` if the destination already existed (operator
3079/// data preserved, WARN logged, caller continues with the rest of the
3080/// split).
3081fn 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    // Cross-filesystem fallback.
3104    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
3128/// Heal intermediate installs that landed skills under
3129/// `agents/default/workspace/skills/` before the host-wide
3130/// `shared/skills/` layout was introduced. Idempotent.
3131pub 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
3207// -----------------------------------------------------------------------------
3208// SQLite agent_id backfill.
3209// -----------------------------------------------------------------------------
3210
3211/// On-disk schema version stamped after a successful SQLite memory
3212/// migration. Future migrations consult this rather than re-running
3213/// PRAGMA detection.
3214pub 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
3222/// Migrate a SQLite memory database to the V3 multi-agent shape.
3223///
3224/// Adds the `agents` table, the `agent_id` column on `memories`,
3225/// backfills existing rows to a synthesized `default` agent, and
3226/// promotes the column to `NOT NULL REFERENCES agents(id)` via a table
3227/// rebuild. Idempotent: re-running on an already-migrated DB is a
3228/// no-op. Before any destructive step the file is backed up at
3229/// `<db_path>.backup-<ts>` when there are rows that would be touched.
3230///
3231/// The caller is responsible for opening the connection with
3232/// `PRAGMA foreign_keys = ON` (and any other backend-specific PRAGMA
3233/// tuning); this function operates on the open connection.
3234pub 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
3394/// Returns `true` when the `memories` table has a UNIQUE index that covers
3395/// exactly `(agent_id, key)` — the constraint required by the `ON CONFLICT`
3396/// upsert clause.  A DB that has `agent_id NOT NULL` + FK but was created
3397/// before the table-rebuild step (or had it skipped) will return `false`,
3398/// causing `migrate_sqlite_memory_to_v3` to fall through and finish the job.
3399fn sqlite_memories_has_unique_agent_key(conn: &Connection) -> MigResult<bool> {
3400    // `PRAGMA index_list` returns one row per index; `PRAGMA index_info`
3401    // returns one row per column in that index.  We want an index that is
3402    // UNIQUE and whose column set is exactly {"agent_id", "key"}.
3403    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        // PRAGMA index_info does not support parameter binding; format inline.
3418        // Index names come from sqlite_master and are controlled by SQLite
3419        // itself or our own migrations, so this is safe.
3420        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
3452/// Mint or query the `default` agent's row. Idempotent on concurrent
3453/// first-init: the returned UUID is the row that actually persisted,
3454/// not the candidate we attempted to insert.
3455pub fn sqlite_ensure_default_agent_uuid(conn: &Connection) -> MigResult<String> {
3456    sqlite_ensure_agent_uuid(conn, "default")
3457}
3458
3459/// Mint-or-query a single agent row keyed by alias. Used by the
3460/// SQLite migration's default-agent backfill and by the `ensure_agent_uuid`
3461/// trait impl on the memory backend (alias resolution at agent-loop entry).
3462pub 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// -----------------------------------------------------------------------------
3506// Postgres agent_id backfill.
3507// -----------------------------------------------------------------------------
3508
3509/// Migrate a Postgres memory schema to the V3 multi-agent shape.
3510///
3511/// Adds the `agents` table and the `agent_id` column on the qualified
3512/// memories table, with a default-agent backfill. Idempotent: every
3513/// step uses `IF NOT EXISTS` / `ON CONFLICT DO NOTHING` so re-runs are
3514/// no-ops. Uses the low-lock NOT VALID → VALIDATE pattern so the
3515/// upgrade does not take ACCESS EXCLUSIVE on a populated table.
3516///
3517/// Backups are the operator's responsibility for Postgres (documented
3518/// in the release notes); reaching across the network to dump a
3519/// managed cluster from inside the binary is out of scope.
3520#[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
3623// -----------------------------------------------------------------------------
3624// Qdrant agent_id backfill (NEW for V3; closes the gap where pre-V3 points
3625// without `agent_id` payload would be silently filtered out by the
3626// AgentScopedMemory `must` clause).
3627// -----------------------------------------------------------------------------
3628
3629/// V3 default agent_id payload value on Qdrant collections.
3630///
3631/// Qdrant does not maintain an `agents` table; it stores the agent
3632/// alias directly as the `agent_id` payload field. The
3633/// `AgentScopedMemory` wrapper's `must` filter expects `agent_id ==
3634/// "default"` for the V1/V2 single-agent bridge.
3635pub const QDRANT_DEFAULT_AGENT_ID: &str = "default";
3636
3637/// Migrate a Qdrant collection to the V3 multi-agent shape.
3638///
3639/// Scrolls the collection in pages of 1000 points; for any point whose
3640/// payload lacks `agent_id`, issues a `set payload` to add
3641/// `agent_id = "default"`. Idempotent: subsequent runs skip points
3642/// that already carry the field.
3643///
3644/// Backups are the operator's responsibility (documented in the
3645/// release notes); we cannot snapshot a remote Qdrant cluster from
3646/// inside the binary.
3647pub 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            // Match only points that lack agent_id. is_empty supports
3663            // the missing-key case (the filter matches a point whose
3664            // payload key is absent or whose stored value is null).
3665            "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    //! End-to-end V2 → V3 filesystem & DB migration test.
3760    //!
3761    //! Lays down a V2 install in a `TempDir` (real disk), drives the
3762    //! orchestrator, and asserts every relocated path matches the
3763    //! shared dispatch fns (`workspace_toplevel_v3_path`,
3764    //! `memory_subentry_v3_path`). The test loops over the canonical
3765    //! dispatch tables — adding a new entry there auto-extends test
3766    //! coverage with no companion edit here.
3767    use super::*;
3768    use rusqlite::Connection;
3769    use std::collections::BTreeSet;
3770    use std::fs;
3771    use tempfile::TempDir;
3772
3773    /// Walk a directory tree and return a sorted list of (relative
3774    /// path, file contents) pairs. Used to diff pre-migration backup
3775    /// against the legacy snapshot for byte-equal verification.
3776    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    /// Lay down a V2 install rooted at `install`: a `workspace/` tree
3796    /// hitting every dispatch branch, plus a populated `brain.db` in
3797    /// pre-multi-agent shape so the SQLite migration has rows to
3798    /// backfill.
3799    fn seed_v2_install(install: &Path) {
3800        // Top-level instance file (devices.db bug fix lands this in
3801        // data/ post-migration).
3802        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        // Per-agent identity files (default branch → agent workspace).
3810        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        // workspace/sessions/ (wholesale → data/sessions/).
3821        fs::create_dir_all(install.join("workspace/sessions")).unwrap();
3822        fs::write(install.join("workspace/sessions/sessions.db"), b"sessions").unwrap();
3823
3824        // workspace/state/ (wholesale → data/state/).
3825        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        // workspace/skills/ (wholesale → shared/skills/).
3833        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        // workspace/memory/ subentries: split between data/memory/ and
3837        // agents/default/workspace/memory/ per V2_MEMORY_DATA_NAMES.
3838        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                // Real SQLite file so the in-DB migration has something
3847                // to migrate after the FS move.
3848                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        // Markdown daily files (must land in agents/default/workspace/memory/).
3876        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        // Backup is byte-equal to the pre-migration workspace snapshot.
3905        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        // Every legacy file lives at exactly one V3 location, predicted
3912        // by the shared dispatch fns. We never name V3 paths here —
3913        // the same fns the migration uses produce them.
3914        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        // Legacy workspace/ is gone (it was empty after the relocation).
3938        assert!(
3939            !install.join("workspace").exists(),
3940            "legacy workspace must be removed after a clean split"
3941        );
3942
3943        // Nothing outside the V3 root names + backup + (no config.toml in
3944        // this test) lives at install root.
3945        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        // Idempotent re-run is a no-op: same on-disk state afterward.
3961        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        // In-DB migration: open the now-moved brain.db and run
3972        // migrate_sqlite_memory_to_v3. Should backfill agent_id on the
3973        // 2 seeded rows and stamp schema_version.
3974        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        // SQLite migration is idempotent.
4005        migrate_sqlite_memory_to_v3(&brain_path, &conn)
4006            .expect("SQLite migration second run must be a no-op");
4007
4008        // SQLite backup file from the in-DB migration is present.
4009        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    /// Regression test: a DB that already has `agent_id NOT NULL` + FK to
4026    /// `agents` but is **missing** the `UNIQUE (agent_id, key)` constraint
4027    /// (e.g. created by an intermediate build) must still be migrated.
4028    /// Before the fix the guard returned `Ok(true)` too early and the
4029    /// `ON CONFLICT(agent_id, key)` upsert would fail at runtime.
4030    #[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        // Manually build the "partially-migrated" shape: agent_id NOT NULL +
4039        // FK, but NO UNIQUE (agent_id, key) constraint.
4040        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        // Migration must detect the missing unique constraint and re-run.
4072        migrate_sqlite_memory_to_v3(&db_path, &conn)
4073            .expect("migration must succeed on partially-migrated DB");
4074
4075        // Idempotent second call must also succeed.
4076        migrate_sqlite_memory_to_v3(&db_path, &conn).expect("second migration run must be a no-op");
4077
4078        // The unique index must now exist.
4079        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        // Existing row must have survived.
4086        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    /// Predict the V3 absolute path for a legacy path relative to
4111    /// `<install>/workspace/`. Uses the same dispatch fns the migration
4112    /// uses; never names a V3 path literally.
4113    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        // Sub-dispatch for memory/<x>: first segment is the subentry name.
4121        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        // Pre-seed an operator-authored file at the V3 destination for
4161        // devices.db. Migration must NOT overwrite it.
4162        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        // Operator file untouched.
4169        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        // Legacy devices.db is still in place (left for operator inspection)
4176        // OR moved to backup; in either case the file is not lost.
4177        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}