1use std::collections::HashSet;
19use std::fs;
20use std::io::IsTerminal;
21use std::path::{Path, PathBuf};
22use std::time::{Instant, SystemTime, UNIX_EPOCH};
23
24use unicode_width::UnicodeWidthStr;
25
26use crate::cli::DemoArgs;
27use crate::commands::run::{
28 execute_run_with_sandbox_options, CliLlmMockMode, RunOutcome, RunProfileOptions,
29 RunSandboxOptions,
30};
31
32include!(concat!(env!("OUT_DIR"), "/demo_assets_table.rs"));
37
38const SCENARIOS: &[Scenario] = &[
43 Scenario {
44 id: "merge-captain",
45 title: "Merge Captain triages 3 PRs",
46 description: "A merge_captain persona triages three mocked PRs (trivial, risky, buggy), \
47 asks an LLM per PR, and emits a structured supervision receipt. \
48 Demonstrates persona supervision, approval gates, and trust receipts.",
49 script: include_str!("../../assets/demo/merge-captain/scenario.harn"),
50 tape: include_str!("../../assets/demo/merge-captain/tape.jsonl"),
51 },
52 Scenario {
53 id: "review-captain",
54 title: "Review Captain inspects a 5-file diff",
55 description: "A review_captain reviews a 5-file diff, asks one clarifying question \
56 (HITL surfaced via the receipt), then renders a verdict with \
57 reasoning. Demonstrates clarifying-question loops and structured \
58 review receipts.",
59 script: include_str!("../../assets/demo/review-captain/scenario.harn"),
60 tape: include_str!("../../assets/demo/review-captain/tape.jsonl"),
61 },
62 Scenario {
63 id: "provider-race",
64 title: "Provider race with cost attribution",
65 description: "Race three providers on one prompt with `parallel each`, pick the \
66 lowest-latency winner, and emit a cost-attribution receipt. \
67 Previews the routing_policy primitive (#1649).",
68 script: include_str!("../../assets/demo/provider-race/scenario.harn"),
69 tape: include_str!("../../assets/demo/provider-race/tape.jsonl"),
70 },
71 Scenario {
72 id: "routing-policy",
73 title: "routing_policy escalates a cheap chain to a frontier link",
74 description: "Drive the v0.8.40 routing_policy primitive through three mocked tasks: a \
75 clean cheap-link success, a 429 that fails over to the frontier, and a \
76 TODO-poisoned cheap reply that the lint verifier escalates. Demonstrates \
77 per-attempt routing receipts, failover, and verifier-signal escalation \
78 (canary scenario for the demo gate, #2437).",
79 script: include_str!("../../assets/demo/routing-policy/scenario.harn"),
80 tape: include_str!("../../assets/demo/routing-policy/tape.jsonl"),
81 },
82 Scenario {
83 id: "stdlib-toolkit",
84 title: "stdlib toolkit assembles an XML system-prompt context",
85 description: "Walk the new clone / deep_merge / unique / dict_from_pairs / to_xml / \
86 from_xml / word_wrap / indent / repeat built-ins through a realistic \
87 pre-flight render: layer per-task overrides onto a shared defaults dict, \
88 dedupe the operator's `previous_chats` list, emit an XML `<context>` \
89 block, round-trip it back through the parser, and frame the result in a \
90 60-column prompt margin. Fully offline.",
91 script: include_str!("../../assets/demo/stdlib-toolkit/scenario.harn"),
92 tape: include_str!("../../assets/demo/stdlib-toolkit/tape.jsonl"),
93 },
94 Scenario {
95 id: "embed-similarity",
96 title: "hostlib_embed_* ranks code-agent context offline",
97 description: "Drive the cross-platform hostlib embedding surface fully offline: inspect \
98 the active backend, embed one symbol-like query, compare related vs \
99 unrelated text, and top-k rank a small context corpus. Demonstrates the \
100 shared similarity contract Burin can reuse for push-context retrieval and \
101 symbol relevance without macOS-only NaturalLanguage or live model calls.",
102 script: include_str!("../../assets/demo/embed-similarity/scenario.harn"),
103 tape: include_str!("../../assets/demo/embed-similarity/tape.jsonl"),
104 },
105 Scenario {
106 id: "project-metadata",
107 title: "project.metadata_* falls back to Harn metadata offline",
108 description: "Drive standalone `host_call(\"project.metadata_*\", ...)` through Harn's \
109 built-in metadata store: set a namespace, read it with hierarchical \
110 inheritance, inspect the origin, refresh structure hashes, and save. \
111 Demonstrates the CLI/debug fallback path while product host bridges keep \
112 precedence when attached.",
113 script: include_str!("../../assets/demo/project-metadata/scenario.harn"),
114 tape: include_str!("../../assets/demo/project-metadata/tape.jsonl"),
115 },
116 Scenario {
117 id: "runtime-prompt-content",
118 title: "runtime.prompt_content reads typed multimodal session input",
119 description: "Mock an ACP session prompt at the runtime host boundary, read its typed text \
120 and image blocks through `runtime_prompt_content()`, and emit a structured \
121 receipt. Fully offline.",
122 script: include_str!("../../assets/demo/runtime-prompt-content/scenario.harn"),
123 tape: include_str!("../../assets/demo/runtime-prompt-content/tape.jsonl"),
124 },
125 Scenario {
126 id: "verification-snapshot",
127 title: "verification_file_hash_snapshot binds checks to current file bytes",
128 description: "Build a throw-away Rust workspace, seed the code index, then capture a \
129 seq-bound batch of file hashes through `std/verification`: fresh indexed \
130 files reuse index metadata, changed or newly-created files read current \
131 disk bytes, missing paths are explicit, and the resulting path->hash map \
132 feeds `verification_diagnostic_classify`. Fully offline.",
133 script: include_str!("../../assets/demo/verification-snapshot/scenario.harn"),
134 tape: include_str!("../../assets/demo/verification-snapshot/tape.jsonl"),
135 },
136 Scenario {
137 id: "command-capture",
138 title: "run_command preserves a slow command's full output past a `| tail` filter",
139 description: "Walk the std/agent/command_capture recognizer: rewrite `producer | tail/wc/grep` \
140 pipelines to `producer | tee '<capture>' 2>/dev/null | filter` so the agent \
141 still sees the filtered output while the producer's COMPLETE output is \
142 preserved on disk, show the cases it deliberately leaves untouched (head, \
143 grep -q, command substitution, subshell grouping), then materialize a capture \
144 and demonstrate the post-run `output_capture` hint that lets an agent read the \
145 full output instead of re-running a slow command. Fully offline — no LLM, no \
146 subprocess.",
147 script: include_str!("../../assets/demo/command-capture/scenario.harn"),
148 tape: include_str!("../../assets/demo/command-capture/tape.jsonl"),
149 },
150 Scenario {
151 id: "agent-edit-tools",
152 title: "agent_edit_tools ships the canonical write/edit/mkdir/delete toolset",
153 description: "Drive the default mutation tools (#PR-D) offline: `write_file`, `edit_file`, \
154 `create_directory`, and `delete_path` from `std/agent/host_tools`, root-scoped \
155 wrappers over the hostlib filesystem primitives. Walk the happy paths, the \
156 guardrails (edit_file refuses an ambiguous match; delete_path refuses a \
157 non-empty directory unless recursive), and the middleware seam — the same \
158 named tools wrapped with `with_consent` + `with_audit_log` so an embedder gets \
159 approval and receipts for free. Honestly annotated as mutating so the \
160 read-only stance hides them. Fully offline — no LLM, no subprocess.",
161 script: include_str!("../../assets/demo/agent-edit-tools/scenario.harn"),
162 tape: include_str!("../../assets/demo/agent-edit-tools/tape.jsonl"),
163 },
164 Scenario {
165 id: "compaction-policy",
166 title: "compaction.{policy,check,run} drives a session through the lifecycle",
167 description: "Declare a per-session compaction policy with thresholds, call \
168 `compaction.check` to get a `compact_now` / `defer` decision, then drive \
169 the canonical #2323 lifecycle via `compaction.run`. Demonstrates the \
170 lifted-from-TUI policy primitive (#2505) entirely offline using a custom \
171 summarize closure.",
172 script: include_str!("../../assets/demo/compaction-policy/scenario.harn"),
173 tape: include_str!("../../assets/demo/compaction-policy/tape.jsonl"),
174 },
175 Scenario {
176 id: "mcp-host",
177 title: "harn.mcp.* host primitive lazy-spawn + status round-trip",
178 description: "Drive the supervised MCP-host primitive (#2504): register two lazy MCP \
179 server specs, snapshot the supervision status (restart_count, circuit, \
180 cache_entries), stop one of them, and re-snapshot. Stays fully offline \
181 because lazy spawn doesn't try to connect. The receipt asserts every \
182 initial entry starts with the circuit closed and cache empty — the \
183 invariants downstream observability hooks lean on.",
184 script: include_str!("../../assets/demo/mcp-host/scenario.harn"),
185 tape: include_str!("../../assets/demo/mcp-host/tape.jsonl"),
186 },
187 Scenario {
188 id: "http-transport",
189 title: "http_etag / http_choose / http_not_modified / http_upgrade_ws / http_push_hints",
190 description: "Drive the A.12 transport-completeness builtins (#2515) offline: pick a \
191 content type from a simulated `Accept` header via `http_choose`, derive a \
192 strong ETag from the JSON payload, build the matching `http_not_modified` \
193 envelope, assemble the `http_upgrade_ws` envelope with subprotocol \
194 negotiation, and decorate an `http_ok` envelope with `http_push_hints` so \
195 the codec emits one `Link: <path>; rel=preload; as=...` header per asset. \
196 The conformance test in `crates/harn-serve/tests/\
197 transport_conformance.rs` exercises the live HTTP / WS path; the demo \
198 keeps the offline smoke covered.",
199 script: include_str!("../../assets/demo/http-transport/scenario.harn"),
200 tape: include_str!("../../assets/demo/http-transport/tape.jsonl"),
201 },
202 Scenario {
203 id: "harn-site",
204 title: "harn serve site — a .harn file answers its own HTTP routes",
205 description: "Drive the `harn serve site` handler contract (#2574) offline: a routed \
206 `pub fn` receives a request dict and returns an `http_*` envelope. The \
207 scenario calls a GET handler, a POST handler that echoes its body, and a \
208 conditional GET that returns 200 then 304 via `http_not_modified`, plus the \
209 `on_message` WebSocket frame callback — capturing each status in a receipt. \
210 The live socket path (routing, multipart, WS upgrade) is covered by \
211 `crates/harn-serve/tests/site_hosting.rs`; the demo keeps the offline \
212 handler-contract smoke covered.",
213 script: include_str!("../../assets/demo/harn-site/scenario.harn"),
214 tape: include_str!("../../assets/demo/harn-site/tape.jsonl"),
215 },
216 Scenario {
217 id: "obs-primitive",
218 title: "harness.obs.* spans + counter/histogram/gauge + audit roundtrip",
219 description: "Drive the standardized observability primitive (#2513): open a span over a \
220 simulated `harn.session.put`, record one of each instrument variant \
221 (counter / histogram / gauge), emit a structured log inside the span, then \
222 drain the in-process buffer and surface a receipt of events-by-kind plus \
223 the bound request_id. Validates vocabulary at emit time and routes through \
224 the `test` backend so the scenario stays fully offline.",
225 script: include_str!("../../assets/demo/obs-primitive/scenario.harn"),
226 tape: include_str!("../../assets/demo/obs-primitive/tape.jsonl"),
227 },
228 Scenario {
229 id: "edit-rename-symbol",
230 title: "edit.rename_symbol rewrites a Rust struct across the workspace",
231 description: "Stage a tiny Rust workspace, build the typed symbol graph (#2434), then \
232 drive `edit_rename_symbol` (#2508) through dry-run + applied + conflict \
233 paths: a workspace-scoped plan with per-edit byte/(row,col) spans, the \
234 same plan committed for real with identifier-context rewrites (skipping \
235 string literals and comments), and a follow-up rename whose new name \
236 already shadows another identifier — host short-circuits without \
237 touching disk. Fully offline.",
238 script: include_str!("../../assets/demo/edit-rename-symbol/scenario.harn"),
239 tape: include_str!("../../assets/demo/edit-rename-symbol/tape.jsonl"),
240 },
241 Scenario {
242 id: "edit-language-coverage",
243 title: "edit.capabilities + apply_node span the B.7 tier-1 languages",
244 description: "Read the per-language AST-precise edit capability matrix (#2519), show \
245 graceful degradation for a language with no grammar (Dockerfile returns \
246 `unsupported_language` plus a text-edit fallback), then drive \
247 `edit_apply_node` (dry-run) against bundled JSON and CSS seeds to prove a \
248 format-preserving edit round-trips on the data/markup grammars added in \
249 B.7. Fully offline.",
250 script: include_str!("../../assets/demo/edit-language-coverage/scenario.harn"),
251 tape: include_str!("../../assets/demo/edit-language-coverage/tape.jsonl"),
252 },
253 Scenario {
254 id: "edit-refactor",
255 title: "edit.extract_function / add_parameter / change_return_type preview as diffs",
256 description: "Drive the B.8 structured-refactoring primitives (#2520) offline against a \
257 bundled seed workspace, all in dry-run mode: extract two statements of a \
258 Python function into `compute_subtotal` (capturing `base`/`qty` but not the \
259 module-level `audit`), append a trailing parameter to a Rust function and \
260 fill the argument at all three call sites, and rewrite that function's \
261 return type — each previewed as a unified diff against a throw-away \
262 staged-fs overlay so no bytes hit disk. The conformance test in \
263 `crates/harn-vm`/`conformance` exercises the apply path; this demo keeps \
264 the offline preview smoke covered.",
265 script: include_str!("../../assets/demo/edit-refactor/scenario.harn"),
266 tape: include_str!("../../assets/demo/edit-refactor/tape.jsonl"),
267 },
268 Scenario {
269 id: "prompt-guidance",
270 title: "tool guidance rides with the tool, and the prompt is auditable",
271 description: "Drive the unified prompt-fragment assembler: a tool carries a `guidance` \
272 string that the runtime injects as a capability-gated system-prompt fragment \
273 (`requires_tools: [<that tool>]`), so a 'always update the TODO tracker' \
274 instruction appears only when the todo tool is present and never otherwise — \
275 instruction and tool share one source of truth and cannot drift. Calls \
276 `prompt_explain(options)` with and without the tool, prints the assembled \
277 system string plus the per-fragment provenance (included/excluded + reason + \
278 bytes) an operator inspects, and proves the only difference between the two \
279 prompts is exactly the gated fragment. Fully offline — no LLM, no network.",
280 script: include_str!("../../assets/demo/prompt-guidance/scenario.harn"),
281 tape: include_str!("../../assets/demo/prompt-guidance/tape.jsonl"),
282 },
283 Scenario {
284 id: "destructure-with-defaults",
285 title: "destructuring-with-defaults collapses the `input?.x ?? default` idiom",
286 description: "Collapse the most-repeated idiom in our Harn corpus — \
287 `const x = input?.field ?? default` (~5,700 sites across BurinCore alone) — \
288 into a single destructuring bind: `const { path = \"\", namespace = nil } = \
289 pipeline_input() ?? {}`. Present keys win, missing keys fall to their \
290 defaults, and the type checker now infers each binding's type from \
291 `field + default` exactly as the `?.`/`??` form did, so the migration is \
292 lossless under the checker. Fully offline — no LLM, no network.",
293 script: include_str!("../../assets/demo/destructure-with-defaults/scenario.harn"),
294 tape: include_str!("../../assets/demo/destructure-with-defaults/tape.jsonl"),
295 },
296 Scenario {
297 id: "lexical-block",
298 title: "`block` bounds cleanup and owned lifetimes",
299 description: "Use an explicit `block { ... }` to create a lexical lifetime without a \
300 control-flow condition: keep inner bindings scoped, run deferred cleanup at \
301 the block boundary, then continue with outer state. This is \
302 the direct lifetime primitive for owned resources that previously required \
303 a helper function. Fully offline — no LLM, network, or subprocess.",
304 script: include_str!("../../assets/demo/lexical-block/scenario.harn"),
305 tape: include_str!("../../assets/demo/lexical-block/tape.jsonl"),
306 },
307 Scenario {
308 id: "pub-type-exports",
309 title: "`pub type` shares one alias across modules, annotations, and output",
310 description: "Export a type alias from one module and consume it from another: the \
311 imported alias drives a plain annotation, an exhaustive `match` over its \
312 literal union, and schema-as-type on `llm_call` \
313 (`output: GradeReport`) — the compiler lowers the exported alias to \
314 JSON Schema and the validated result narrows back to it. Before `pub type`, \
315 every consumer re-declared shared wire shapes because aliases were \
316 module-private. Offline — the single LLM call replays from the bundled \
317 tape.",
318 script: include_str!("../../assets/demo/pub-type-exports/scenario.harn"),
319 tape: include_str!("../../assets/demo/pub-type-exports/tape.jsonl"),
320 },
321 Scenario {
322 id: "catalog-patch-models",
323 title: "[patch.models] tweaks one catalog field without copying the row",
324 description: "A sibling harn.toml contributes a demo catalog row and a field-wise \
325 `[llm.patch.models]` patch for two of its fields (stream_timeout and \
326 pricing.output_per_mtok). The scenario reads the effective catalog back \
327 via `llm_model_info` and asserts the patched fields carry the patch \
328 values while every unpatched sibling field keeps its baseline — the \
329 alternative used to be copying the whole row verbatim and freezing it \
330 against catalog updates. Fully offline — no LLM calls.",
331 script: include_str!("../../assets/demo/catalog-patch-models/scenario.harn"),
332 tape: include_str!("../../assets/demo/catalog-patch-models/tape.jsonl"),
333 },
334];
335
336#[derive(Clone, Copy)]
337struct Scenario {
338 id: &'static str,
339 title: &'static str,
340 description: &'static str,
341 script: &'static str,
342 tape: &'static str,
343}
344
345pub fn scenario_ids() -> Vec<&'static str> {
348 SCENARIOS.iter().map(|s| s.id).collect()
349}
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq)]
353enum NoScenarioAction {
354 ListJson,
356 Prompt,
358 ListTable,
360}
361
362fn no_scenario_action(json: bool, stdout_tty: bool, stdin_tty: bool) -> NoScenarioAction {
366 if json {
367 return NoScenarioAction::ListJson;
368 }
369 if stdout_tty && stdin_tty {
370 return NoScenarioAction::Prompt;
371 }
372 NoScenarioAction::ListTable
373}
374
375pub(crate) async fn run(args: DemoArgs) -> i32 {
376 if args.list {
377 print_list_table(args.json);
378 return 0;
379 }
380
381 let Some(scenario_id) = args.scenario.clone() else {
382 return match no_scenario_action(
383 args.json,
384 std::io::stdout().is_terminal(),
385 std::io::stdin().is_terminal(),
386 ) {
387 NoScenarioAction::ListJson => {
388 print_list_table(true);
389 0
390 }
391 NoScenarioAction::Prompt => interactive_pick(&args).await,
392 NoScenarioAction::ListTable => {
393 print_list_table(false);
394 0
395 }
396 };
397 };
398
399 let Some(scenario) = lookup_scenario(&scenario_id) else {
400 eprintln!("error: unknown scenario `{scenario_id}`");
401 eprintln!();
402 print_list_table(false);
403 return 2;
404 };
405 run_scenario(&args, *scenario).await
406}
407
408fn lookup_scenario(id: &str) -> Option<&'static Scenario> {
409 SCENARIOS.iter().find(|s| s.id == id)
410}
411
412fn print_list_table(as_json: bool) {
413 if as_json {
414 let entries: Vec<serde_json::Value> = SCENARIOS
415 .iter()
416 .map(|s| {
417 serde_json::json!({
418 "id": s.id,
419 "title": s.title,
420 "description": s.description,
421 })
422 })
423 .collect();
424 println!(
425 "{}",
426 serde_json::to_string_pretty(&serde_json::json!({"scenarios": entries}))
427 .unwrap_or_default()
428 );
429 return;
430 }
431 println!("Available demos (replayable offline, no API keys required):");
432 println!();
433 for s in SCENARIOS {
434 println!(" {:<16} {}", s.id, s.title);
435 for line in wrap_text(s.description, 70) {
436 println!(" {line}");
437 }
438 println!();
439 }
440 println!("Run a scenario: harn demo <id>");
441 println!("Use real provider: harn demo <id> --live");
442}
443
444fn wrap_text(text: &str, width: usize) -> Vec<String> {
448 let mut lines = Vec::new();
449 let mut current = String::new();
450 for word in text.split_whitespace() {
451 if current.is_empty() {
452 current.push_str(word);
453 continue;
454 }
455 if UnicodeWidthStr::width(current.as_str()) + 1 + UnicodeWidthStr::width(word) > width {
456 lines.push(std::mem::take(&mut current));
457 current.push_str(word);
458 } else {
459 current.push(' ');
460 current.push_str(word);
461 }
462 }
463 if !current.is_empty() {
464 lines.push(current);
465 }
466 lines
467}
468
469async fn interactive_pick(args: &DemoArgs) -> i32 {
470 use std::io::Write;
471 println!("Pick a Harn demo (offline replay, no API keys required):");
472 println!();
473 for (idx, s) in SCENARIOS.iter().enumerate() {
474 println!(" {}) {:<16} {}", idx + 1, s.id, s.title);
475 }
476 println!();
477 print!("Choice [1-{}, default 1]: ", SCENARIOS.len());
478 let _ = std::io::stdout().flush();
479 let mut buf = String::new();
480 let n = std::io::stdin().read_line(&mut buf).unwrap_or(0);
481 let trimmed = buf.trim();
482 let pick: usize = if n == 0 || trimmed.is_empty() {
483 1
484 } else {
485 match trimmed.parse::<usize>() {
486 Ok(n) if (1..=SCENARIOS.len()).contains(&n) => n,
487 _ => {
488 eprintln!("error: invalid selection `{trimmed}`");
489 return 2;
490 }
491 }
492 };
493 run_scenario(args, SCENARIOS[pick - 1]).await
494}
495
496async fn run_scenario(args: &DemoArgs, scenario: Scenario) -> i32 {
497 let staged = match stage_scenario(scenario) {
498 Ok(s) => s,
499 Err(error) => {
500 eprintln!("error: {error}");
501 return 1;
502 }
503 };
504
505 if !args.json {
506 println!("=== harn demo · {} ===", scenario.id);
507 println!("{}", scenario.title);
508 println!();
509 if !args.live {
510 println!("(offline replay — no API keys required)");
511 println!();
512 }
513 }
514
515 let llm_mock_mode = if args.live {
516 if !args.json {
517 println!("(--live: routing through the configured provider — set HARN_LLM_PROVIDER if none is wired)");
518 println!();
519 }
520 CliLlmMockMode::Off
521 } else {
522 CliLlmMockMode::Replay {
523 fixture_path: staged.tape_path.clone(),
524 }
525 };
526
527 let sandbox = RunSandboxOptions {
535 workspace_root: Some(staged.root.clone()),
536 ..RunSandboxOptions::default()
537 };
538
539 let started = Instant::now();
540 let outcome = {
541 let _cwd = ScopedCwd::enter(&staged.root);
542 execute_run_with_sandbox_options(
543 staged.script_path.to_string_lossy().as_ref(),
544 false,
545 HashSet::new(),
546 Vec::new(),
547 Vec::new(),
548 llm_mock_mode,
549 None,
550 RunProfileOptions::default(),
551 sandbox,
552 )
553 .await
554 };
555 let elapsed = started.elapsed();
556
557 if !args.json && !outcome.stdout.is_empty() {
558 print!("{}", outcome.stdout);
559 }
560 if !outcome.stderr.is_empty() {
561 eprint!("{}", outcome.stderr);
562 }
563
564 if outcome.exit_code != 0 {
565 if !args.json {
566 eprintln!(
567 "error: demo `{}` failed (exit {})",
568 scenario.id, outcome.exit_code
569 );
570 if args.live && live_failure_looks_like_provider_misconfig(&outcome) {
571 eprintln!();
572 eprintln!("hint: --live needs a configured LLM provider. Re-run without --live");
573 eprintln!(" to use the bundled offline tape, or run `harn quickstart`");
574 eprintln!(" to wire a provider.");
575 }
576 } else {
577 print_json_summary(scenario, &outcome, elapsed.as_millis(), None);
578 }
579 return outcome.exit_code;
580 }
581
582 let receipt_dir = if args.no_record {
583 None
584 } else {
585 match write_run_record(scenario, &outcome) {
586 Ok(path) => Some(path),
587 Err(error) => {
588 eprintln!("warning: failed to write demo run record: {error}");
589 None
590 }
591 }
592 };
593
594 if args.json {
595 print_json_summary(
596 scenario,
597 &outcome,
598 elapsed.as_millis(),
599 receipt_dir.as_deref(),
600 );
601 } else {
602 println!();
603 println!("--- demo complete in {} ms ---", elapsed.as_millis());
604 if let Some(dir) = &receipt_dir {
605 println!(" run record: {}", dir.join("run.json").display());
606 }
607 println!();
608 println!("Next steps:");
609 println!(" harn demo --list list every bundled scenario");
610 if !args.live {
611 println!(
612 " harn demo {} --live run again against the configured provider",
613 scenario.id
614 );
615 }
616 println!(" harn portal browse run records in the UI");
617 }
618 0
619}
620
621struct StagedScenario {
622 _temp_root: tempfile::TempDir,
623 script_path: PathBuf,
624 tape_path: PathBuf,
625 root: PathBuf,
630}
631
632struct ScopedCwd {
638 previous: Option<PathBuf>,
639}
640
641impl ScopedCwd {
642 fn enter(dir: &Path) -> Self {
643 let previous = std::env::current_dir().ok();
644 if let Err(error) = std::env::set_current_dir(dir) {
645 eprintln!(
646 "warning: failed to enter demo working dir {}: {error}",
647 dir.display()
648 );
649 }
650 Self { previous }
651 }
652}
653
654impl Drop for ScopedCwd {
655 fn drop(&mut self) {
656 if let Some(previous) = self.previous.take() {
657 let _ = std::env::set_current_dir(previous);
658 }
659 }
660}
661
662fn stage_scenario(scenario: Scenario) -> Result<StagedScenario, String> {
663 let dir = tempfile::Builder::new()
664 .prefix(&format!("harn-demo-{}-", scenario.id))
665 .tempdir()
666 .map_err(|e| format!("failed to create demo tempdir: {e}"))?;
667 let script_path = dir.path().join(format!("{}.harn", scenario.id));
668 let tape_path = dir.path().join(format!("{}.tape.jsonl", scenario.id));
669 fs::write(&script_path, scenario.script)
670 .map_err(|e| format!("failed to stage demo script: {e}"))?;
671 fs::write(&tape_path, scenario.tape).map_err(|e| format!("failed to stage demo tape: {e}"))?;
672
673 let prefix = format!("{}/", scenario.id);
678 for (rel, bytes) in DEMO_SIBLING_FILES {
679 let Some(sub) = rel.strip_prefix(&prefix) else {
680 continue;
681 };
682 let dest = dir.path().join(sub);
683 if let Some(parent) = dest.parent() {
684 fs::create_dir_all(parent).map_err(|e| {
685 format!("failed to create demo asset dir {}: {e}", parent.display())
686 })?;
687 }
688 fs::write(&dest, bytes).map_err(|e| format!("failed to stage demo asset {sub}: {e}"))?;
689 }
690
691 let root = dir.path().to_path_buf();
692 Ok(StagedScenario {
693 _temp_root: dir,
694 script_path,
695 tape_path,
696 root,
697 })
698}
699
700fn write_run_record(scenario: Scenario, outcome: &RunOutcome) -> Result<PathBuf, String> {
701 let cwd = std::env::current_dir().map_err(|e| format!("cwd: {e}"))?;
702 let runs_root = cwd.join(".harn-runs");
703 let ts = SystemTime::now()
704 .duration_since(UNIX_EPOCH)
705 .map(|d| d.as_secs())
706 .unwrap_or(0);
707 let started_iso = time::OffsetDateTime::from_unix_timestamp(ts as i64)
708 .ok()
709 .and_then(|t| {
710 t.format(&time::format_description::well_known::Rfc3339)
711 .ok()
712 })
713 .unwrap_or_else(|| format!("1970-01-01T00:00:{ts:02}Z"));
714 let dir = runs_root.join(format!("demo-{}-{ts}", scenario.id));
715 fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
716 let record = serde_json::json!({
721 "_type": "run_record",
722 "id": format!("demo-{}-{ts}", scenario.id),
723 "workflow_id": format!("harn-demo:{}", scenario.id),
724 "workflow_name": scenario.title,
725 "task": scenario.id,
726 "status": if outcome.exit_code == 0 { "complete" } else { "failed" },
727 "started_at": started_iso,
728 "finished_at": started_iso,
729 "stages": [],
730 "transitions": [],
731 "checkpoints": [],
732 "pending_nodes": [],
733 "completed_nodes": [],
734 "child_runs": [],
735 "artifacts": [],
736 "policy": {},
737 "metadata": {
738 "demo": {
739 "scenario": scenario.id,
740 "title": scenario.title,
741 "description": scenario.description,
742 "exit_code": outcome.exit_code,
743 "stdout": outcome.stdout,
744 "stderr": outcome.stderr,
745 "recorded_at_unix_seconds": ts,
746 }
747 },
748 });
749 let path = dir.join("run.json");
750 fs::write(
751 &path,
752 serde_json::to_string_pretty(&record).unwrap_or_default(),
753 )
754 .map_err(|e| format!("write {}: {e}", path.display()))?;
755 Ok(dir)
756}
757
758fn live_failure_looks_like_provider_misconfig(outcome: &RunOutcome) -> bool {
759 let blob = format!("{}{}", outcome.stderr, outcome.stdout);
764 blob.contains("category: auth")
765 || blob.contains("auth_failure")
766 || blob.contains("HTTP 401")
767 || blob.contains("HTTP 403")
768 || blob.contains("api_key")
769 || blob.contains("HARN_LLM_PROVIDER")
770 || blob.contains("no provider configured")
771}
772
773fn print_json_summary(
774 scenario: Scenario,
775 outcome: &RunOutcome,
776 elapsed_ms: u128,
777 record_dir: Option<&Path>,
778) {
779 let record = serde_json::json!({
780 "scenario": scenario.id,
781 "title": scenario.title,
782 "exit_code": outcome.exit_code,
783 "elapsed_ms": elapsed_ms,
784 "stdout": outcome.stdout,
785 "stderr": outcome.stderr,
786 "run_record_dir": record_dir.map(|p| p.display().to_string()),
787 });
788 println!(
789 "{}",
790 serde_json::to_string_pretty(&record).unwrap_or_default()
791 );
792}
793
794#[cfg(test)]
795mod tests {
796 use super::*;
797
798 #[test]
799 fn wrap_text_budgets_columns_not_bytes_or_chars() {
800 let wrapped = wrap_text("日本 語で すね", 9);
805 assert_eq!(wrapped, vec!["日本 語で".to_string(), "すね".to_string()]);
806 }
807
808 #[test]
809 fn wrap_text_treats_emoji_as_double_width() {
810 assert_eq!(
813 wrap_text("ab 🚀🚀", 6),
814 vec!["ab".to_string(), "🚀🚀".to_string()]
815 );
816 }
817
818 #[test]
819 fn wrap_text_is_unchanged_for_ascii() {
820 assert_eq!(
821 wrap_text("the quick brown fox", 9),
822 vec!["the quick".to_string(), "brown fox".to_string()]
823 );
824 }
825
826 #[test]
827 fn bare_demo_action_covers_every_stdio_shape() {
828 let cases = [
829 (false, true, true, NoScenarioAction::Prompt),
830 (false, true, false, NoScenarioAction::ListTable),
831 (false, false, true, NoScenarioAction::ListTable),
832 (false, false, false, NoScenarioAction::ListTable),
833 (true, true, true, NoScenarioAction::ListJson),
834 (true, true, false, NoScenarioAction::ListJson),
835 (true, false, true, NoScenarioAction::ListJson),
836 (true, false, false, NoScenarioAction::ListJson),
837 ];
838 for (json, stdout_tty, stdin_tty, expected) in cases {
839 assert_eq!(
840 no_scenario_action(json, stdout_tty, stdin_tty),
841 expected,
842 "json={json} stdout_tty={stdout_tty} stdin_tty={stdin_tty}"
843 );
844 }
845 }
846
847 #[test]
848 fn scenarios_have_unique_nonempty_ids() {
849 let mut seen = HashSet::new();
850 for s in SCENARIOS {
851 assert!(!s.id.is_empty(), "scenario id is empty");
852 assert!(!s.title.is_empty(), "scenario {} has empty title", s.id);
853 assert!(
854 !s.description.is_empty(),
855 "scenario {} has empty description",
856 s.id
857 );
858 assert!(!s.script.is_empty(), "scenario {} script is empty", s.id);
859 assert!(!s.tape.is_empty(), "scenario {} tape is empty", s.id);
860 assert!(seen.insert(s.id), "duplicate scenario id: {}", s.id);
861 }
862 }
863
864 #[test]
865 fn review_captain_prompt_templates_are_embedded() {
866 for name in [
871 "review-captain/review.harn.prompt",
872 "review-captain/clarification.harn.prompt",
873 ] {
874 let found = DEMO_SIBLING_FILES
875 .iter()
876 .find(|(rel, _)| *rel == name)
877 .unwrap_or_else(|| panic!("demo sibling asset `{name}` was not embedded"));
878 assert!(!found.1.is_empty(), "embedded demo asset `{name}` is empty");
879 }
880 }
881
882 #[test]
883 fn embedded_sibling_files_exclude_primary_and_runrecord_files() {
884 for (rel, _) in DEMO_SIBLING_FILES {
887 assert!(
888 !rel.ends_with("/scenario.harn") && !rel.ends_with("/tape.jsonl"),
889 "primary file `{rel}` should not be in the sibling table"
890 );
891 assert!(
892 !rel.contains("/.harn/"),
893 "run-record file `{rel}` should not be embedded"
894 );
895 }
896 }
897
898 #[test]
899 fn scenario_tape_lines_parse_as_json() {
900 for s in SCENARIOS {
901 for (i, line) in s.tape.lines().enumerate() {
902 if line.trim().is_empty() {
903 continue;
904 }
905 serde_json::from_str::<serde_json::Value>(line).unwrap_or_else(|e| {
906 panic!("scenario {} tape line {} is invalid JSON: {e}", s.id, i + 1)
907 });
908 }
909 }
910 }
911
912 #[test]
913 fn scenario_ids_match_assets_dir_names() {
914 let manifest_dir = env!("CARGO_MANIFEST_DIR");
921 let assets = std::path::Path::new(manifest_dir).join("assets/demo");
922 for s in SCENARIOS {
923 let dir = assets.join(s.id);
924 assert!(dir.is_dir(), "missing demo asset dir for {}", s.id);
925 assert!(
926 dir.join("scenario.harn").is_file(),
927 "missing scenario.harn for {}",
928 s.id
929 );
930 assert!(
931 dir.join("tape.jsonl").is_file(),
932 "missing tape.jsonl for {}",
933 s.id
934 );
935 }
936 }
937}