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
14pub 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
72pub 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
128pub 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
232pub 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
256pub 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
280pub 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
304pub 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
328pub 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#[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
433pub 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| validation_errors_to_string(&errors))
499}
500
501fn load_catalog_validation(
502 manifest: Option<&Path>,
503) -> Result<ResolvedPersonaManifest, Vec<PersonaValidationError>> {
504 let result = if let Some(path) = manifest {
505 package::load_personas_from_manifest_path(path).map(Some)
506 } else {
507 package::load_personas_config(None)
508 };
509 match result {
510 Ok(Some(catalog)) => Ok(catalog),
511 Ok(None) => Err(vec![PersonaValidationError {
512 manifest_path: PathBuf::from("harn.toml"),
513 field_path: "harn.toml".to_string(),
514 message: "no harn.toml found; pass --manifest <path> or run inside a Harn project"
515 .to_string(),
516 }]),
517 Err(errors) => Err(errors),
518 }
519}
520
521fn validation_errors_to_string(errors: &[PersonaValidationError]) -> String {
522 errors
523 .iter()
524 .map(ToString::to_string)
525 .collect::<Vec<_>>()
526 .join("\n")
527}
528
529fn runtime_binding_or_err(
530 catalog: &ResolvedPersonaManifest,
531 name: &str,
532) -> Result<harn_vm::PersonaRuntimeBinding, String> {
533 let persona = catalog
534 .personas
535 .iter()
536 .find(|persona| persona.name.as_deref() == Some(name))
537 .ok_or_else(|| {
538 format!(
539 "persona '{}' not found in {}",
540 name,
541 catalog.manifest_path.display()
542 )
543 })?;
544 Ok(harn_vm::PersonaRuntimeBinding {
545 name: persona.name.clone().unwrap_or_default(),
546 template_ref: persona_template_ref(persona),
547 entry_workflow: persona.entry_workflow.clone().unwrap_or_default(),
548 schedules: persona.schedules.clone(),
549 triggers: persona.triggers.clone(),
550 budget: harn_vm::PersonaBudgetPolicy {
551 daily_usd: persona.budget.daily_usd,
552 hourly_usd: persona.budget.hourly_usd,
553 run_usd: persona.budget.run_usd,
554 max_tokens: persona.budget.max_tokens,
555 },
556 })
557}
558
559fn persona_template_ref(persona: &PersonaManifestEntry) -> Option<String> {
560 persona
561 .package_source
562 .package
563 .as_ref()
564 .zip(persona.version.as_ref())
565 .map(|(package, version)| format!("{package}@{version}"))
566 .or_else(|| persona.package_source.package.clone())
567 .or_else(|| {
568 persona
569 .name
570 .as_ref()
571 .zip(persona.version.as_ref())
572 .map(|(name, version)| format!("{name}@{version}"))
573 })
574}
575
576pub(super) fn open_persona_log(state_dir: &Path) -> Result<Arc<AnyEventLog>, String> {
577 let state_dir = absolutize_from_cwd(state_dir)?;
578 std::fs::create_dir_all(&state_dir).map_err(|error| {
579 format!(
580 "failed to create persona state dir {}: {error}",
581 state_dir.display()
582 )
583 })?;
584 harn_vm::event_log::install_default_for_base_dir(&state_dir)
585 .map_err(|error| format!("failed to open persona event log: {error}"))
586}
587
588fn absolutize_from_cwd(path: &Path) -> Result<PathBuf, String> {
589 if path.is_absolute() {
590 return Ok(path.to_path_buf());
591 }
592 std::env::current_dir()
593 .map(|cwd| cwd.join(path))
594 .map_err(|error| format!("failed to read current directory: {error}"))
595}
596
597pub(super) fn timestamp_arg(value: Option<&str>) -> Result<i64, String> {
598 match value {
599 Some(value) => harn_vm::parse_persona_ms(value),
600 None => Ok(harn_vm::persona_now_ms()),
601 }
602}
603
604fn parse_metadata(values: &[String]) -> Result<BTreeMap<String, String>, String> {
605 let mut metadata = BTreeMap::new();
606 for value in values {
607 let Some((key, raw)) = value.split_once('=') else {
608 return Err(format!("metadata '{value}' must use KEY=VALUE syntax"));
609 };
610 let key = key.trim();
611 if key.is_empty() {
612 return Err(format!("metadata '{value}' has an empty key"));
613 }
614 metadata.insert(key.to_string(), raw.to_string());
615 }
616 Ok(metadata)
617}
618
619fn print_status(status: &harn_vm::PersonaStatus, json: bool) {
620 if json {
621 println!(
622 "{}",
623 serde_json::to_string_pretty(status)
624 .unwrap_or_else(|error| fatal(&format!("failed to serialize status: {error}")))
625 );
626 return;
627 }
628 println!("persona: {}", status.name);
629 println!(
630 "template_ref: {}",
631 status.template_ref.as_deref().unwrap_or("-")
632 );
633 println!("state: {}", status.state.as_str());
634 println!("entry_workflow: {}", status.entry_workflow);
635 println!("role: {}", status.role);
636 println!(
637 "assignment: {}",
638 status
639 .current_assignment
640 .as_ref()
641 .map(|assignment| assignment.work_key.as_str())
642 .unwrap_or("-")
643 );
644 println!(
645 "last_run: {}",
646 status.last_run.as_deref().unwrap_or("-")
647 );
648 println!(
649 "next_run: {}",
650 status.next_scheduled_run.as_deref().unwrap_or("-")
651 );
652 println!("queued_events: {}", status.queued_events);
653 if !status.handoff_inbox.is_empty() {
654 println!("handoffs:");
655 for handoff in &status.handoff_inbox {
656 println!(
657 " - {} kind={} from={} task={}",
658 handoff
659 .handoff_id
660 .as_deref()
661 .unwrap_or(handoff.work_key.as_str()),
662 handoff.handoff_kind.as_deref().unwrap_or("-"),
663 handoff.source_persona.as_deref().unwrap_or("-"),
664 handoff.task.as_deref().unwrap_or("-")
665 );
666 }
667 }
668 println!(
669 "active_lease: {}",
670 status
671 .active_lease
672 .as_ref()
673 .map(|lease| lease.id.as_str())
674 .unwrap_or("-")
675 );
676 println!(
677 "budget: spent_today=${:.4} remaining_today={}",
678 status.budget.spent_today_usd,
679 status
680 .budget
681 .remaining_today_usd
682 .map(|value| format!("${value:.4}"))
683 .unwrap_or_else(|| "-".to_string())
684 );
685 if let Some(receipt) = status.value_receipts.last() {
686 println!(
687 "last_receipt: {} paid=${:.4} avoided=${:.4}",
688 receipt.kind.as_str(),
689 receipt.paid_cost_usd,
690 receipt.avoided_cost_usd
691 );
692 }
693 if let Some(error) = &status.last_error {
694 println!("last_error: {error}");
695 }
696}
697
698fn print_receipt(receipt: &harn_vm::PersonaRunReceipt, json: bool) {
699 if json {
700 println!(
701 "{}",
702 serde_json::to_string_pretty(receipt)
703 .unwrap_or_else(|error| fatal(&format!("failed to serialize receipt: {error}")))
704 );
705 } else {
706 println!(
707 "persona={} status={} work_key={} queued={}",
708 receipt.persona, receipt.status, receipt.work_key, receipt.queued
709 );
710 if let Some(error) = &receipt.error {
711 println!("error={error}");
712 }
713 }
714}
715
716fn print_validation_errors_json(errors: &[PersonaValidationError]) {
717 let payload = serde_json::json!({
718 "ok": false,
719 "errors": errors.iter().map(|error| {
720 serde_json::json!({
721 "manifest_path": &error.manifest_path,
722 "field_path": &error.field_path,
723 "message": &error.message,
724 })
725 }).collect::<Vec<_>>(),
726 });
727 println!(
728 "{}",
729 serde_json::to_string_pretty(&payload).unwrap_or_else(|error| {
730 fatal(&format!(
731 "failed to serialize persona validation errors: {error}"
732 ))
733 })
734 );
735}
736
737fn persona_to_json(
738 persona: &PersonaManifestEntry,
739 catalog: &ResolvedPersonaManifest,
740) -> serde_json::Value {
741 serde_json::json!({
742 "name": persona.name.as_deref().unwrap_or_default(),
743 "version": persona.version.as_deref(),
744 "description": persona.description.as_deref().unwrap_or_default(),
745 "entry_workflow": persona.entry_workflow.as_deref().unwrap_or_default(),
746 "tools": &persona.tools,
747 "capabilities": &persona.capabilities,
748 "autonomy_tier": persona.autonomy_tier.map(|tier| tier.as_str()).unwrap_or_default(),
749 "receipt_policy": persona.receipt_policy.map(|policy| policy.as_str()).unwrap_or_default(),
750 "triggers": &persona.triggers,
751 "schedules": &persona.schedules,
752 "model_policy": {
753 "default_model": persona.model_policy.default_model.as_deref(),
754 "escalation_model": persona.model_policy.escalation_model.as_deref(),
755 "fallback_models": &persona.model_policy.fallback_models,
756 "reasoning_effort": persona.model_policy.reasoning_effort.as_deref(),
757 },
758 "budget": {
759 "daily_usd": persona.budget.daily_usd,
760 "hourly_usd": persona.budget.hourly_usd,
761 "run_usd": persona.budget.run_usd,
762 "frontier_escalations": persona.budget.frontier_escalations,
763 "max_tokens": persona.budget.max_tokens,
764 "max_runtime_seconds": persona.budget.max_runtime_seconds,
765 },
766 "handoffs": &persona.handoffs,
767 "context_packs": &persona.context_packs,
768 "evals": &persona.evals,
769 "steps": &persona.steps,
770 "owner": persona.owner.as_deref(),
771 "package_source": {
772 "package": persona.package_source.package.as_deref(),
773 "path": persona.package_source.path.as_deref(),
774 "git": persona.package_source.git.as_deref(),
775 "rev": persona.package_source.rev.as_deref(),
776 },
777 "rollout_policy": {
778 "mode": persona.rollout_policy.mode.as_deref(),
779 "percentage": persona.rollout_policy.percentage,
780 "cohorts": &persona.rollout_policy.cohorts,
781 },
782 "source": {
783 "manifest_path": &catalog.manifest_path,
784 "manifest_dir": &catalog.manifest_dir,
785 },
786 })
787}
788
789fn comma_or_dash(values: &[String]) -> String {
790 if values.is_empty() {
791 "-".to_string()
792 } else {
793 values.join(", ")
794 }
795}
796
797fn fatal(message: &str) -> ! {
798 eprintln!("error: {message}");
799 process::exit(1);
800}