Skip to main content

harn_cli/commands/
demo.rs

1//! `harn demo` — bundled, fully-offline scenarios that demonstrate the
2//! Harn moat (persona supervision, replay determinism, provider
3//! routing) without any API keys. See issue #1650.
4//!
5//! Each scenario ships with:
6//!   - a `.harn` script (`assets/demo/<id>/scenario.harn`)
7//!   - a JSONL `--llm-mock` tape (`assets/demo/<id>/tape.jsonl`)
8//!
9//! Both are `include_str!`'d into the binary so `harn demo <id>` works
10//! from a static-linked install with no repo checkout.
11
12use 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
21/// Bundled scenarios shipped with the binary. Keep ordered by the
22/// "first-touch impact" we want stranger users to see — the menu and
23/// `--list` print in this order, and a bare `harn demo` (no scenario
24/// arg, non-interactive context) defaults to the first entry.
25const 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
65/// Public list of bundled scenario ids — used by tests and the smoke
66/// loop that exercises every demo on every PR.
67pub 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        // Non-interactive default: pick the first scenario.
86        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    // Conform to the `run_record` envelope the portal scans for so the
332    // demo shows up in `harn portal` alongside real workflow runs. The
333    // demo-specific payload (script, tape ref, stdout/stderr) lives
334    // under `metadata.demo` so portal listings can group on it.
335    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    // Heuristic on the rendered diagnostic — every error path Harn
375    // surfaces for "no key / wrong key / no provider" is one of these
376    // category strings or substrings. Avoids over-firing on script
377    // bugs that happen to fail under `--live`.
378    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        // Sanity: the const SCENARIOS array's ids should mirror the
447        // checked-in asset directories. If a developer adds a new
448        // scenario but forgets to wire it into SCENARIOS, this test
449        // does nothing — but if they rename an asset dir without
450        // updating the const, the include_str! at top will fail to
451        // compile, which is the better failure mode.
452        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}