Skip to main content

harn_cli/commands/
persona.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3use std::process;
4use std::sync::Arc;
5
6use harn_vm::event_log::{AnyEventLog, EventLog};
7
8use crate::cli::{
9    PersonaCheckArgs, PersonaControlArgs, PersonaInspectArgs, PersonaListArgs, PersonaSpendArgs,
10    PersonaStatusArgs, PersonaTickArgs, PersonaTriggerArgs,
11};
12use crate::package::{self, PersonaManifestEntry, PersonaValidationError, ResolvedPersonaManifest};
13
14/// In-process variant of `harn persona list --json` used by the binary's
15/// dispatcher and by integration tests that want to assert on the
16/// structured payload without spawning a subprocess.
17pub fn list_payload(manifest: Option<&Path>) -> Result<Vec<serde_json::Value>, String> {
18    let catalog = load_catalog_result(manifest)?;
19    Ok(catalog
20        .personas
21        .iter()
22        .map(|persona| persona_to_json(persona, &catalog))
23        .collect())
24}
25
26pub(crate) fn run_list(manifest: Option<&Path>, args: &PersonaListArgs) {
27    if args.json {
28        let personas = list_payload(manifest).unwrap_or_else(|error| fatal(&error));
29        println!(
30            "{}",
31            serde_json::to_string_pretty(&personas)
32                .unwrap_or_else(|error| fatal(&format!("failed to serialize personas: {error}")))
33        );
34        return;
35    }
36
37    let catalog = load_catalog_or_exit(manifest);
38    if catalog.personas.is_empty() {
39        println!(
40            "No personas declared in {}.",
41            catalog.manifest_path.display()
42        );
43        return;
44    }
45
46    println!("Personas in {}:", catalog.manifest_path.display());
47    let name_width = catalog
48        .personas
49        .iter()
50        .filter_map(|persona| persona.name.as_ref())
51        .map(String::len)
52        .max()
53        .unwrap_or(4);
54    for persona in &catalog.personas {
55        let name = persona.name.as_deref().unwrap_or("<unnamed>");
56        let tier = persona
57            .autonomy_tier
58            .map(|tier| tier.as_str())
59            .unwrap_or("<missing>");
60        let receipts = persona
61            .receipt_policy
62            .map(|policy| policy.as_str())
63            .unwrap_or("<missing>");
64        let entry = persona.entry_workflow.as_deref().unwrap_or("<missing>");
65        println!(
66            "  {name:<name_width$}  tier={tier:<17} receipts={receipts:<8} entry={entry}",
67            name_width = name_width
68        );
69    }
70}
71
72/// In-process variant of `harn persona check --json`. Returns the JSON
73/// payload the CLI would print on success; structured validation errors
74/// surface in `Err` so callers can format or assert on them.
75pub fn check_payload(
76    path: Option<&Path>,
77) -> Result<serde_json::Value, Vec<PersonaValidationError>> {
78    let catalog = load_catalog_validation(path)?;
79    Ok(serde_json::json!({
80        "ok": true,
81        "manifest_path": catalog.manifest_path,
82        "personas": catalog.personas.iter().map(|persona| {
83            serde_json::json!({
84                "name": persona.name.as_deref().unwrap_or_default(),
85                "triggers": &persona.triggers,
86                "tools": &persona.tools,
87                "autonomy": persona.autonomy_tier.map(|tier| tier.as_str()).unwrap_or_default(),
88                "receipts": persona.receipt_policy.map(|policy| policy.as_str()).unwrap_or_default(),
89            })
90        }).collect::<Vec<_>>(),
91    }))
92}
93
94pub(crate) fn run_check(manifest: Option<&Path>, args: &PersonaCheckArgs) {
95    let selected = args.path.as_deref().or(manifest);
96    if args.json {
97        match check_payload(selected) {
98            Ok(payload) => println!(
99                "{}",
100                serde_json::to_string_pretty(&payload).unwrap_or_else(|error| fatal(&format!(
101                    "failed to serialize persona check output: {error}"
102                )))
103            ),
104            Err(errors) => {
105                print_validation_errors_json(&errors);
106                process::exit(1);
107            }
108        }
109        return;
110    }
111    let catalog = match load_catalog_validation(selected) {
112        Ok(catalog) => catalog,
113        Err(errors) => fatal(
114            &errors
115                .iter()
116                .map(ToString::to_string)
117                .collect::<Vec<_>>()
118                .join("\n"),
119        ),
120    };
121    println!(
122        "ok: {} persona manifest validates ({} personas)",
123        catalog.manifest_path.display(),
124        catalog.personas.len()
125    );
126}
127
128/// In-process variant of `harn persona inspect <name> --json`.
129pub fn inspect_payload(manifest: Option<&Path>, name: &str) -> Result<serde_json::Value, String> {
130    let catalog = load_catalog_result(manifest)?;
131    let persona = catalog
132        .personas
133        .iter()
134        .find(|persona| persona.name.as_deref() == Some(name))
135        .ok_or_else(|| {
136            format!(
137                "persona '{}' not found in {}",
138                name,
139                catalog.manifest_path.display()
140            )
141        })?;
142    Ok(persona_to_json(persona, &catalog))
143}
144
145pub(crate) fn run_inspect(manifest: Option<&Path>, args: &PersonaInspectArgs) {
146    if args.json {
147        let json = inspect_payload(manifest, &args.name).unwrap_or_else(|error| fatal(&error));
148        println!(
149            "{}",
150            serde_json::to_string_pretty(&json)
151                .unwrap_or_else(|error| fatal(&format!("failed to serialize persona: {error}")))
152        );
153        return;
154    }
155
156    let catalog = load_catalog_or_exit(manifest);
157    let Some(persona) = catalog
158        .personas
159        .iter()
160        .find(|persona| persona.name.as_deref() == Some(args.name.as_str()))
161    else {
162        fatal(&format!(
163            "persona '{}' not found in {}",
164            args.name,
165            catalog.manifest_path.display()
166        ));
167    };
168
169    println!(
170        "name:           {}",
171        persona.name.as_deref().unwrap_or_default()
172    );
173    if let Some(version) = &persona.version {
174        println!("version:        {version}");
175    }
176    println!(
177        "description:    {}",
178        persona.description.as_deref().unwrap_or_default()
179    );
180    println!(
181        "entry_workflow: {}",
182        persona.entry_workflow.as_deref().unwrap_or_default()
183    );
184    println!("tools:          {}", comma_or_dash(&persona.tools));
185    println!("capabilities:   {}", comma_or_dash(&persona.capabilities));
186    println!(
187        "autonomy_tier:  {}",
188        persona
189            .autonomy_tier
190            .map(|tier| tier.as_str())
191            .unwrap_or_default()
192    );
193    println!(
194        "receipt_policy: {}",
195        persona
196            .receipt_policy
197            .map(|policy| policy.as_str())
198            .unwrap_or_default()
199    );
200    println!("triggers:       {}", comma_or_dash(&persona.triggers));
201    println!("schedules:      {}", comma_or_dash(&persona.schedules));
202    println!("handoffs:       {}", comma_or_dash(&persona.handoffs));
203    println!("context_packs:  {}", comma_or_dash(&persona.context_packs));
204    println!("evals:          {}", comma_or_dash(&persona.evals));
205    if !persona.steps.is_empty() {
206        println!("steps:");
207        for step in &persona.steps {
208            let mut detail = format!("  - {} ({})", step.name, step.function);
209            if let Some(model) = step.model.as_deref() {
210                detail.push_str(&format!(" model={model}"));
211            }
212            if let Some(budget) = step.budget.as_ref() {
213                if let Some(max_tokens) = budget.max_tokens {
214                    detail.push_str(&format!(" max_tokens={max_tokens}"));
215                }
216                if let Some(max_usd) = budget.max_usd {
217                    detail.push_str(&format!(" max_usd={max_usd}"));
218                }
219            }
220            if let Some(boundary) = step.error_boundary.as_deref() {
221                detail.push_str(&format!(" error_boundary={boundary}"));
222            }
223            println!("{detail}");
224        }
225    }
226    if let Some(owner) = &persona.owner {
227        println!("owner:          {owner}");
228    }
229    println!("manifest:       {}", catalog.manifest_path.display());
230}
231
232/// In-process variant of `harn persona status`.
233pub async fn status_payload(
234    manifest: Option<&Path>,
235    state_dir: &Path,
236    name: &str,
237    at: Option<&str>,
238) -> Result<harn_vm::PersonaStatus, String> {
239    let catalog = load_catalog_result(manifest)?;
240    let binding = runtime_binding_or_err(&catalog, name)?;
241    let log = open_persona_log(state_dir)?;
242    let now_ms = timestamp_arg(at)?;
243    harn_vm::persona_status(&log, &binding, now_ms).await
244}
245
246pub(crate) async fn run_status(
247    manifest: Option<&Path>,
248    state_dir: &Path,
249    args: &PersonaStatusArgs,
250) -> Result<(), String> {
251    let status = status_payload(manifest, state_dir, &args.name, args.at.as_deref()).await?;
252    print_status(&status, args.json);
253    Ok(())
254}
255
256/// In-process variant of `harn persona pause`.
257pub async fn pause_payload(
258    manifest: Option<&Path>,
259    state_dir: &Path,
260    name: &str,
261    at: Option<&str>,
262) -> Result<harn_vm::PersonaStatus, String> {
263    let catalog = load_catalog_result(manifest)?;
264    let binding = runtime_binding_or_err(&catalog, name)?;
265    let log = open_persona_log(state_dir)?;
266    let now_ms = timestamp_arg(at)?;
267    harn_vm::pause_persona(&log, &binding, now_ms).await
268}
269
270pub(crate) async fn run_pause(
271    manifest: Option<&Path>,
272    state_dir: &Path,
273    args: &PersonaControlArgs,
274) -> Result<(), String> {
275    let status = pause_payload(manifest, state_dir, &args.name, args.at.as_deref()).await?;
276    print_status(&status, args.json);
277    Ok(())
278}
279
280/// In-process variant of `harn persona resume`.
281pub async fn resume_payload(
282    manifest: Option<&Path>,
283    state_dir: &Path,
284    name: &str,
285    at: Option<&str>,
286) -> Result<harn_vm::PersonaStatus, String> {
287    let catalog = load_catalog_result(manifest)?;
288    let binding = runtime_binding_or_err(&catalog, name)?;
289    let log = open_persona_log(state_dir)?;
290    let now_ms = timestamp_arg(at)?;
291    harn_vm::resume_persona(&log, &binding, now_ms).await
292}
293
294pub(crate) async fn run_resume(
295    manifest: Option<&Path>,
296    state_dir: &Path,
297    args: &PersonaControlArgs,
298) -> Result<(), String> {
299    let status = resume_payload(manifest, state_dir, &args.name, args.at.as_deref()).await?;
300    print_status(&status, args.json);
301    Ok(())
302}
303
304/// In-process variant of `harn persona disable`.
305pub async fn disable_payload(
306    manifest: Option<&Path>,
307    state_dir: &Path,
308    name: &str,
309    at: Option<&str>,
310) -> Result<harn_vm::PersonaStatus, String> {
311    let catalog = load_catalog_result(manifest)?;
312    let binding = runtime_binding_or_err(&catalog, name)?;
313    let log = open_persona_log(state_dir)?;
314    let now_ms = timestamp_arg(at)?;
315    harn_vm::disable_persona(&log, &binding, now_ms).await
316}
317
318pub(crate) async fn run_disable(
319    manifest: Option<&Path>,
320    state_dir: &Path,
321    args: &PersonaControlArgs,
322) -> Result<(), String> {
323    let status = disable_payload(manifest, state_dir, &args.name, args.at.as_deref()).await?;
324    print_status(&status, args.json);
325    Ok(())
326}
327
328/// In-process variant of `harn persona tick`. Returns the run receipt that
329/// the CLI would otherwise print to stdout.
330pub async fn tick_payload(
331    manifest: Option<&Path>,
332    state_dir: &Path,
333    name: &str,
334    at: Option<&str>,
335    cost_usd: f64,
336    tokens: u64,
337) -> Result<harn_vm::PersonaRunReceipt, String> {
338    let catalog = load_catalog_result(manifest)?;
339    let binding = runtime_binding_or_err(&catalog, name)?;
340    let log = open_persona_log(state_dir)?;
341    let now_ms = timestamp_arg(at)?;
342    let receipt = harn_vm::fire_persona_schedule(
343        &log,
344        &binding,
345        harn_vm::PersonaRunCost {
346            cost_usd,
347            tokens,
348            ..Default::default()
349        },
350        now_ms,
351    )
352    .await?;
353    log.flush().await.map_err(|error| error.to_string())?;
354    Ok(receipt)
355}
356
357pub(crate) async fn run_tick(
358    manifest: Option<&Path>,
359    state_dir: &Path,
360    args: &PersonaTickArgs,
361) -> Result<(), String> {
362    let receipt = tick_payload(
363        manifest,
364        state_dir,
365        &args.name,
366        args.at.as_deref(),
367        args.cost_usd,
368        args.tokens,
369    )
370    .await?;
371    print_receipt(&receipt, args.json);
372    Ok(())
373}
374
375/// In-process variant of `harn persona trigger`. `metadata_pairs` accepts
376/// the same `KEY=VALUE` strings the CLI does.
377#[allow(clippy::too_many_arguments)]
378pub async fn trigger_payload(
379    manifest: Option<&Path>,
380    state_dir: &Path,
381    name: &str,
382    provider: &str,
383    kind: &str,
384    metadata_pairs: &[String],
385    at: Option<&str>,
386    cost_usd: f64,
387    tokens: u64,
388) -> Result<harn_vm::PersonaRunReceipt, String> {
389    let catalog = load_catalog_result(manifest)?;
390    let binding = runtime_binding_or_err(&catalog, name)?;
391    let log = open_persona_log(state_dir)?;
392    let now_ms = timestamp_arg(at)?;
393    let metadata = parse_metadata(metadata_pairs)?;
394    let receipt = harn_vm::fire_persona_trigger(
395        &log,
396        &binding,
397        provider,
398        kind,
399        metadata,
400        harn_vm::PersonaRunCost {
401            cost_usd,
402            tokens,
403            ..Default::default()
404        },
405        now_ms,
406    )
407    .await?;
408    log.flush().await.map_err(|error| error.to_string())?;
409    Ok(receipt)
410}
411
412pub(crate) async fn run_trigger(
413    manifest: Option<&Path>,
414    state_dir: &Path,
415    args: &PersonaTriggerArgs,
416) -> Result<(), String> {
417    let receipt = trigger_payload(
418        manifest,
419        state_dir,
420        &args.name,
421        &args.provider,
422        &args.kind,
423        &args.metadata,
424        args.at.as_deref(),
425        args.cost_usd,
426        args.tokens,
427    )
428    .await?;
429    print_receipt(&receipt, args.json);
430    Ok(())
431}
432
433/// In-process variant of `harn persona spend`.
434pub async fn spend_payload(
435    manifest: Option<&Path>,
436    state_dir: &Path,
437    name: &str,
438    at: Option<&str>,
439    cost_usd: f64,
440    tokens: u64,
441) -> Result<harn_vm::PersonaBudgetStatus, String> {
442    let catalog = load_catalog_result(manifest)?;
443    let binding = runtime_binding_or_err(&catalog, name)?;
444    let log = open_persona_log(state_dir)?;
445    let now_ms = timestamp_arg(at)?;
446    let budget = harn_vm::record_persona_spend(
447        &log,
448        &binding,
449        harn_vm::PersonaRunCost {
450            cost_usd,
451            tokens,
452            ..Default::default()
453        },
454        now_ms,
455    )
456    .await?;
457    log.flush().await.map_err(|error| error.to_string())?;
458    Ok(budget)
459}
460
461pub(crate) async fn run_spend(
462    manifest: Option<&Path>,
463    state_dir: &Path,
464    args: &PersonaSpendArgs,
465) -> Result<(), String> {
466    let budget = spend_payload(
467        manifest,
468        state_dir,
469        &args.name,
470        args.at.as_deref(),
471        args.cost_usd,
472        args.tokens,
473    )
474    .await?;
475    if args.json {
476        println!(
477            "{}",
478            serde_json::to_string_pretty(&budget)
479                .unwrap_or_else(|error| fatal(&format!("failed to serialize budget: {error}")))
480        );
481    } else {
482        println!(
483            "budget: spent_today=${:.4} tokens_today={} exhausted={}",
484            budget.spent_today_usd, budget.tokens_today, budget.exhausted
485        );
486    }
487    Ok(())
488}
489
490fn load_catalog_or_exit(manifest: Option<&Path>) -> ResolvedPersonaManifest {
491    match load_catalog_result(manifest) {
492        Ok(catalog) => catalog,
493        Err(message) => fatal(&message),
494    }
495}
496
497fn load_catalog_result(manifest: Option<&Path>) -> Result<ResolvedPersonaManifest, String> {
498    load_catalog_validation(manifest).map_err(|errors| {
499        errors
500            .iter()
501            .map(ToString::to_string)
502            .collect::<Vec<_>>()
503            .join("\n")
504    })
505}
506
507fn load_catalog_validation(
508    manifest: Option<&Path>,
509) -> Result<ResolvedPersonaManifest, Vec<PersonaValidationError>> {
510    let result = if let Some(path) = manifest {
511        package::load_personas_from_manifest_path(path).map(Some)
512    } else {
513        package::load_personas_config(None)
514    };
515    match result {
516        Ok(Some(catalog)) => Ok(catalog),
517        Ok(None) => Err(vec![PersonaValidationError {
518            manifest_path: PathBuf::from("harn.toml"),
519            field_path: "harn.toml".to_string(),
520            message: "no harn.toml found; pass --manifest <path> or run inside a Harn project"
521                .to_string(),
522        }]),
523        Err(errors) => Err(errors),
524    }
525}
526
527fn runtime_binding_or_err(
528    catalog: &ResolvedPersonaManifest,
529    name: &str,
530) -> Result<harn_vm::PersonaRuntimeBinding, String> {
531    let persona = catalog
532        .personas
533        .iter()
534        .find(|persona| persona.name.as_deref() == Some(name))
535        .ok_or_else(|| {
536            format!(
537                "persona '{}' not found in {}",
538                name,
539                catalog.manifest_path.display()
540            )
541        })?;
542    Ok(harn_vm::PersonaRuntimeBinding {
543        name: persona.name.clone().unwrap_or_default(),
544        template_ref: persona_template_ref(persona),
545        entry_workflow: persona.entry_workflow.clone().unwrap_or_default(),
546        schedules: persona.schedules.clone(),
547        triggers: persona.triggers.clone(),
548        budget: harn_vm::PersonaBudgetPolicy {
549            daily_usd: persona.budget.daily_usd,
550            hourly_usd: persona.budget.hourly_usd,
551            run_usd: persona.budget.run_usd,
552            max_tokens: persona.budget.max_tokens,
553        },
554    })
555}
556
557fn persona_template_ref(persona: &PersonaManifestEntry) -> Option<String> {
558    persona
559        .package_source
560        .package
561        .as_ref()
562        .zip(persona.version.as_ref())
563        .map(|(package, version)| format!("{package}@{version}"))
564        .or_else(|| persona.package_source.package.clone())
565        .or_else(|| {
566            persona
567                .name
568                .as_ref()
569                .zip(persona.version.as_ref())
570                .map(|(name, version)| format!("{name}@{version}"))
571        })
572}
573
574fn open_persona_log(state_dir: &Path) -> Result<Arc<AnyEventLog>, String> {
575    let state_dir = absolutize_from_cwd(state_dir)?;
576    std::fs::create_dir_all(&state_dir).map_err(|error| {
577        format!(
578            "failed to create persona state dir {}: {error}",
579            state_dir.display()
580        )
581    })?;
582    harn_vm::event_log::install_default_for_base_dir(&state_dir)
583        .map_err(|error| format!("failed to open persona event log: {error}"))
584}
585
586fn absolutize_from_cwd(path: &Path) -> Result<PathBuf, String> {
587    if path.is_absolute() {
588        return Ok(path.to_path_buf());
589    }
590    std::env::current_dir()
591        .map(|cwd| cwd.join(path))
592        .map_err(|error| format!("failed to read current directory: {error}"))
593}
594
595fn timestamp_arg(value: Option<&str>) -> Result<i64, String> {
596    match value {
597        Some(value) => harn_vm::parse_persona_ms(value),
598        None => Ok(harn_vm::persona_now_ms()),
599    }
600}
601
602fn parse_metadata(values: &[String]) -> Result<BTreeMap<String, String>, String> {
603    let mut metadata = BTreeMap::new();
604    for value in values {
605        let Some((key, raw)) = value.split_once('=') else {
606            return Err(format!("metadata '{value}' must use KEY=VALUE syntax"));
607        };
608        let key = key.trim();
609        if key.is_empty() {
610            return Err(format!("metadata '{value}' has an empty key"));
611        }
612        metadata.insert(key.to_string(), raw.to_string());
613    }
614    Ok(metadata)
615}
616
617fn print_status(status: &harn_vm::PersonaStatus, json: bool) {
618    if json {
619        println!(
620            "{}",
621            serde_json::to_string_pretty(status)
622                .unwrap_or_else(|error| fatal(&format!("failed to serialize status: {error}")))
623        );
624        return;
625    }
626    println!("persona:        {}", status.name);
627    println!("state:          {}", status.state.as_str());
628    println!("entry_workflow: {}", status.entry_workflow);
629    println!(
630        "last_run:       {}",
631        status.last_run.as_deref().unwrap_or("-")
632    );
633    println!(
634        "next_run:       {}",
635        status.next_scheduled_run.as_deref().unwrap_or("-")
636    );
637    println!("queued_events:  {}", status.queued_events);
638    println!(
639        "active_lease:   {}",
640        status
641            .active_lease
642            .as_ref()
643            .map(|lease| lease.id.as_str())
644            .unwrap_or("-")
645    );
646    println!(
647        "budget:         spent_today=${:.4} remaining_today={}",
648        status.budget.spent_today_usd,
649        status
650            .budget
651            .remaining_today_usd
652            .map(|value| format!("${value:.4}"))
653            .unwrap_or_else(|| "-".to_string())
654    );
655    if let Some(error) = &status.last_error {
656        println!("last_error:     {error}");
657    }
658}
659
660fn print_receipt(receipt: &harn_vm::PersonaRunReceipt, json: bool) {
661    if json {
662        println!(
663            "{}",
664            serde_json::to_string_pretty(receipt)
665                .unwrap_or_else(|error| fatal(&format!("failed to serialize receipt: {error}")))
666        );
667    } else {
668        println!(
669            "persona={} status={} work_key={} queued={}",
670            receipt.persona, receipt.status, receipt.work_key, receipt.queued
671        );
672        if let Some(error) = &receipt.error {
673            println!("error={error}");
674        }
675    }
676}
677
678fn print_validation_errors_json(errors: &[PersonaValidationError]) {
679    let payload = serde_json::json!({
680        "ok": false,
681        "errors": errors.iter().map(|error| {
682            serde_json::json!({
683                "manifest_path": &error.manifest_path,
684                "field_path": &error.field_path,
685                "message": &error.message,
686            })
687        }).collect::<Vec<_>>(),
688    });
689    println!(
690        "{}",
691        serde_json::to_string_pretty(&payload).unwrap_or_else(|error| {
692            fatal(&format!(
693                "failed to serialize persona validation errors: {error}"
694            ))
695        })
696    );
697}
698
699fn persona_to_json(
700    persona: &PersonaManifestEntry,
701    catalog: &ResolvedPersonaManifest,
702) -> serde_json::Value {
703    serde_json::json!({
704        "name": persona.name.as_deref().unwrap_or_default(),
705        "version": persona.version.as_deref(),
706        "description": persona.description.as_deref().unwrap_or_default(),
707        "entry_workflow": persona.entry_workflow.as_deref().unwrap_or_default(),
708        "tools": &persona.tools,
709        "capabilities": &persona.capabilities,
710        "autonomy_tier": persona.autonomy_tier.map(|tier| tier.as_str()).unwrap_or_default(),
711        "receipt_policy": persona.receipt_policy.map(|policy| policy.as_str()).unwrap_or_default(),
712        "triggers": &persona.triggers,
713        "schedules": &persona.schedules,
714        "model_policy": {
715            "default_model": persona.model_policy.default_model.as_deref(),
716            "escalation_model": persona.model_policy.escalation_model.as_deref(),
717            "fallback_models": &persona.model_policy.fallback_models,
718            "reasoning_effort": persona.model_policy.reasoning_effort.as_deref(),
719        },
720        "budget": {
721            "daily_usd": persona.budget.daily_usd,
722            "hourly_usd": persona.budget.hourly_usd,
723            "run_usd": persona.budget.run_usd,
724            "frontier_escalations": persona.budget.frontier_escalations,
725            "max_tokens": persona.budget.max_tokens,
726            "max_runtime_seconds": persona.budget.max_runtime_seconds,
727        },
728        "handoffs": &persona.handoffs,
729        "context_packs": &persona.context_packs,
730        "evals": &persona.evals,
731        "steps": &persona.steps,
732        "owner": persona.owner.as_deref(),
733        "package_source": {
734            "package": persona.package_source.package.as_deref(),
735            "path": persona.package_source.path.as_deref(),
736            "git": persona.package_source.git.as_deref(),
737            "rev": persona.package_source.rev.as_deref(),
738        },
739        "rollout_policy": {
740            "mode": persona.rollout_policy.mode.as_deref(),
741            "percentage": persona.rollout_policy.percentage,
742            "cohorts": &persona.rollout_policy.cohorts,
743        },
744        "source": {
745            "manifest_path": &catalog.manifest_path,
746            "manifest_dir": &catalog.manifest_dir,
747        },
748    })
749}
750
751fn comma_or_dash(values: &[String]) -> String {
752    if values.is_empty() {
753        "-".to_string()
754    } else {
755        values.join(", ")
756    }
757}
758
759fn fatal(message: &str) -> ! {
760    eprintln!("error: {message}");
761    process::exit(1);
762}