1use std::collections::HashSet;
13use std::fs;
14use std::io::IsTerminal;
15use std::path::{Path, PathBuf};
16use std::time::{Instant, SystemTime, UNIX_EPOCH};
17
18use crate::cli::DemoArgs;
19use crate::commands::run::{execute_run, CliLlmMockMode, RunOutcome, RunProfileOptions};
20
21const SCENARIOS: &[Scenario] = &[
26 Scenario {
27 id: "merge-captain",
28 title: "Merge Captain triages 3 PRs",
29 description: "A merge_captain persona triages three mocked PRs (trivial, risky, buggy), \
30 asks an LLM per PR, and emits a structured supervision receipt. \
31 Demonstrates persona supervision, approval gates, and trust receipts.",
32 script: include_str!("../../assets/demo/merge-captain/scenario.harn"),
33 tape: include_str!("../../assets/demo/merge-captain/tape.jsonl"),
34 },
35 Scenario {
36 id: "review-captain",
37 title: "Review Captain inspects a 5-file diff",
38 description: "A review_captain reviews a 5-file diff, asks one clarifying question \
39 (HITL surfaced via the receipt), then renders a verdict with \
40 reasoning. Demonstrates clarifying-question loops and structured \
41 review receipts.",
42 script: include_str!("../../assets/demo/review-captain/scenario.harn"),
43 tape: include_str!("../../assets/demo/review-captain/tape.jsonl"),
44 },
45 Scenario {
46 id: "provider-race",
47 title: "Provider race with cost attribution",
48 description: "Race three providers on one prompt with `parallel each`, pick the \
49 lowest-latency winner, and emit a cost-attribution receipt. \
50 Previews the routing_policy primitive (#1649).",
51 script: include_str!("../../assets/demo/provider-race/scenario.harn"),
52 tape: include_str!("../../assets/demo/provider-race/tape.jsonl"),
53 },
54];
55
56#[derive(Clone, Copy)]
57struct Scenario {
58 id: &'static str,
59 title: &'static str,
60 description: &'static str,
61 script: &'static str,
62 tape: &'static str,
63}
64
65pub fn scenario_ids() -> Vec<&'static str> {
68 SCENARIOS.iter().map(|s| s.id).collect()
69}
70
71pub(crate) async fn run(args: DemoArgs) -> i32 {
72 if args.list {
73 print_list_table(args.json);
74 return 0;
75 }
76
77 let Some(scenario_id) = args.scenario.clone() else {
78 if args.json {
79 print_list_table(true);
80 return 0;
81 }
82 if std::io::stdout().is_terminal() {
83 return interactive_pick(&args).await;
84 }
85 return run_scenario(&args, SCENARIOS[0]).await;
87 };
88
89 let Some(scenario) = lookup_scenario(&scenario_id) else {
90 eprintln!("error: unknown scenario `{scenario_id}`");
91 eprintln!();
92 print_list_table(false);
93 return 2;
94 };
95 run_scenario(&args, *scenario).await
96}
97
98fn lookup_scenario(id: &str) -> Option<&'static Scenario> {
99 SCENARIOS.iter().find(|s| s.id == id)
100}
101
102fn print_list_table(as_json: bool) {
103 if as_json {
104 let entries: Vec<serde_json::Value> = SCENARIOS
105 .iter()
106 .map(|s| {
107 serde_json::json!({
108 "id": s.id,
109 "title": s.title,
110 "description": s.description,
111 })
112 })
113 .collect();
114 println!(
115 "{}",
116 serde_json::to_string_pretty(&serde_json::json!({"scenarios": entries}))
117 .unwrap_or_default()
118 );
119 return;
120 }
121 println!("Available demos (replayable offline, no API keys required):");
122 println!();
123 for s in SCENARIOS {
124 println!(" {:<16} {}", s.id, s.title);
125 for line in wrap_text(s.description, 70) {
126 println!(" {line}");
127 }
128 println!();
129 }
130 println!("Run a scenario: harn demo <id>");
131 println!("Use real provider: harn demo <id> --live");
132}
133
134fn wrap_text(text: &str, width: usize) -> Vec<String> {
135 let mut lines = Vec::new();
136 let mut current = String::new();
137 for word in text.split_whitespace() {
138 if current.is_empty() {
139 current.push_str(word);
140 continue;
141 }
142 if current.len() + 1 + word.len() > width {
143 lines.push(std::mem::take(&mut current));
144 current.push_str(word);
145 } else {
146 current.push(' ');
147 current.push_str(word);
148 }
149 }
150 if !current.is_empty() {
151 lines.push(current);
152 }
153 lines
154}
155
156async fn interactive_pick(args: &DemoArgs) -> i32 {
157 use std::io::Write;
158 println!("Pick a Harn demo (offline replay, no API keys required):");
159 println!();
160 for (idx, s) in SCENARIOS.iter().enumerate() {
161 println!(" {}) {:<16} {}", idx + 1, s.id, s.title);
162 }
163 println!();
164 print!("Choice [1-{}, default 1]: ", SCENARIOS.len());
165 let _ = std::io::stdout().flush();
166 let mut buf = String::new();
167 let n = std::io::stdin().read_line(&mut buf).unwrap_or(0);
168 let trimmed = buf.trim();
169 let pick: usize = if n == 0 || trimmed.is_empty() {
170 1
171 } else {
172 match trimmed.parse::<usize>() {
173 Ok(n) if (1..=SCENARIOS.len()).contains(&n) => n,
174 _ => {
175 eprintln!("error: invalid selection `{trimmed}`");
176 return 2;
177 }
178 }
179 };
180 run_scenario(args, SCENARIOS[pick - 1]).await
181}
182
183async fn run_scenario(args: &DemoArgs, scenario: Scenario) -> i32 {
184 let staged = match stage_scenario(scenario) {
185 Ok(s) => s,
186 Err(error) => {
187 eprintln!("error: {error}");
188 return 1;
189 }
190 };
191
192 if !args.json {
193 println!("=== harn demo · {} ===", scenario.id);
194 println!("{}", scenario.title);
195 println!();
196 if !args.live {
197 println!("(offline replay — no API keys required)");
198 println!();
199 }
200 }
201
202 let llm_mock_mode = if args.live {
203 if !args.json {
204 println!("(--live: routing through the configured provider — set HARN_LLM_PROVIDER if none is wired)");
205 println!();
206 }
207 CliLlmMockMode::Off
208 } else {
209 CliLlmMockMode::Replay {
210 fixture_path: staged.tape_path.clone(),
211 }
212 };
213
214 let started = Instant::now();
215 let outcome = execute_run(
216 staged.script_path.to_string_lossy().as_ref(),
217 false,
218 HashSet::new(),
219 Vec::new(),
220 Vec::new(),
221 llm_mock_mode,
222 None,
223 RunProfileOptions::default(),
224 )
225 .await;
226 let elapsed = started.elapsed();
227
228 if !args.json && !outcome.stdout.is_empty() {
229 print!("{}", outcome.stdout);
230 }
231 if !outcome.stderr.is_empty() {
232 eprint!("{}", outcome.stderr);
233 }
234
235 if outcome.exit_code != 0 {
236 if !args.json {
237 eprintln!(
238 "error: demo `{}` failed (exit {})",
239 scenario.id, outcome.exit_code
240 );
241 if args.live && live_failure_looks_like_provider_misconfig(&outcome) {
242 eprintln!();
243 eprintln!("hint: --live needs a configured LLM provider. Re-run without --live");
244 eprintln!(" to use the bundled offline tape, or run `harn quickstart`");
245 eprintln!(" to wire a provider.");
246 }
247 } else {
248 print_json_summary(scenario, &outcome, elapsed.as_millis(), None);
249 }
250 return outcome.exit_code;
251 }
252
253 let receipt_dir = if args.no_record {
254 None
255 } else {
256 match write_run_record(scenario, &outcome) {
257 Ok(path) => Some(path),
258 Err(error) => {
259 eprintln!("warning: failed to write demo run record: {error}");
260 None
261 }
262 }
263 };
264
265 if args.json {
266 print_json_summary(
267 scenario,
268 &outcome,
269 elapsed.as_millis(),
270 receipt_dir.as_deref(),
271 );
272 } else {
273 println!();
274 println!("--- demo complete in {} ms ---", elapsed.as_millis());
275 if let Some(dir) = &receipt_dir {
276 println!(" run record: {}", dir.join("run.json").display());
277 }
278 println!();
279 println!("Next steps:");
280 println!(" harn demo --list list every bundled scenario");
281 if !args.live {
282 println!(
283 " harn demo {} --live run again against the configured provider",
284 scenario.id
285 );
286 }
287 println!(" harn portal browse run records in the UI");
288 }
289 0
290}
291
292struct StagedScenario {
293 _temp_root: tempfile::TempDir,
294 script_path: PathBuf,
295 tape_path: PathBuf,
296}
297
298fn stage_scenario(scenario: Scenario) -> Result<StagedScenario, String> {
299 let dir = tempfile::Builder::new()
300 .prefix(&format!("harn-demo-{}-", scenario.id))
301 .tempdir()
302 .map_err(|e| format!("failed to create demo tempdir: {e}"))?;
303 let script_path = dir.path().join(format!("{}.harn", scenario.id));
304 let tape_path = dir.path().join(format!("{}.tape.jsonl", scenario.id));
305 fs::write(&script_path, scenario.script)
306 .map_err(|e| format!("failed to stage demo script: {e}"))?;
307 fs::write(&tape_path, scenario.tape).map_err(|e| format!("failed to stage demo tape: {e}"))?;
308 Ok(StagedScenario {
309 _temp_root: dir,
310 script_path,
311 tape_path,
312 })
313}
314
315fn write_run_record(scenario: Scenario, outcome: &RunOutcome) -> Result<PathBuf, String> {
316 let cwd = std::env::current_dir().map_err(|e| format!("cwd: {e}"))?;
317 let runs_root = cwd.join(".harn-runs");
318 let ts = SystemTime::now()
319 .duration_since(UNIX_EPOCH)
320 .map(|d| d.as_secs())
321 .unwrap_or(0);
322 let started_iso = time::OffsetDateTime::from_unix_timestamp(ts as i64)
323 .ok()
324 .and_then(|t| {
325 t.format(&time::format_description::well_known::Rfc3339)
326 .ok()
327 })
328 .unwrap_or_else(|| format!("1970-01-01T00:00:{ts:02}Z"));
329 let dir = runs_root.join(format!("demo-{}-{ts}", scenario.id));
330 fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
331 let record = serde_json::json!({
336 "_type": "run_record",
337 "id": format!("demo-{}-{ts}", scenario.id),
338 "workflow_id": format!("harn-demo:{}", scenario.id),
339 "workflow_name": scenario.title,
340 "task": scenario.id,
341 "status": if outcome.exit_code == 0 { "complete" } else { "failed" },
342 "started_at": started_iso,
343 "finished_at": started_iso,
344 "stages": [],
345 "transitions": [],
346 "checkpoints": [],
347 "pending_nodes": [],
348 "completed_nodes": [],
349 "child_runs": [],
350 "artifacts": [],
351 "policy": {},
352 "metadata": {
353 "demo": {
354 "scenario": scenario.id,
355 "title": scenario.title,
356 "description": scenario.description,
357 "exit_code": outcome.exit_code,
358 "stdout": outcome.stdout,
359 "stderr": outcome.stderr,
360 "recorded_at_unix_seconds": ts,
361 }
362 },
363 });
364 let path = dir.join("run.json");
365 fs::write(
366 &path,
367 serde_json::to_string_pretty(&record).unwrap_or_default(),
368 )
369 .map_err(|e| format!("write {}: {e}", path.display()))?;
370 Ok(dir)
371}
372
373fn live_failure_looks_like_provider_misconfig(outcome: &RunOutcome) -> bool {
374 let blob = format!("{}{}", outcome.stderr, outcome.stdout);
379 blob.contains("category: auth")
380 || blob.contains("auth_failure")
381 || blob.contains("HTTP 401")
382 || blob.contains("HTTP 403")
383 || blob.contains("api_key")
384 || blob.contains("HARN_LLM_PROVIDER")
385 || blob.contains("no provider configured")
386}
387
388fn print_json_summary(
389 scenario: Scenario,
390 outcome: &RunOutcome,
391 elapsed_ms: u128,
392 record_dir: Option<&Path>,
393) {
394 let record = serde_json::json!({
395 "scenario": scenario.id,
396 "title": scenario.title,
397 "exit_code": outcome.exit_code,
398 "elapsed_ms": elapsed_ms,
399 "stdout": outcome.stdout,
400 "stderr": outcome.stderr,
401 "run_record_dir": record_dir.map(|p| p.display().to_string()),
402 });
403 println!(
404 "{}",
405 serde_json::to_string_pretty(&record).unwrap_or_default()
406 );
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 #[test]
414 fn scenarios_have_unique_nonempty_ids() {
415 let mut seen = HashSet::new();
416 for s in SCENARIOS {
417 assert!(!s.id.is_empty(), "scenario id is empty");
418 assert!(!s.title.is_empty(), "scenario {} has empty title", s.id);
419 assert!(
420 !s.description.is_empty(),
421 "scenario {} has empty description",
422 s.id
423 );
424 assert!(!s.script.is_empty(), "scenario {} script is empty", s.id);
425 assert!(!s.tape.is_empty(), "scenario {} tape is empty", s.id);
426 assert!(seen.insert(s.id), "duplicate scenario id: {}", s.id);
427 }
428 }
429
430 #[test]
431 fn scenario_tape_lines_parse_as_json() {
432 for s in SCENARIOS {
433 for (i, line) in s.tape.lines().enumerate() {
434 if line.trim().is_empty() {
435 continue;
436 }
437 serde_json::from_str::<serde_json::Value>(line).unwrap_or_else(|e| {
438 panic!("scenario {} tape line {} is invalid JSON: {e}", s.id, i + 1)
439 });
440 }
441 }
442 }
443
444 #[test]
445 fn scenario_ids_match_assets_dir_names() {
446 let manifest_dir = env!("CARGO_MANIFEST_DIR");
453 let assets = std::path::Path::new(manifest_dir).join("assets/demo");
454 for s in SCENARIOS {
455 let dir = assets.join(s.id);
456 assert!(dir.is_dir(), "missing demo asset dir for {}", s.id);
457 assert!(
458 dir.join("scenario.harn").is_file(),
459 "missing scenario.harn for {}",
460 s.id
461 );
462 assert!(
463 dir.join("tape.jsonl").is_file(),
464 "missing tape.jsonl for {}",
465 s.id
466 );
467 }
468 }
469}