Skip to main content

zeroclaw_runtime/quickstart/
mod.rs

1//! Quickstart apply path.
2//!
3//! Single entry point both surfaces (web gateway, zerocode RPC, CLI)
4//! call to land a [`BuilderSubmission`] into the live [`Config`]. The
5//! runtime never enumerates channel types, provider types, or storage
6//! backends itself — every write goes through `Config::set_prop_persistent`,
7//! which dispatches through the schema-derived `Configurable` tree.
8//! Adding a new channel / provider / storage backend to the schema
9//! lights up in the Quickstart for free.
10
11use serde::{Deserialize, Serialize};
12
13use zeroclaw_config::helpers::kebab_to_snake;
14use zeroclaw_config::presets::{
15    AgentIdentity, BuilderSubmission, ChannelQuickStart, MemoryChoice, ModelProviderChoice,
16    SelectorChoice, risk_preset, runtime_preset,
17};
18use zeroclaw_config::schema::Config;
19
20/// Which surface invoked the Quickstart. Stamped on every event in
21/// the apply path so SSE/dashboard consumers can filter by origin
22/// without parsing message strings.
23#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
24#[serde(rename_all = "snake_case")]
25pub enum Surface {
26    Web,
27    Tui,
28    Cli,
29    Test,
30}
31
32impl Surface {
33    pub fn as_str(self) -> &'static str {
34        match self {
35            Surface::Web => "web",
36            Surface::Tui => "tui",
37            Surface::Cli => "cli",
38            Surface::Test => "test",
39        }
40    }
41}
42
43/// Per-run attribution carried through the apply path so every emitted
44/// event lands with the same correlation id. Constructed by `apply`
45/// and `validate_only`; threaded down into `apply_into` and the
46/// per-selector helpers via `&RunCtx`.
47struct RunCtx {
48    run_id: String,
49    surface: Surface,
50}
51
52impl RunCtx {
53    fn new(surface: Surface) -> Self {
54        // Fall back to nanosecond timestamp if a system without a clock
55        // is somehow in play. Either way the id is unique per process.
56        let run_id = std::time::SystemTime::now()
57            .duration_since(std::time::UNIX_EPOCH)
58            .map(|d| format!("{:x}{:x}", d.as_secs(), d.subsec_nanos()))
59            .unwrap_or_else(|_| format!("{:x}", std::process::id()));
60        Self { run_id, surface }
61    }
62
63    fn base_attrs(&self) -> serde_json::Value {
64        serde_json::json!({
65            "quickstart.run_id": self.run_id,
66            "quickstart.surface": self.surface.as_str(),
67        })
68    }
69}
70
71/// Layer per-event attrs on top of the run-scoped base. Both must be
72/// JSON objects; non-object inputs return `base` unchanged.
73fn merge_attrs(base: serde_json::Value, extra: serde_json::Value) -> serde_json::Value {
74    let (mut base_map, extra_map) = match (base, extra) {
75        (serde_json::Value::Object(b), serde_json::Value::Object(e)) => (b, e),
76        (b, _) => return b,
77    };
78    for (k, v) in extra_map {
79        base_map.insert(k, v);
80    }
81    serde_json::Value::Object(base_map)
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85pub struct AppliedAgent {
86    pub alias: String,
87    pub model_provider: String,
88    pub risk_profile: String,
89    pub runtime_profile: String,
90    pub channels: Vec<String>,
91    pub memory_backend: String,
92}
93
94#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
95#[serde(rename_all = "snake_case")]
96pub enum QuickstartStep {
97    ModelProvider,
98    RiskProfile,
99    RuntimeProfile,
100    Memory,
101    Channels,
102    PeerGroups,
103    Agent,
104}
105
106impl QuickstartStep {
107    #[must_use]
108    pub fn label(self) -> &'static str {
109        match self {
110            Self::ModelProvider => "Model provider",
111            Self::RiskProfile => "Risk profile",
112            Self::RuntimeProfile => "Runtime profile",
113            Self::Memory => "Memory",
114            Self::Channels => "Channels",
115            Self::PeerGroups => "Peer groups",
116            Self::Agent => "Agent",
117        }
118    }
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
122pub struct QuickstartError {
123    pub step: QuickstartStep,
124    pub field: String,
125    pub message: String,
126}
127
128impl QuickstartError {
129    fn new(step: QuickstartStep, field: impl Into<String>, message: impl Into<String>) -> Self {
130        Self {
131            step,
132            field: field.into(),
133            message: message.into(),
134        }
135    }
136}
137
138impl std::fmt::Display for QuickstartError {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        if self.field.is_empty() {
141            write!(f, "{:?}: {}", self.step, self.message)
142        } else {
143            write!(f, "{:?}.{}: {}", self.step, self.field, self.message)
144        }
145    }
146}
147
148pub fn validate_only(
149    submission: &BuilderSubmission,
150    config: &Config,
151) -> Result<(), Vec<QuickstartError>> {
152    validate_only_with_surface(submission, config, Surface::Web)
153}
154
155pub fn validate_only_with_surface(
156    submission: &BuilderSubmission,
157    config: &Config,
158    surface: Surface,
159) -> Result<(), Vec<QuickstartError>> {
160    let ctx = RunCtx::new(surface);
161    let mut staged = config.clone();
162    let mut errors = Vec::new();
163    // validate-only never commits; staged tempfiles drop at scope exit.
164    let mut staged_files = Vec::new();
165    apply_into(
166        &mut staged,
167        submission,
168        &mut staged_files,
169        &mut errors,
170        Some(&ctx),
171    );
172    let ok = errors.is_empty();
173    let attrs = merge_attrs(
174        ctx.base_attrs(),
175        serde_json::json!({"error_count": errors.len()}),
176    );
177    let outcome = if ok {
178        ::zeroclaw_log::EventOutcome::Success
179    } else {
180        ::zeroclaw_log::EventOutcome::Failure
181    };
182    if ok {
183        ::zeroclaw_log::record!(
184            INFO,
185            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Validate)
186                .with_outcome(outcome)
187                .with_attrs(attrs),
188            "quickstart: validate_only"
189        );
190    } else {
191        ::zeroclaw_log::record!(
192            WARN,
193            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Validate)
194                .with_outcome(outcome)
195                .with_attrs(attrs),
196            "quickstart: validate_only"
197        );
198    }
199    if ok { Ok(()) } else { Err(errors) }
200}
201
202pub async fn apply(
203    submission: BuilderSubmission,
204    config: &mut Config,
205) -> Result<AppliedAgent, Vec<QuickstartError>> {
206    apply_with_surface(submission, config, Surface::Web).await
207}
208
209pub async fn apply_with_surface(
210    submission: BuilderSubmission,
211    config: &mut Config,
212    surface: Surface,
213) -> Result<AppliedAgent, Vec<QuickstartError>> {
214    let ctx = RunCtx::new(surface);
215    let started = std::time::Instant::now();
216
217    ::zeroclaw_log::record!(
218        INFO,
219        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Start)
220            .with_attrs(ctx.base_attrs()),
221        "quickstart: apply"
222    );
223
224    let mut errors = Vec::new();
225    let mut staged_files = Vec::new();
226    let applied = apply_into(
227        config,
228        &submission,
229        &mut staged_files,
230        &mut errors,
231        Some(&ctx),
232    );
233    if !errors.is_empty() {
234        ::zeroclaw_log::record!(
235            WARN,
236            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
237                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
238                .with_attrs(merge_attrs(
239                    ctx.base_attrs(),
240                    serde_json::json!({
241                        "error_count": errors.len(),
242                        "elapsed_ms": started.elapsed().as_millis() as u64,
243                    }),
244                )),
245            "quickstart: apply rejected"
246        );
247        return Err(errors);
248    }
249    let applied = match applied {
250        Some(applied) => applied,
251        None => {
252            return Err(vec![QuickstartError::new(
253                QuickstartStep::Agent,
254                "apply",
255                "internal error: apply_into returned no result despite no validation errors",
256            )]);
257        }
258    };
259
260    config
261        .set_prop_persistent("onboard_state.quickstart_completed", "true")
262        .map_err(|err| {
263            vec![QuickstartError::new(
264                QuickstartStep::Agent,
265                "",
266                format!("failed to flip quickstart-completed: {err}"),
267            )]
268        })?;
269    ::zeroclaw_log::record!(
270        INFO,
271        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
272            merge_attrs(
273                ctx.base_attrs(),
274                serde_json::json!({"flag": "quickstart_completed"}),
275            )
276        ),
277        "quickstart: completion flag flipped"
278    );
279
280    let dirty_count = config.dirty_paths.len();
281    let write_started = std::time::Instant::now();
282    ::zeroclaw_log::record!(
283        DEBUG,
284        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Write).with_attrs(
285            merge_attrs(
286                ctx.base_attrs(),
287                serde_json::json!({"dirty_path_count": dirty_count}),
288            )
289        ),
290        "quickstart: persist start"
291    );
292    let write_result = config.save_dirty().await;
293    let write_ms = write_started.elapsed().as_millis() as u64;
294    match &write_result {
295        Ok(_) => ::zeroclaw_log::record!(
296            INFO,
297            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Write)
298                .with_outcome(::zeroclaw_log::EventOutcome::Success)
299                .with_attrs(merge_attrs(
300                    ctx.base_attrs(),
301                    serde_json::json!({
302                        "dirty_path_count": dirty_count,
303                        "elapsed_ms": write_ms,
304                    }),
305                )),
306            "quickstart: persist complete"
307        ),
308        Err(err) => ::zeroclaw_log::record!(
309            WARN,
310            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Write)
311                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
312                .with_attrs(merge_attrs(
313                    ctx.base_attrs(),
314                    serde_json::json!({
315                        "dirty_path_count": dirty_count,
316                        "elapsed_ms": write_ms,
317                        "error": err.to_string(),
318                    }),
319                )),
320            "quickstart: persist failed"
321        ),
322    }
323    write_result.map_err(|err| {
324        vec![QuickstartError::new(
325            QuickstartStep::Agent,
326            "",
327            format!("failed to persist config: {err}"),
328        )]
329    })?;
330
331    // Config landed atomically — now move the staged personality files
332    // into place. Any failure here is reported but does not unwind the
333    // already-persisted config; the agent is valid without them.
334    let mut commit_errors = Vec::new();
335    commit_personality_files(staged_files, &mut commit_errors);
336    if !commit_errors.is_empty() {
337        return Err(commit_errors);
338    }
339
340    ::zeroclaw_log::record!(
341        INFO,
342        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Complete)
343            .with_outcome(::zeroclaw_log::EventOutcome::Success)
344            .with_attrs(merge_attrs(
345                ctx.base_attrs(),
346                serde_json::json!({
347                    "agent": applied.alias,
348                    "channels": applied.channels.len(),
349                    "elapsed_ms": started.elapsed().as_millis() as u64,
350                }),
351            )),
352        "quickstart: apply complete"
353    );
354    Ok(applied)
355}
356
357/// Record a `dismissed` event for a run that exited without a
358/// Create. Surfaces call this when the user closes the Quickstart
359/// page / leaves the modal stack before submitting. `last_step` is
360/// optional and names whichever selector the user got furthest with;
361/// pass `None` for "didn't progress past the first selector."
362pub fn record_dismissed(run_id: &str, surface: Surface, last_step: Option<QuickstartStep>) {
363    let last_step_str = last_step
364        .map(|s| match s {
365            QuickstartStep::ModelProvider => "model_provider",
366            QuickstartStep::RiskProfile => "risk_profile",
367            QuickstartStep::RuntimeProfile => "runtime_profile",
368            QuickstartStep::Memory => "memory",
369            QuickstartStep::Channels => "channels",
370            QuickstartStep::PeerGroups => "peer_groups",
371            QuickstartStep::Agent => "agent",
372        })
373        .unwrap_or("none");
374    ::zeroclaw_log::record!(
375        INFO,
376        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
377            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
378            .with_attrs(::serde_json::json!({
379                "quickstart.run_id": run_id,
380                "quickstart.surface": surface.as_str(),
381                "last_step": last_step_str,
382                "dismissed": true,
383            })),
384        "quickstart: dismissed"
385    );
386}
387
388/// `onboard_state.quickstart_completed` is false **and** no
389/// `agents.*` entries exist. Returning users with existing agents
390/// never see the auto-trigger even if the flag was never flipped.
391pub fn should_auto_launch(config: &Config) -> bool {
392    !config.onboard_state.quickstart_completed && config.agents.is_empty()
393}
394
395/// Snapshot of the bits of `Config` the Quickstart UI needs to render
396/// each step's "Use existing" section without pulling the entire config.
397///
398/// Shared by every surface — the gateway's `GET /api/quickstart/state`
399/// and the RPC `quickstart/state` method both build the response from
400/// this one function, so the two transports cannot drift.
401#[derive(Debug, Clone, serde::Serialize)]
402#[serde(rename_all = "snake_case")]
403pub struct QuickstartState {
404    pub quickstart_completed: bool,
405    pub agents: Vec<String>,
406    pub risk_profiles: Vec<String>,
407    pub runtime_profiles: Vec<String>,
408    /// `<provider_type>.<alias>` refs for every configured model provider.
409    pub model_providers: Vec<String>,
410    /// `<channel_type>.<alias>` refs.
411    pub channels: Vec<String>,
412    /// Subset of `channels` that is not yet bound to any agent's
413    /// `agents.<alias>.channels` field. Surfaces use this for "Use
414    /// existing" pickers so they cannot let the user accidentally
415    /// reassign a channel that's still owned by another agent
416    /// (the schema invariant is one channel → one agent).
417    #[serde(default)]
418    pub unassigned_channels: Vec<String>,
419    /// `<storage_type>.<alias>` refs.
420    pub storage: Vec<String>,
421    /// Available model-provider types the Quickstart "Create new"
422    /// picker can offer. Derived at request time from the canonical
423    /// registry in `zeroclaw_providers::list_model_providers()` — the
424    /// same source the CLI catalog and gateway sections route use.
425    /// Surfaces render this list as-is; they do not maintain their own.
426    pub model_provider_types: Vec<QuickstartTypeOption>,
427    /// Available channel kinds the Quickstart "Create new" picker can
428    /// offer. Derived at request time from
429    /// [`zeroclaw_config::schema::ChannelsConfig::channels`] — the
430    /// schema-side single source of truth for "what channel kinds the
431    /// config schema knows about." Compile-time gating of channel
432    /// implementations (via `zeroclaw-channels` features) is enforced
433    /// later, at apply time; the picker shows every kind the schema
434    /// can represent so users get a consistent option list across
435    /// builds.
436    pub channel_types: Vec<QuickstartTypeOption>,
437    /// Risk presets from `zeroclaw_config::presets::RISK_PRESETS`.
438    pub risk_presets: &'static [zeroclaw_config::presets::RiskPreset],
439    /// Runtime presets from `zeroclaw_config::presets::RUNTIME_PRESETS`.
440    pub runtime_presets: &'static [zeroclaw_config::presets::RuntimePreset],
441    /// Memory backend snake-case kinds from `MemoryBackendKind`.
442    pub memory_kinds: Vec<String>,
443    /// Canonical personality filenames the Quickstart will accept.
444    /// Surfaces iterate this; never hardcode the filename list.
445    pub personality_files: &'static [&'static str],
446}
447
448/// One row in the Quickstart "Create new …" picker, sourced from a
449/// schema- or registry-level inventory so neither the TUI nor the web
450/// surface needs its own list. `kind` is the canonical kebab-case
451/// identifier written into config; `display_name` is the picker label.
452#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
453#[serde(rename_all = "snake_case")]
454pub struct QuickstartTypeOption {
455    /// Canonical identifier (e.g. `"anthropic"`, `"telegram"`).
456    pub kind: String,
457    /// Human-readable picker label (e.g. `"Anthropic"`, `"Telegram"`).
458    pub display_name: String,
459    /// `true` when the entry runs locally and needs no remote
460    /// credential. Channels always report `false`; providers reflect
461    /// their `local` flag from `ModelProviderInfo`.
462    pub local: bool,
463}
464
465/// Build a [`QuickstartState`] snapshot from the live config.
466///
467/// The two `*_types` lists are populated from the canonical sources
468/// (`zeroclaw_providers::list_model_providers()` for providers,
469/// `cfg.channels.channels()` for channel kinds). Adding a new entry in
470/// either source automatically lights up here — no Quickstart code
471/// change required. This is the DRY contract the plan calls out under
472/// "Reads the per-provider field map at render time so adding a
473/// provider in the schema doesn't require Quickstart code changes."
474pub fn snapshot_state(cfg: &Config) -> QuickstartState {
475    let model_provider_types = zeroclaw_providers::list_model_providers()
476        .into_iter()
477        .map(|info| QuickstartTypeOption {
478            kind: info.name.to_string(),
479            display_name: info.display_name.to_string(),
480            local: info.local,
481        })
482        .collect();
483    // Channel kinds come from the schema-side inventory. The
484    // serde-shaped `ChannelsConfig` is an object whose top-level
485    // keys are the kebab-case channel kinds (`telegram`, `discord`,
486    // `wecom-ws`, …). We walk that shape — same technique
487    // `collect_aliased_refs` uses below — so adding a new channel
488    // family in the schema lights up here for free. Display names
489    // are looked up from `ChannelsConfig::channels()` by index so we
490    // don't drift between the two views; if `channels()` returns
491    // fewer rows than the schema has top-level keys, the missing
492    // ones fall back to their kebab-case kind for display.
493    let channel_types = build_channel_type_options(&cfg.channels);
494    QuickstartState {
495        quickstart_completed: cfg.onboard_state.quickstart_completed,
496        agents: cfg.agents.keys().cloned().collect(),
497        risk_profiles: cfg.risk_profiles.keys().cloned().collect(),
498        runtime_profiles: cfg.runtime_profiles.keys().cloned().collect(),
499        model_providers: cfg
500            .providers
501            .models
502            .iter_entries()
503            .map(|(family, alias, _)| format!("{family}.{alias}"))
504            .collect(),
505        channels: collect_aliased_refs(&cfg.channels),
506        // Channel refs that are not yet bound to any agent. The
507        // schema enforces one-channel-one-agent; surfacing already-
508        // owned channels in a "Use existing" picker would silently
509        // break that invariant. Surfaces should always present this
510        // list (not the raw `channels` list) when offering reuse.
511        unassigned_channels: collect_aliased_refs(&cfg.channels)
512            .into_iter()
513            .filter(|ch| cfg.agent_for_channel(ch).is_none())
514            .collect(),
515        storage: collect_aliased_refs(&cfg.storage),
516        model_provider_types,
517        channel_types,
518        risk_presets: zeroclaw_config::presets::RISK_PRESETS,
519        runtime_presets: zeroclaw_config::presets::RUNTIME_PRESETS,
520        memory_kinds: memory_kind_keys(),
521        personality_files: crate::agent::personality::EDITABLE_PERSONALITY_FILES,
522    }
523}
524
525/// Snake-case wire keys for every `MemoryBackendKind` variant. Exhaustive
526/// match probe catches missing variants at compile time; serde produces
527/// the wire key so there's no parallel mapping.
528fn memory_kind_keys() -> Vec<String> {
529    use zeroclaw_config::multi_agent::MemoryBackendKind as M;
530    [
531        M::Sqlite,
532        M::Markdown,
533        M::Postgres,
534        M::Qdrant,
535        M::Lucid,
536        M::None,
537    ]
538    .into_iter()
539    .map(|k| {
540        // Exhaustiveness guard: adding a new variant forces this match to fail
541        // to compile until the contributor decides whether the new backend
542        // belongs in the quickstart picker.
543        match k {
544            M::Sqlite | M::Markdown | M::Postgres | M::Qdrant | M::Lucid | M::None => (),
545        }
546        serde_json::to_value(k)
547            .ok()
548            .and_then(|v| v.as_str().map(str::to_string))
549            .unwrap_or_default()
550    })
551    .collect()
552}
553
554/// Build the Quickstart channel-type picker rows directly from the
555/// schema's curated `ChannelsConfig::channels()` list. Each entry
556/// already carries its canonical kebab-case `kind` and human label,
557/// so the surface never re-derives them from serde introspection
558/// (which loses unconfigured channels because of
559/// `#[serde(skip_serializing_if = "HashMap::is_empty")]`).
560fn build_channel_type_options(
561    channels_cfg: &zeroclaw_config::schema::ChannelsConfig,
562) -> Vec<QuickstartTypeOption> {
563    channels_cfg
564        .channels()
565        .into_iter()
566        .map(|info| QuickstartTypeOption {
567            kind: info.kind.to_string(),
568            display_name: info.name.to_string(),
569            local: false,
570        })
571        .collect()
572}
573
574/// Walk the serialised form of `value` and yield `<type>.<alias>` refs
575/// for every `HashMap<String, _>`-shaped subsection. Schema-driven —
576/// adding a new channel or storage slot in the schema lights up here
577/// for free, no code change required.
578fn collect_aliased_refs<T: serde::Serialize>(value: &T) -> Vec<String> {
579    let mut out = Vec::new();
580    let Ok(serde_json::Value::Object(map)) = serde_json::to_value(value) else {
581        return out;
582    };
583    for (family, subvalue) in map {
584        if let serde_json::Value::Object(entries) = subvalue {
585            for alias in entries.keys() {
586                out.push(format!("{family}.{alias}"));
587            }
588        }
589    }
590    out.sort();
591    out
592}
593
594/// Selector kinds that the Quickstart "field shape" descriptor
595/// covers. The TUI / web ask the runtime for the shape, then render
596/// inputs dumbly off the response.
597#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
598#[serde(rename_all = "snake_case")]
599pub enum FieldSection {
600    ModelProvider,
601    Channel,
602    PeerGroup,
603}
604
605/// One renderable input the TUI / web modal must draw.
606///
607/// Shape is derived from `prop_fields()` filtered by the relevant
608/// schema prefix, then trimmed to the "greatest hits" required for
609/// Quickstart per [`field_shape`]. Surfaces never invent fields —
610/// adding a provider or channel kind to the schema lights up here
611/// automatically.
612#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
613#[serde(rename_all = "snake_case")]
614pub struct FieldDescriptor {
615    /// Schema-side field key (kebab-case terminal segment). The
616    /// caller submits this back through [`BuilderSubmission`].
617    pub key: String,
618    /// Human label shown next to the input.
619    pub label: String,
620    /// One-line help blurb. Empty when the schema field has no doc.
621    pub help: String,
622    /// Wire-tag for the input control to render. Mirrors
623    /// `PropKind::wire_name`.
624    pub kind: zeroclaw_config::traits::PropKind,
625    /// `true` for `#[secret]` fields — the modal masks input.
626    pub is_secret: bool,
627    /// Closed-set choices for `Enum` kind. `None` for everything else.
628    pub enum_variants: Option<Vec<String>>,
629    /// `true` when Quickstart treats this field as required. Currently
630    /// every field returned by [`field_shape`] is required, but the
631    /// flag is exposed so future additions can include optional rows.
632    pub required: bool,
633    /// Pre-filled default the modal should show as ghost text /
634    /// initial input value. `None` when the schema has no meaningful
635    /// default for this field (e.g. API keys, bot tokens).
636    pub default: Option<String>,
637}
638
639/// Return the renderable field shape for a single section + type
640/// combination. Walks `prop_fields()` against a synthetic config with
641/// one default-instantiated entry under the requested type, then
642/// filters to the per-section "essential" allowlist.
643pub fn field_shape(section: FieldSection, type_key: &str) -> Vec<FieldDescriptor> {
644    // Probe alias for the synthetic field-shape lookup. Must satisfy
645    // `validate_alias_key` (lowercase alphanumeric + underscore, can't
646    // start/end with `_`, no `__`) — otherwise `create_map_key` returns
647    // an alias-validation Err that the recurse arms in the Configurable
648    // derive mask as "no map-keyed/list section", and field_shape
649    // silently returns an empty Vec.
650    const SYNTHETIC_ALIAS: &str = "qs0probe";
651    let (section_path, essentials) = match section {
652        FieldSection::ModelProvider => (
653            format!("providers.models.{type_key}"),
654            MODEL_PROVIDER_ESSENTIALS,
655        ),
656        FieldSection::Channel => (format!("channels.{type_key}"), CHANNEL_ESSENTIALS),
657        FieldSection::PeerGroup => (format!("peer-groups.{type_key}"), PEER_GROUP_ESSENTIALS),
658    };
659
660    // A throwaway Config we can mutate freely. Inject one default
661    // entry under the requested type so `prop_fields()` enumerates
662    // its leaves.
663    let mut probe = Config::default();
664    if probe
665        .create_map_key(&section_path, SYNTHETIC_ALIAS)
666        .is_err()
667    {
668        return Vec::new();
669    }
670    let leaf_prefix = format!("{section_path}.{SYNTHETIC_ALIAS}.");
671
672    let mut out = Vec::new();
673    for info in probe.prop_fields() {
674        let Some(field_path) = info.name.strip_prefix(&leaf_prefix) else {
675            continue;
676        };
677        if !essentials.contains(&field_path) {
678            continue;
679        }
680        // `display_value` already masks secrets as `****`; we want
681        // ghost-text defaults for plain fields only. `<unset>` is the
682        // placeholder for an unset Option, not a real value — emitting
683        // it as a default makes every surface (CLI, TUI, web) echo it
684        // back into the submission, where the daemon then validates
685        // `<unset>` against the field's true type (e.g. a bool, which
686        // fails with "length 7"). Treat it like an empty default.
687        let default = if info.is_secret {
688            None
689        } else {
690            let raw = info.display_value.trim();
691            if raw.is_empty() || raw == zeroclaw_config::traits::UNSET_DISPLAY {
692                None
693            } else {
694                Some(raw.to_string())
695            }
696        };
697        out.push(FieldDescriptor {
698            key: field_path.to_string(),
699            label: kebab_to_snake(field_path),
700            help: info.description.trim().to_string(),
701            kind: info.kind,
702            is_secret: info.is_secret,
703            enum_variants: info.enum_variants.map(|f| f()),
704            // `uri` is an override-only field — operators set it only
705            // when pointing at a self-hosted gateway. `requires_openai_auth`
706            // and `wire_api` are OpenAI Codex subscription fields — optional
707            // for all providers, meaningful only for OpenAI. `api_key` is
708            // left non-required because local providers (Ollama) and Codex
709            // subscription auth don't need one — the runtime surfaces a
710            // clear error at request time if a remote provider is missing
711            // its key. Everything else in the essentials list is required
712            // to actually issue a request.
713            required: !matches!(
714                field_path,
715                "uri" | "api_key" | "requires_openai_auth" | "wire_api"
716            ),
717            default,
718        });
719    }
720    out.sort_by_key(|d| {
721        essentials
722            .iter()
723            .position(|k| *k == d.key.as_str())
724            .unwrap_or(usize::MAX)
725    });
726    out
727}
728
729/// Essentials per section kind. Kept in one place so adding a
730/// provider type or channel kind lights up Quickstart for free,
731/// while keeping the modal focused on what an agent cannot start
732/// without.
733const MODEL_PROVIDER_ESSENTIALS: &[&str] = &[
734    "model",
735    "api_key",
736    "uri",
737    "requires_openai_auth",
738    "wire_api",
739];
740const CHANNEL_ESSENTIALS: &[&str] = &["bot_token", "token", "webhook_url", "allowed_users"];
741const PEER_GROUP_ESSENTIALS: &[&str] = &["channel", "external_peers", "agents", "ignore"];
742
743/// Runtime profile the Quickstart silently installs. The Runtime Profile
744/// picker was removed from every surface; apply always writes this preset.
745const FORCED_RUNTIME_PRESET: &str = "unbounded";
746
747fn apply_into(
748    config: &mut Config,
749    submission: &BuilderSubmission,
750    staged_files: &mut Vec<StagedPersonalityWrite>,
751    errors: &mut Vec<QuickstartError>,
752    ctx: Option<&RunCtx>,
753) -> Option<AppliedAgent> {
754    let provider_ref = apply_model_provider(config, &submission.model_provider, errors)?;
755    emit_selector_pick(
756        ctx,
757        "model_provider",
758        selector_mode(&submission.model_provider),
759        &provider_ref,
760    );
761
762    let risk_alias = apply_named_preset(
763        config,
764        &submission.risk_profile,
765        QuickstartStep::RiskProfile,
766        risk_preset_keys,
767        write_risk_preset,
768        errors,
769    )?;
770    emit_selector_pick(
771        ctx,
772        "risk_profile",
773        selector_mode(&submission.risk_profile),
774        &risk_alias,
775    );
776
777    let runtime_alias = match write_runtime_preset(config, FORCED_RUNTIME_PRESET) {
778        Ok(alias) => alias,
779        Err(msg) => {
780            errors.push(QuickstartError::new(
781                QuickstartStep::RuntimeProfile,
782                "",
783                msg,
784            ));
785            return None;
786        }
787    };
788    emit_selector_pick(
789        ctx,
790        "runtime_profile",
791        selector_mode(&submission.runtime_profile),
792        &runtime_alias,
793    );
794
795    let memory_backend = apply_memory(config, &submission.memory, errors)?;
796    emit_selector_pick(
797        ctx,
798        "memory",
799        selector_mode(&submission.memory),
800        &memory_backend,
801    );
802
803    let channel_refs = apply_channels(config, &submission.channels, errors);
804    if let Some(ctx) = ctx {
805        ::zeroclaw_log::record!(
806            DEBUG,
807            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
808                merge_attrs(
809                    ctx.base_attrs(),
810                    serde_json::json!({
811                        "selector": "channels",
812                        "count": channel_refs.len(),
813                    }),
814                )
815            ),
816            "quickstart: selector channels"
817        );
818    }
819
820    if !errors.is_empty() {
821        return None;
822    }
823    let alias = apply_agent(
824        config,
825        &submission.agent,
826        &provider_ref,
827        &risk_alias,
828        &runtime_alias,
829        &channel_refs,
830        errors,
831    )?;
832    emit_selector_pick(ctx, "agent", "create_new", &alias);
833
834    let peer_group_refs = apply_peer_groups(config, &submission.peer_groups, &channel_refs, errors);
835    if let Some(ctx) = ctx {
836        ::zeroclaw_log::record!(
837            DEBUG,
838            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
839                merge_attrs(
840                    ctx.base_attrs(),
841                    serde_json::json!({
842                        "selector": "peer_groups",
843                        "count": peer_group_refs.len(),
844                    }),
845                )
846            ),
847            "quickstart: selector peer_groups"
848        );
849    }
850
851    apply_personality_files(
852        config,
853        &alias,
854        &submission.agent.personality_files,
855        staged_files,
856        errors,
857    );
858
859    materialize_default_skills_bundle(config);
860
861    if !errors.is_empty() {
862        return None;
863    }
864
865    Some(AppliedAgent {
866        alias,
867        model_provider: provider_ref,
868        risk_profile: risk_alias,
869        runtime_profile: runtime_alias,
870        channels: channel_refs,
871        memory_backend,
872    })
873}
874
875/// Surface representation of a selector's submission mode for
876/// observability. We never inspect the wrapped value here — only
877/// whether the user picked an existing alias or created fresh.
878fn selector_mode<T>(choice: &SelectorChoice<T>) -> &'static str {
879    match choice {
880        SelectorChoice::Existing(_) => "use_existing",
881        SelectorChoice::Fresh(_) => "create_new",
882    }
883}
884
885fn emit_selector_pick(ctx: Option<&RunCtx>, selector: &str, mode: &str, value: &str) {
886    let Some(ctx) = ctx else { return };
887    ::zeroclaw_log::record!(
888        DEBUG,
889        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
890            merge_attrs(
891                ctx.base_attrs(),
892                serde_json::json!({
893                    "selector": selector,
894                    "mode": mode,
895                    "value": value,
896                }),
897            )
898        ),
899        "quickstart: selector pick"
900    );
901}
902
903// ── Model provider ─────────────────────────────────────────────────
904
905fn apply_model_provider(
906    config: &mut Config,
907    choice: &SelectorChoice<ModelProviderChoice>,
908    errors: &mut Vec<QuickstartError>,
909) -> Option<String> {
910    match choice {
911        SelectorChoice::Existing(reference) => {
912            let (family, alias) = match split_ref(reference) {
913                Some(parts) => parts,
914                None => {
915                    errors.push(QuickstartError::new(
916                        QuickstartStep::ModelProvider,
917                        "",
918                        format!("`{reference}` is not a `<type>.<alias>` reference"),
919                    ));
920                    return None;
921                }
922            };
923            if !section_has_alias(config, "providers.models", family, alias) {
924                errors.push(QuickstartError::new(
925                    QuickstartStep::ModelProvider,
926                    "",
927                    format!("no `providers.models.{family}.{alias}` configured"),
928                ));
929                return None;
930            }
931            Some(reference.clone())
932        }
933        SelectorChoice::Fresh(choice) => {
934            if choice.provider_type.trim().is_empty()
935                || choice.alias.trim().is_empty()
936                || choice.model.trim().is_empty()
937            {
938                errors.push(QuickstartError::new(
939                    QuickstartStep::ModelProvider,
940                    "",
941                    "provider type, alias, and model are required",
942                ));
943                return None;
944            }
945            // Canonicalize the provider type against the registry. The picker
946            // offers canonical `info.name` keys, but a hand-typed or
947            // whitespace-padded value (e.g. "llamacpp ", "llama.cpp") would
948            // otherwise reach `create_map_key` verbatim and fail with a cryptic
949            // "no map-keyed/list section" because the family key doesn't match.
950            let provider_type = choice.provider_type.trim();
951            let provider_type = match zeroclaw_providers::list_model_providers()
952                .into_iter()
953                .find(|info| info.name.eq_ignore_ascii_case(provider_type))
954            {
955                Some(info) => info.name.to_string(),
956                None => {
957                    errors.push(QuickstartError::new(
958                        QuickstartStep::ModelProvider,
959                        "provider_type",
960                        format!(
961                            "unknown model provider type `{}` — pick one from the provider list",
962                            choice.provider_type.trim()
963                        ),
964                    ));
965                    return None;
966                }
967            };
968            if section_has_alias(config, "providers.models", &provider_type, &choice.alias) {
969                errors.push(QuickstartError::new(
970                    QuickstartStep::ModelProvider,
971                    "alias",
972                    format!("alias `{}.{}` already exists", provider_type, choice.alias),
973                ));
974                return None;
975            }
976            let prefix = format!("providers.models.{}.{}", provider_type, choice.alias);
977            if let Err(err) = config.create_map_key(
978                &format!("providers.models.{}", provider_type),
979                &choice.alias,
980            ) {
981                errors.push(QuickstartError::new(
982                    QuickstartStep::ModelProvider,
983                    "provider_type",
984                    err.to_string(),
985                ));
986                return None;
987            }
988            if let Err(err) = config.set_prop_persistent(&format!("{prefix}.model"), &choice.model)
989            {
990                errors.push(QuickstartError::new(
991                    QuickstartStep::ModelProvider,
992                    "model",
993                    err.to_string(),
994                ));
995                return None;
996            }
997            // Round-trip every field the surface echoed back. Keys are
998            // whatever `field_shape()` emitted — the daemon authored
999            // them, so it knows where they go.
1000            let mut entries: Vec<(&String, &String)> = choice.fields.iter().collect();
1001            entries.sort_by(|a, b| a.0.cmp(b.0));
1002            for (key, value) in entries {
1003                if value.is_empty() {
1004                    continue;
1005                }
1006                if let Err(err) = config.set_prop_persistent(&format!("{prefix}.{key}"), value) {
1007                    errors.push(QuickstartError::new(
1008                        QuickstartStep::ModelProvider,
1009                        zeroclaw_config::helpers::kebab_to_snake(key),
1010                        err.to_string(),
1011                    ));
1012                    return None;
1013                }
1014            }
1015            Some(format!("{}.{}", provider_type, choice.alias))
1016        }
1017    }
1018}
1019
1020// ── Risk / Runtime presets ─────────────────────────────────────────
1021
1022fn apply_named_preset<K, W>(
1023    config: &mut Config,
1024    choice: &SelectorChoice<String>,
1025    step: QuickstartStep,
1026    list_existing: K,
1027    write_preset: W,
1028    errors: &mut Vec<QuickstartError>,
1029) -> Option<String>
1030where
1031    K: Fn(&Config) -> Vec<String>,
1032    W: Fn(&mut Config, &str) -> Result<String, String>,
1033{
1034    match choice {
1035        SelectorChoice::Existing(alias) => {
1036            if list_existing(config).iter().any(|a| a == alias) {
1037                Some(alias.clone())
1038            } else {
1039                errors.push(QuickstartError::new(
1040                    step,
1041                    "",
1042                    format!("no `{alias}` profile configured"),
1043                ));
1044                None
1045            }
1046        }
1047        SelectorChoice::Fresh(preset_name) => match write_preset(config, preset_name) {
1048            Ok(alias) => Some(alias),
1049            Err(msg) => {
1050                errors.push(QuickstartError::new(step, "", msg));
1051                None
1052            }
1053        },
1054    }
1055}
1056
1057fn risk_preset_keys(config: &Config) -> Vec<String> {
1058    config.risk_profiles.keys().cloned().collect()
1059}
1060
1061fn write_risk_preset(config: &mut Config, preset_name: &str) -> Result<String, String> {
1062    let preset =
1063        risk_preset(preset_name).ok_or_else(|| format!("unknown risk preset `{preset_name}`"))?;
1064    // Existing block wins — never clobber a user-customised `[risk-profiles.<name>]`
1065    // that happens to share a preset name.
1066    if config.risk_profiles.contains_key(preset.preset_name) {
1067        return Ok(preset.preset_name.to_string());
1068    }
1069    config
1070        .create_map_key("risk_profiles", preset.preset_name)
1071        .map_err(|e| e.to_string())?;
1072    config
1073        .risk_profiles
1074        .insert(preset.preset_name.to_string(), (preset.values)());
1075    config.mark_dirty(&format!("risk_profiles.{}", preset.preset_name));
1076    Ok(preset.preset_name.to_string())
1077}
1078
1079fn write_runtime_preset(config: &mut Config, preset_name: &str) -> Result<String, String> {
1080    let preset = runtime_preset(preset_name)
1081        .ok_or_else(|| format!("unknown runtime preset `{preset_name}`"))?;
1082    // Existing block wins — same rule as `write_risk_preset`.
1083    if config.runtime_profiles.contains_key(preset.preset_name) {
1084        return Ok(preset.preset_name.to_string());
1085    }
1086    config
1087        .create_map_key("runtime_profiles", preset.preset_name)
1088        .map_err(|e| e.to_string())?;
1089    config
1090        .runtime_profiles
1091        .insert(preset.preset_name.to_string(), (preset.values)());
1092    config.mark_dirty(&format!("runtime_profiles.{}", preset.preset_name));
1093    Ok(preset.preset_name.to_string())
1094}
1095
1096// ── Memory ─────────────────────────────────────────────────────────
1097
1098fn apply_memory(
1099    config: &mut Config,
1100    choice: &SelectorChoice<MemoryChoice>,
1101    errors: &mut Vec<QuickstartError>,
1102) -> Option<String> {
1103    match choice {
1104        SelectorChoice::Existing(reference) => {
1105            let (family, alias) = match split_ref(reference) {
1106                Some(parts) => parts,
1107                None => {
1108                    errors.push(QuickstartError::new(
1109                        QuickstartStep::Memory,
1110                        "",
1111                        format!("`{reference}` is not a `<type>.<alias>` reference"),
1112                    ));
1113                    return None;
1114                }
1115            };
1116            if !section_has_alias(config, "storage", family, alias) {
1117                errors.push(QuickstartError::new(
1118                    QuickstartStep::Memory,
1119                    "",
1120                    format!("no `storage.{family}.{alias}` configured"),
1121                ));
1122                return None;
1123            }
1124            if let Err(err) = config.set_prop_persistent("memory.backend", reference) {
1125                errors.push(QuickstartError::new(
1126                    QuickstartStep::Memory,
1127                    "backend",
1128                    err.to_string(),
1129                ));
1130                return None;
1131            }
1132            Some(reference.clone())
1133        }
1134        SelectorChoice::Fresh(kind) => {
1135            // The schema's `MemoryBackendKind::serialize` rename
1136            // (`#[serde(rename_all = "snake_case")]`) gives us the
1137            // canonical TOML kebab-case spelling without any
1138            // surface-side mapping table. `None` writes `"none"`,
1139            // every other backend creates a `[storage.<kind>.<kind>]`
1140            // table and points `memory.backend` at it.
1141            let kind_name = serde_json::to_value(kind)
1142                .ok()
1143                .and_then(|v| v.as_str().map(str::to_string))
1144                .unwrap_or_else(|| format!("{kind:?}").to_lowercase());
1145            if matches!(kind, MemoryChoice::None) {
1146                if let Err(err) = config.set_prop_persistent("memory.backend", "none") {
1147                    errors.push(QuickstartError::new(
1148                        QuickstartStep::Memory,
1149                        "backend",
1150                        err.to_string(),
1151                    ));
1152                    return None;
1153                }
1154                return Some("none".to_string());
1155            }
1156            let backend_ref = format!("{kind_name}.{kind_name}");
1157            let parent_path = format!("storage.{kind_name}");
1158            if let Err(err) = config.create_map_key(&parent_path, &kind_name) {
1159                errors.push(QuickstartError::new(
1160                    QuickstartStep::Memory,
1161                    "",
1162                    err.to_string(),
1163                ));
1164                return None;
1165            }
1166            if let Err(err) = config.set_prop_persistent("memory.backend", &backend_ref) {
1167                errors.push(QuickstartError::new(
1168                    QuickstartStep::Memory,
1169                    "backend",
1170                    err.to_string(),
1171                ));
1172                return None;
1173            }
1174            Some(backend_ref)
1175        }
1176    }
1177}
1178
1179// ── Channels ───────────────────────────────────────────────────────
1180
1181fn apply_channels(
1182    config: &mut Config,
1183    channels: &[SelectorChoice<ChannelQuickStart>],
1184    errors: &mut Vec<QuickstartError>,
1185) -> Vec<String> {
1186    let mut refs = Vec::with_capacity(channels.len());
1187    for (idx, ch) in channels.iter().enumerate() {
1188        match ch {
1189            SelectorChoice::Existing(reference) => {
1190                if let Some((family, alias)) = split_ref(reference) {
1191                    if !channel_exists(config, family, alias) {
1192                        errors.push(QuickstartError::new(
1193                            QuickstartStep::Channels,
1194                            format!("channels[{idx}]"),
1195                            format!("no `channels.{family}.{alias}` configured"),
1196                        ));
1197                        continue;
1198                    }
1199                    // Existing channel already bound to a different agent
1200                    // cannot be re-used — one channel, one agent invariant.
1201                    if let Some(owner) = config.agent_for_channel(reference) {
1202                        errors.push(QuickstartError::new(
1203                            QuickstartStep::Channels,
1204                            format!("channels[{idx}]"),
1205                            format!("channel `{reference}` is already bound to agent `{owner}`"),
1206                        ));
1207                        continue;
1208                    }
1209                    refs.push(reference.clone());
1210                } else {
1211                    errors.push(QuickstartError::new(
1212                        QuickstartStep::Channels,
1213                        format!("channels[{idx}]"),
1214                        format!("`{reference}` is not a `<type>.<alias>` reference"),
1215                    ));
1216                }
1217            }
1218            SelectorChoice::Fresh(entry) => {
1219                if entry.channel_type.trim().is_empty() || entry.alias.trim().is_empty() {
1220                    errors.push(QuickstartError::new(
1221                        QuickstartStep::Channels,
1222                        format!("channels[{idx}]"),
1223                        "channel type and alias are required",
1224                    ));
1225                    continue;
1226                }
1227                if channel_exists(config, &entry.channel_type, &entry.alias) {
1228                    errors.push(QuickstartError::new(
1229                        QuickstartStep::Channels,
1230                        format!("channels[{idx}].alias"),
1231                        format!(
1232                            "alias `{}.{}` already exists",
1233                            entry.channel_type, entry.alias
1234                        ),
1235                    ));
1236                    continue;
1237                }
1238                if let Err(err) =
1239                    config.create_map_key(&format!("channels.{}", entry.channel_type), &entry.alias)
1240                {
1241                    errors.push(QuickstartError::new(
1242                        QuickstartStep::Channels,
1243                        format!("channels[{idx}].channel_type"),
1244                        err.to_string(),
1245                    ));
1246                    continue;
1247                }
1248                let token_path =
1249                    format!("channels.{}.{}.bot_token", entry.channel_type, entry.alias);
1250                if let Some(tok) = &entry.token {
1251                    if let Err(err) = config.set_prop_persistent(&token_path, tok) {
1252                        errors.push(QuickstartError::new(
1253                            QuickstartStep::Channels,
1254                            format!("channels[{idx}].token"),
1255                            err.to_string(),
1256                        ));
1257                        continue;
1258                    }
1259                } else {
1260                    // No creds — still need to materialize the entry so the agent
1261                    // record can reference it. Set `enabled = true` as the minimum
1262                    // schema-recognised field; channels without creds will fail
1263                    // their own bootstrap loudly, which is the desired behaviour.
1264                    let enabled_path =
1265                        format!("channels.{}.{}.enabled", entry.channel_type, entry.alias);
1266                    if let Err(err) = config.set_prop_persistent(&enabled_path, "true") {
1267                        errors.push(QuickstartError::new(
1268                            QuickstartStep::Channels,
1269                            format!("channels[{idx}]"),
1270                            err.to_string(),
1271                        ));
1272                        continue;
1273                    }
1274                }
1275                refs.push(format!("{}.{}", entry.channel_type, entry.alias));
1276            }
1277        }
1278    }
1279    refs
1280}
1281
1282fn channel_exists(config: &Config, channel_type: &str, alias: &str) -> bool {
1283    let probe = format!("channels.{channel_type}.{alias}.enabled");
1284    config.get_prop(&probe).is_ok()
1285}
1286
1287// ── Peer groups ────────────────────────────────────────────────────
1288
1289fn apply_peer_groups(
1290    config: &mut Config,
1291    peer_groups: &[zeroclaw_config::presets::QuickstartPeerGroup],
1292    staged_channel_refs: &[String],
1293    errors: &mut Vec<QuickstartError>,
1294) -> Vec<String> {
1295    let mut refs = Vec::with_capacity(peer_groups.len());
1296    for (idx, pg) in peer_groups.iter().enumerate() {
1297        if pg.name.trim().is_empty() {
1298            errors.push(QuickstartError::new(
1299                QuickstartStep::Channels,
1300                format!("peer_groups[{idx}].name"),
1301                "peer-group name is required",
1302            ));
1303            continue;
1304        }
1305        if pg.channel.trim().is_empty() {
1306            errors.push(QuickstartError::new(
1307                QuickstartStep::Channels,
1308                format!("peer_groups[{idx}].channel"),
1309                "peer-group channel ref is required",
1310            ));
1311            continue;
1312        }
1313        // Channel ref must resolve to either a channel already in config
1314        // OR a channel staged in this same submission.
1315        let staged_match = staged_channel_refs.iter().any(|r| r == &pg.channel);
1316        let configured_match = match split_ref(&pg.channel) {
1317            Some((family, alias)) => channel_exists(config, family, alias),
1318            None => false,
1319        };
1320        if !staged_match && !configured_match {
1321            errors.push(QuickstartError::new(
1322                QuickstartStep::Channels,
1323                format!("peer_groups[{idx}].channel"),
1324                format!(
1325                    "peer-group `{}` references unknown channel `{}`",
1326                    pg.name, pg.channel
1327                ),
1328            ));
1329            continue;
1330        }
1331        // Collision: existing peer-group block wins. Surface the conflict
1332        // so the operator sees what they need to rename.
1333        if config.peer_groups.contains_key(&pg.name) {
1334            errors.push(QuickstartError::new(
1335                QuickstartStep::Channels,
1336                format!("peer_groups[{idx}].name"),
1337                format!("peer-group `{}` already exists", pg.name),
1338            ));
1339            continue;
1340        }
1341        if let Err(err) = config.create_map_key("peer-groups", &pg.name) {
1342            errors.push(QuickstartError::new(
1343                QuickstartStep::Channels,
1344                format!("peer_groups[{idx}]"),
1345                err.to_string(),
1346            ));
1347            continue;
1348        }
1349        let prefix = format!("peer-groups.{}", pg.name);
1350        if let Err(err) = config.set_prop_persistent(&format!("{prefix}.channel"), &pg.channel) {
1351            errors.push(QuickstartError::new(
1352                QuickstartStep::Channels,
1353                format!("peer_groups[{idx}].channel"),
1354                err.to_string(),
1355            ));
1356            continue;
1357        }
1358        if !pg.external_peers.is_empty() {
1359            let joined = pg
1360                .external_peers
1361                .iter()
1362                .map(|s| s.as_str())
1363                .collect::<Vec<_>>()
1364                .join("\n");
1365            if let Err(err) =
1366                config.set_prop_persistent(&format!("{prefix}.external_peers"), &joined)
1367            {
1368                errors.push(QuickstartError::new(
1369                    QuickstartStep::Channels,
1370                    format!("peer_groups[{idx}].external_peers"),
1371                    err.to_string(),
1372                ));
1373                continue;
1374            }
1375        }
1376        if !pg.ignore.is_empty() {
1377            let joined = pg
1378                .ignore
1379                .iter()
1380                .map(|s| s.as_str())
1381                .collect::<Vec<_>>()
1382                .join("\n");
1383            if let Err(err) = config.set_prop_persistent(&format!("{prefix}.ignore"), &joined) {
1384                errors.push(QuickstartError::new(
1385                    QuickstartStep::Channels,
1386                    format!("peer_groups[{idx}].ignore"),
1387                    err.to_string(),
1388                ));
1389                continue;
1390            }
1391        }
1392        refs.push(pg.name.clone());
1393    }
1394    refs
1395}
1396
1397// ── Personality files ──────────────────────────────────────────────
1398
1399/// A personality file staged to a tempfile during `apply_into`, moved
1400/// into place only after the atomic config write succeeds. On config
1401/// failure the tempfile drops and cleans itself up — nothing orphaned.
1402struct StagedPersonalityWrite {
1403    tempfile: tempfile::NamedTempFile,
1404    dest: std::path::PathBuf,
1405}
1406
1407fn apply_personality_files(
1408    config: &Config,
1409    agent_alias: &str,
1410    files: &[zeroclaw_config::presets::QuickstartPersonalityFile],
1411    staged: &mut Vec<StagedPersonalityWrite>,
1412    errors: &mut Vec<QuickstartError>,
1413) {
1414    if files.is_empty() {
1415        return;
1416    }
1417    let workspace = config.agent_workspace_dir(agent_alias);
1418    if let Err(err) = std::fs::create_dir_all(&workspace) {
1419        errors.push(QuickstartError::new(
1420            QuickstartStep::Agent,
1421            "personality_files",
1422            format!("could not create agent workspace: {err}"),
1423        ));
1424        return;
1425    }
1426    for (idx, file) in files.iter().enumerate() {
1427        let trimmed = file.filename.trim();
1428        if trimmed.is_empty() {
1429            errors.push(QuickstartError::new(
1430                QuickstartStep::Agent,
1431                format!("personality_files[{idx}].filename"),
1432                "filename is required",
1433            ));
1434            continue;
1435        }
1436        if !crate::agent::personality::EDITABLE_PERSONALITY_FILES.contains(&trimmed) {
1437            errors.push(QuickstartError::new(
1438                QuickstartStep::Agent,
1439                format!("personality_files[{idx}].filename"),
1440                format!("`{trimmed}` is not an editable personality file"),
1441            ));
1442            continue;
1443        }
1444        if file.content.chars().count() > crate::agent::personality::MAX_FILE_CHARS {
1445            errors.push(QuickstartError::new(
1446                QuickstartStep::Agent,
1447                format!("personality_files[{idx}].content"),
1448                format!(
1449                    "content exceeds {} char limit",
1450                    crate::agent::personality::MAX_FILE_CHARS
1451                ),
1452            ));
1453            continue;
1454        }
1455        // Stage to a tempfile in the destination directory rather than
1456        // writing the final path now. The commit happens after the atomic
1457        // config persist in `apply_with_surface`.
1458        let mut tempfile = match tempfile::NamedTempFile::new_in(&workspace) {
1459            Ok(t) => t,
1460            Err(err) => {
1461                errors.push(QuickstartError::new(
1462                    QuickstartStep::Agent,
1463                    format!("personality_files[{idx}]"),
1464                    format!("stage {trimmed} failed: {err}"),
1465                ));
1466                continue;
1467            }
1468        };
1469        if let Err(err) = std::io::Write::write_all(&mut tempfile, file.content.as_bytes()) {
1470            errors.push(QuickstartError::new(
1471                QuickstartStep::Agent,
1472                format!("personality_files[{idx}]"),
1473                format!("stage {trimmed} failed: {err}"),
1474            ));
1475            continue;
1476        }
1477        staged.push(StagedPersonalityWrite {
1478            tempfile,
1479            dest: workspace.join(trimmed),
1480        });
1481    }
1482}
1483
1484/// Move every staged tempfile into place. Called only after the atomic
1485/// config write succeeds; a failure here is reported but the agent is
1486/// already persisted and valid.
1487fn commit_personality_files(
1488    staged: Vec<StagedPersonalityWrite>,
1489    errors: &mut Vec<QuickstartError>,
1490) {
1491    for write in staged {
1492        if let Err(err) = write.tempfile.persist(&write.dest) {
1493            errors.push(QuickstartError::new(
1494                QuickstartStep::Agent,
1495                "personality_files",
1496                format!("write {} failed: {}", write.dest.display(), err.error),
1497            ));
1498        }
1499    }
1500}
1501
1502// ── Default skills bundle FTUE ─────────────────────────────────────
1503
1504fn materialize_default_skills_bundle(config: &mut Config) {
1505    if !config.skill_bundles.is_empty() {
1506        return;
1507    }
1508    // create_map_key returns Ok(false) on existing key (idempotent),
1509    // Ok(true) on insertion. We don't propagate the error: the FTUE
1510    // bundle is best-effort and the operator can configure one later.
1511    let _ = config.create_map_key("skill-bundles", "default");
1512}
1513
1514// ── Agent ──────────────────────────────────────────────────────────
1515
1516fn apply_agent(
1517    config: &mut Config,
1518    identity: &AgentIdentity,
1519    provider_ref: &str,
1520    risk_alias: &str,
1521    runtime_alias: &str,
1522    channel_refs: &[String],
1523    errors: &mut Vec<QuickstartError>,
1524) -> Option<String> {
1525    if identity.name.trim().is_empty() {
1526        errors.push(QuickstartError::new(
1527            QuickstartStep::Agent,
1528            "name",
1529            "agent name is required",
1530        ));
1531        return None;
1532    }
1533    if config.agents.contains_key(&identity.name) {
1534        errors.push(QuickstartError::new(
1535            QuickstartStep::Agent,
1536            "name",
1537            format!("agent `{}` already exists", identity.name),
1538        ));
1539        return None;
1540    }
1541
1542    let prefix = format!("agents.{}", identity.name);
1543    if let Err(err) = config.create_map_key("agents", &identity.name) {
1544        errors.push(QuickstartError::new(
1545            QuickstartStep::Agent,
1546            "name",
1547            err.to_string(),
1548        ));
1549        return None;
1550    }
1551    let writes: [(&str, &str); 3] = [
1552        ("model_provider", provider_ref),
1553        ("risk_profile", risk_alias),
1554        ("runtime_profile", runtime_alias),
1555    ];
1556    for (field, value) in writes {
1557        let path = format!("{prefix}.{field}");
1558        if let Err(err) = config.set_prop_persistent(&path, value) {
1559            errors.push(QuickstartError::new(
1560                QuickstartStep::Agent,
1561                field,
1562                err.to_string(),
1563            ));
1564            return None;
1565        }
1566    }
1567    if !channel_refs.is_empty() {
1568        let path = format!("{prefix}.channels");
1569        let json = serde_json::to_string(channel_refs).unwrap_or_else(|_| "[]".to_string());
1570        if let Err(err) = config.set_prop_persistent(&path, &json) {
1571            errors.push(QuickstartError::new(
1572                QuickstartStep::Agent,
1573                "channels",
1574                err.to_string(),
1575            ));
1576            return None;
1577        }
1578    }
1579    Some(identity.name.clone())
1580}
1581
1582// ── Shared helpers ─────────────────────────────────────────────────
1583
1584fn split_ref(reference: &str) -> Option<(&str, &str)> {
1585    let (ty, alias) = reference.split_once('.')?;
1586    if ty.is_empty() || alias.is_empty() {
1587        None
1588    } else {
1589        Some((ty, alias))
1590    }
1591}
1592
1593/// Probe whether `<prefix>.<family>.<alias>` resolves to a populated
1594/// entry. Uses the schema's own `get_prop` dispatch — no per-family
1595/// list. We probe a path the entry's own struct must have if it
1596/// exists (`enabled` or `model`); the schema bubbles an error for
1597/// unknown families which we treat as "not present".
1598fn section_has_alias(config: &Config, prefix: &str, family: &str, alias: &str) -> bool {
1599    for probe_field in ["enabled", "model", "uri"] {
1600        let probe = format!("{prefix}.{family}.{alias}.{probe_field}");
1601        if config.get_prop(&probe).is_ok() {
1602            return true;
1603        }
1604    }
1605    false
1606}
1607
1608/// Live model catalog for a provider type. `(models, pricing, live)`:
1609/// `live=true` means surfaces should render a picker; `live=false`
1610/// means fall back to free text. Tries `ModelProvider::list_models_with_pricing()`
1611/// first, then the family catalog table (no pricing for fallbacks).
1612pub async fn model_catalog(
1613    model_provider: &str,
1614) -> (
1615    Vec<String>,
1616    Option<std::collections::HashMap<String, zeroclaw_api::model_provider::ModelPricing>>,
1617    bool,
1618) {
1619    if let Ok(handle) = zeroclaw_providers::create_model_provider(model_provider, None)
1620        && let Ok(models) = handle.list_models_with_pricing().await
1621        && !models.is_empty()
1622    {
1623        let pricing: std::collections::HashMap<String, zeroclaw_api::model_provider::ModelPricing> =
1624            models
1625                .iter()
1626                .filter_map(|m| m.pricing.as_ref().map(|p| (m.id.clone(), p.clone())))
1627                .collect();
1628        let ids: Vec<String> = models.into_iter().map(|m| m.id).collect();
1629        let pricing = if pricing.is_empty() {
1630            None
1631        } else {
1632            Some(pricing)
1633        };
1634        return (ids, pricing, true);
1635    }
1636    match zeroclaw_providers::catalog::list_models_for_family(model_provider).await {
1637        Ok(models) if !models.is_empty() => (models, None, true),
1638        _ => (Vec::new(), None, false),
1639    }
1640}
1641
1642/// `true` for model_provider families that need no remote credential.
1643#[must_use]
1644pub fn model_provider_is_local(model_provider: &str) -> bool {
1645    zeroclaw_providers::list_model_providers()
1646        .iter()
1647        .find(|p| p.name == model_provider)
1648        .is_some_and(|p| p.local)
1649}
1650
1651#[cfg(test)]
1652mod tests {
1653    use super::*;
1654    use zeroclaw_config::presets::{
1655        AgentIdentity, BuilderSubmission, ChannelQuickStart, MemoryChoice, ModelProviderChoice,
1656        SelectorChoice,
1657    };
1658    use zeroclaw_config::schema::Config;
1659
1660    /// Regression: every channel kind the schema enumerates in
1661    /// `ChannelsConfig::channels()` must appear in the Quickstart
1662    /// `channel_types` picker. The previous implementation walked the
1663    /// serialized form of `ChannelsConfig`, which hid every empty
1664    /// channel HashMap because of
1665    /// `#[serde(skip_serializing_if = "HashMap::is_empty")]` — that
1666    /// silently truncated the picker to whatever channels happened
1667    /// to have a configured alias on the live config (~9 instead of
1668    /// 32). Drive the picker from the schema's curated list so the
1669    /// picker matches what the schema knows about.
1670    #[test]
1671    fn channel_type_options_cover_every_schema_channel() {
1672        let cfg = Config::default();
1673        let picker = build_channel_type_options(&cfg.channels);
1674        let schema = cfg.channels.channels();
1675        assert_eq!(
1676            picker.len(),
1677            schema.len(),
1678            "Quickstart channel-type picker count diverged from \
1679             ChannelsConfig::channels(); picker has {} rows, schema has {}",
1680            picker.len(),
1681            schema.len(),
1682        );
1683        for (picked, expected) in picker.iter().zip(schema.iter()) {
1684            assert_eq!(
1685                picked.kind, expected.kind,
1686                "kind mismatch at {} — picker `{}`, schema `{}`",
1687                picked.display_name, picked.kind, expected.kind,
1688            );
1689            assert_eq!(
1690                picked.display_name, expected.name,
1691                "display_name mismatch at `{}` — picker `{}`, schema `{}`",
1692                picked.kind, picked.display_name, expected.name,
1693            );
1694        }
1695    }
1696
1697    fn fresh_submission(agent_name: &str) -> BuilderSubmission {
1698        BuilderSubmission {
1699            model_provider: SelectorChoice::Fresh(ModelProviderChoice {
1700                provider_type: "anthropic".into(),
1701                alias: "anthropic".into(),
1702                model: "claude-sonnet-4-5".into(),
1703                fields: std::collections::HashMap::from([(
1704                    "api_key".to_string(),
1705                    "sk-test".to_string(),
1706                )]),
1707            }),
1708            risk_profile: SelectorChoice::Fresh("balanced".into()),
1709            runtime_profile: SelectorChoice::Fresh("balanced".into()),
1710            memory: SelectorChoice::Fresh(MemoryChoice::Sqlite),
1711            channels: vec![],
1712            peer_groups: vec![],
1713            agent: AgentIdentity {
1714                name: agent_name.into(),
1715                system_prompt: "You are helpful.".into(),
1716                personality_file: None,
1717                personality_files: vec![],
1718            },
1719        }
1720    }
1721
1722    #[test]
1723    fn apply_serializes_provider_fields_as_snake_case() {
1724        let mut cfg = Config::default();
1725        let submission = fresh_submission("bot");
1726        let mut staged = Vec::new();
1727        let mut errors = Vec::new();
1728        let applied = apply_into(&mut cfg, &submission, &mut staged, &mut errors, None);
1729        assert!(errors.is_empty(), "apply_into errors: {errors:?}");
1730        assert!(applied.is_some(), "apply_into should yield an agent");
1731        // The submission carries the snake field key `api_key` and it must
1732        // land on disk as the snake serde field `api_key`, never kebab.
1733        let toml = toml::to_string(&cfg).expect("serialize config");
1734        assert!(
1735            toml.contains("api_key"),
1736            "expected snake `api_key` in serialized config:\n{toml}"
1737        );
1738        assert!(
1739            !toml.contains("api-key"),
1740            "kebab `api-key` leaked into serialized config:\n{toml}"
1741        );
1742    }
1743
1744    #[test]
1745    fn apply_provider_type_trims_and_canonicalizes_whitespace() {
1746        // A provider type with stray whitespace must canonicalize to the
1747        // registry's family key, not reach create_map_key verbatim (which would
1748        // fail with "no map-keyed/list section at providers.models.llamacpp ").
1749        let mut cfg = Config::default();
1750        let mut submission = fresh_submission("bot");
1751        submission.model_provider = SelectorChoice::Fresh(ModelProviderChoice {
1752            provider_type: "  llamacpp  ".into(),
1753            alias: "local".into(),
1754            model: "qwen2.5-coder".into(),
1755            fields: std::collections::HashMap::new(),
1756        });
1757        let mut staged = Vec::new();
1758        let mut errors = Vec::new();
1759        let applied = apply_into(&mut cfg, &submission, &mut staged, &mut errors, None);
1760        assert!(errors.is_empty(), "apply_into errors: {errors:?}");
1761        assert!(applied.is_some());
1762        assert!(
1763            cfg.providers.models.find("llamacpp", "local").is_some(),
1764            "expected providers.models.llamacpp.local to exist"
1765        );
1766        let agent = cfg.agents.get("bot").expect("agent created");
1767        assert_eq!(agent.model_provider.as_str(), "llamacpp.local");
1768    }
1769
1770    #[test]
1771    fn apply_provider_type_case_insensitive() {
1772        let mut cfg = Config::default();
1773        let mut submission = fresh_submission("bot");
1774        submission.model_provider = SelectorChoice::Fresh(ModelProviderChoice {
1775            provider_type: "Anthropic".into(),
1776            alias: "main".into(),
1777            model: "claude-sonnet-4-5".into(),
1778            fields: std::collections::HashMap::new(),
1779        });
1780        let mut staged = Vec::new();
1781        let mut errors = Vec::new();
1782        let applied = apply_into(&mut cfg, &submission, &mut staged, &mut errors, None);
1783        assert!(errors.is_empty(), "apply_into errors: {errors:?}");
1784        assert!(applied.is_some());
1785        assert!(cfg.providers.models.find("anthropic", "main").is_some());
1786    }
1787
1788    #[test]
1789    fn apply_unknown_provider_type_errors_clearly() {
1790        let mut cfg = Config::default();
1791        let mut submission = fresh_submission("bot");
1792        submission.model_provider = SelectorChoice::Fresh(ModelProviderChoice {
1793            provider_type: "not_a_real_provider".into(),
1794            alias: "x".into(),
1795            model: "m".into(),
1796            fields: std::collections::HashMap::new(),
1797        });
1798        let mut staged = Vec::new();
1799        let mut errors = Vec::new();
1800        let applied = apply_into(&mut cfg, &submission, &mut staged, &mut errors, None);
1801        assert!(applied.is_none());
1802        assert!(
1803            errors
1804                .iter()
1805                .any(|e| e.step == QuickstartStep::ModelProvider
1806                    && e.message.contains("unknown model provider type")),
1807            "expected a clear unknown-provider error, got: {errors:?}"
1808        );
1809    }
1810
1811    #[test]
1812    fn validate_only_passes_on_fresh_submission() {
1813        let cfg = Config::default();
1814        let submission = fresh_submission("bot");
1815        validate_only(&submission, &cfg).expect("fresh submission validates");
1816    }
1817
1818    #[test]
1819    fn validate_only_rejects_blank_agent_name() {
1820        let cfg = Config::default();
1821        let submission = fresh_submission("");
1822        let errors = validate_only(&submission, &cfg).unwrap_err();
1823        assert!(
1824            errors
1825                .iter()
1826                .any(|e| e.step == QuickstartStep::Agent && e.field == "name")
1827        );
1828    }
1829
1830    #[test]
1831    fn validate_only_rejects_existing_agent_name() {
1832        let mut cfg = Config::default();
1833        cfg.agents.insert(
1834            "bot".into(),
1835            zeroclaw_config::schema::AliasedAgentConfig::default(),
1836        );
1837        let submission = fresh_submission("bot");
1838        let errors = validate_only(&submission, &cfg).unwrap_err();
1839        assert!(errors.iter().any(|e| e.step == QuickstartStep::Agent));
1840    }
1841
1842    #[test]
1843    fn validate_only_rejects_unknown_risk_preset() {
1844        let cfg = Config::default();
1845        let mut submission = fresh_submission("bot");
1846        submission.risk_profile = SelectorChoice::Fresh("does-not-exist".into());
1847        let errors = validate_only(&submission, &cfg).unwrap_err();
1848        assert!(errors.iter().any(|e| e.step == QuickstartStep::RiskProfile));
1849    }
1850
1851    #[test]
1852    fn validate_only_accepts_every_builtin_risk_preset() {
1853        let cfg = Config::default();
1854        for p in zeroclaw_config::presets::RISK_PRESETS {
1855            let mut submission = fresh_submission("bot");
1856            submission.risk_profile = SelectorChoice::Fresh(p.preset_name.into());
1857            validate_only(&submission, &cfg).unwrap_or_else(|e| {
1858                panic!("risk preset `{}` failed validate: {e:?}", p.preset_name)
1859            });
1860        }
1861    }
1862
1863    /// Regression for the silent empty-form bug: `field_shape(ModelProvider,
1864    /// <type>)` must return at least the model + api-key rows for every
1865    /// known model provider type. Before fix, the synthetic probe alias
1866    /// failed `validate_alias_key`, the recurse arms in the Configurable
1867    /// derive masked it as "no map-keyed/list section", and field_shape
1868    /// silently returned an empty Vec — leaving the TUI form with zero
1869    /// editable rows and the CLI wizard dumped to a manual `Model id for X:`
1870    /// fallback.
1871    #[test]
1872    fn field_shape_returns_model_provider_rows_for_canonical_types() {
1873        for kind in ["anthropic", "openai", "ollama", "openrouter", "groq"] {
1874            let rows = super::field_shape(super::FieldSection::ModelProvider, kind);
1875            let keys: Vec<&str> = rows.iter().map(|r| r.key.as_str()).collect();
1876            assert!(
1877                keys.contains(&"model"),
1878                "field_shape for `{kind}` is missing `model` row; got {keys:?}",
1879            );
1880            assert!(
1881                keys.contains(&"api_key"),
1882                "field_shape for `{kind}` is missing `api_key` row; got {keys:?}",
1883            );
1884        }
1885    }
1886
1887    /// Codex subscription auth: `field_shape(ModelProvider, "openai")` must
1888    /// include the `requires_openai_auth` and `wire_api` rows so the
1889    /// Quickstart form can offer Codex subscription auth (no API key needed).
1890    /// These fields are non-required — they default to `false`/empty and are
1891    /// harmless for non-OpenAI providers.
1892    #[test]
1893    fn field_shape_openai_includes_codex_auth_fields() {
1894        let rows = super::field_shape(super::FieldSection::ModelProvider, "openai");
1895        let keys: Vec<&str> = rows.iter().map(|r| r.key.as_str()).collect();
1896        assert!(
1897            keys.contains(&"requires_openai_auth"),
1898            "field_shape for openai must include `requires_openai_auth` for Codex subscription; got {keys:?}",
1899        );
1900        assert!(
1901            keys.contains(&"wire_api"),
1902            "field_shape for openai must include `wire_api` for Codex subscription; got {keys:?}",
1903        );
1904        // Both must be non-required so Quickstart doesn't block on them.
1905        for row in &rows {
1906            if row.key == "requires_openai_auth" || row.key == "wire_api" {
1907                assert!(
1908                    !row.required,
1909                    "`{}` must be non-required in the Quickstart form",
1910                    row.key
1911                );
1912            }
1913        }
1914        // No row may carry the `<unset>` placeholder as its default.
1915        // It's a display sentinel for an unset Option; echoing it back
1916        // through any surface (CLI/TUI/web) makes the daemon validate
1917        // `<unset>` against the field's real type and reject it.
1918        for row in &rows {
1919            assert_ne!(
1920                row.default.as_deref(),
1921                Some(zeroclaw_config::traits::UNSET_DISPLAY),
1922                "`{}` must not default to the <unset> placeholder",
1923                row.key
1924            );
1925        }
1926    }
1927
1928    /// `api_key` must be non-required in the Quickstart form so Codex
1929    /// subscription (no API key) and local providers (Ollama) can proceed
1930    /// without one.
1931    #[test]
1932    fn field_shape_api_key_is_not_required() {
1933        for kind in ["openai", "ollama"] {
1934            let rows = super::field_shape(super::FieldSection::ModelProvider, kind);
1935            let api_key_row = rows.iter().find(|r| r.key == "api_key");
1936            assert!(
1937                api_key_row.is_some(),
1938                "field_shape for `{kind}` must include `api_key`",
1939            );
1940            assert!(
1941                !api_key_row.unwrap().required,
1942                "`api_key` must be non-required for `{kind}` (Codex subscription / local providers don't need one)",
1943            );
1944        }
1945    }
1946
1947    async fn apply_to_temp(submission: BuilderSubmission) -> (tempfile::TempDir, Config) {
1948        let dir = tempfile::tempdir().unwrap();
1949        let config = Config {
1950            config_path: dir.path().join("config.toml"),
1951            data_dir: dir.path().join("data"),
1952            ..Default::default()
1953        };
1954        config.save().await.unwrap();
1955        let mut config = config;
1956        super::apply(submission, &mut config)
1957            .await
1958            .expect("apply should succeed");
1959        (dir, config)
1960    }
1961
1962    fn reload(dir: &tempfile::TempDir) -> Config {
1963        let raw = std::fs::read_to_string(dir.path().join("config.toml")).unwrap();
1964        toml::from_str(&raw).expect("on-disk config must round-trip")
1965    }
1966
1967    #[tokio::test]
1968    async fn fresh_preset_profiles_persist_to_disk() {
1969        // The runtime profile picker was removed; apply silently forces the
1970        // `unbounded` preset regardless of the submitted runtime value.
1971        let (dir, applied) = apply_to_temp(fresh_submission("bot")).await;
1972        assert!(applied.risk_profiles.contains_key("balanced"));
1973        assert!(applied.runtime_profiles.contains_key("unbounded"));
1974        let reloaded = reload(&dir);
1975        assert!(
1976            reloaded.risk_profiles.contains_key("balanced"),
1977            "risk_profiles.balanced must survive save_dirty + reload, not dangle"
1978        );
1979        assert!(
1980            reloaded.runtime_profiles.contains_key("unbounded"),
1981            "runtime_profiles.unbounded must survive save_dirty + reload, not dangle"
1982        );
1983        let agent = reloaded.agents.get("bot").expect("agent persisted");
1984        assert_eq!(agent.risk_profile, "balanced");
1985        assert_eq!(agent.runtime_profile, "unbounded");
1986    }
1987
1988    #[tokio::test]
1989    async fn multiple_channels_all_bind_to_agent() {
1990        let mut submission = fresh_submission("bot");
1991        submission.channels = vec![
1992            SelectorChoice::Fresh(ChannelQuickStart {
1993                channel_type: "telegram".into(),
1994                alias: "tg".into(),
1995                token: Some("tok-a".into()),
1996            }),
1997            SelectorChoice::Fresh(ChannelQuickStart {
1998                channel_type: "discord".into(),
1999                alias: "dc".into(),
2000                token: Some("tok-b".into()),
2001            }),
2002        ];
2003        let (dir, _applied) = apply_to_temp(submission).await;
2004        let reloaded = reload(&dir);
2005        let agent = reloaded.agents.get("bot").expect("agent persisted");
2006        let bound: Vec<String> = agent.channels.iter().map(|c| c.to_string()).collect();
2007        assert!(
2008            bound.iter().any(|c| c.contains("tg")),
2009            "first channel must stay bound; got {bound:?}"
2010        );
2011        assert!(
2012            bound.iter().any(|c| c.contains("dc")),
2013            "second channel must also be bound; got {bound:?}"
2014        );
2015        assert_eq!(bound.len(), 2, "both channels bound, not just the last");
2016    }
2017}