Skip to main content

runx_cli/
history.rs

1use std::collections::BTreeMap;
2use std::ffi::OsString;
3use std::fmt;
4use std::path::{Path, PathBuf};
5
6use crate::cli_args;
7use runx_runtime::journal::{
8    HistoryFilter, JournalProjectionError, list_local_history, list_local_history_with_policy,
9};
10use runx_runtime::{
11    Ed25519ReceiptVerifier, LocalReceiptStore, ReceiptPathInputs, RuntimeReceiptConfig,
12    RuntimeReceiptSignaturePolicy, resolve_receipt_path,
13};
14
15// rust-style-allow: large-file because the native history CLI slice keeps
16// parsing, rendering, and CLI parity tests together until the rest of the Rust
17// command routing settles.
18#[derive(Debug)]
19pub enum HistoryCliError {
20    InvalidArgs(String),
21    InvalidReceiptVerifier(String),
22    Projection(JournalProjectionError),
23    Serialize(serde_json::Error),
24}
25
26impl fmt::Display for HistoryCliError {
27    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
28        match self {
29            Self::InvalidArgs(message) => formatter.write_str(message),
30            Self::InvalidReceiptVerifier(message) => formatter.write_str(message),
31            Self::Projection(error) => write!(formatter, "{error}"),
32            Self::Serialize(error) => write!(formatter, "failed to serialize history: {error}"),
33        }
34    }
35}
36
37impl std::error::Error for HistoryCliError {}
38
39#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct HistoryCliResult {
41    pub output: String,
42    pub error_is_usage: bool,
43}
44
45#[derive(Clone, Debug, Default, PartialEq, Eq)]
46struct ParsedHistoryArgs {
47    receipt_dir: Option<PathBuf>,
48    query: Option<String>,
49    filter: HistoryFilter,
50    json: bool,
51}
52
53pub fn run_history_command(
54    args: &[OsString],
55    env: &BTreeMap<String, String>,
56    cwd: &Path,
57) -> Result<HistoryCliResult, HistoryCliError> {
58    let parsed = parse_history_args(args)?;
59    let receipt_config = RuntimeReceiptConfig::default();
60    let resolved = resolve_receipt_path(ReceiptPathInputs {
61        explicit_dir: parsed.receipt_dir.as_deref(),
62        runtime_config: Some(&receipt_config),
63        env,
64        cwd,
65    });
66    let store = LocalReceiptStore::new(&resolved.path);
67    let verifier = history_production_verifier(env)?;
68    let history = if let Some(verifier) = verifier.as_ref() {
69        list_local_history_with_policy(
70            &store,
71            &resolved.workspace_base,
72            &resolved.project_runx_dir,
73            &parsed.filter,
74            RuntimeReceiptSignaturePolicy::production(verifier),
75        )
76    } else {
77        list_local_history(
78            &store,
79            &resolved.workspace_base,
80            &resolved.project_runx_dir,
81            &parsed.filter,
82        )
83    }
84    .map_err(HistoryCliError::Projection)?;
85    let output = if parsed.json {
86        format!(
87            "{}\n",
88            serde_json::to_string_pretty(&history).map_err(HistoryCliError::Serialize)?
89        )
90    } else {
91        render_history(
92            &history,
93            parsed.query.as_deref(),
94            parsed.receipt_dir.as_deref(),
95        )
96    };
97    Ok(HistoryCliResult {
98        output,
99        error_is_usage: false,
100    })
101}
102
103pub(crate) const RUNX_RECEIPT_VERIFY_KID_ENV: &str = "RUNX_RECEIPT_VERIFY_KID";
104pub(crate) const RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64_ENV: &str =
105    "RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64";
106
107fn history_production_verifier(
108    env: &BTreeMap<String, String>,
109) -> Result<Option<Ed25519ReceiptVerifier>, HistoryCliError> {
110    let kid = non_empty_env(env, RUNX_RECEIPT_VERIFY_KID_ENV);
111    let public_key = non_empty_env(env, RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64_ENV);
112    match (kid, public_key) {
113        (None, None) => Ok(None),
114        (Some(kid), Some(public_key)) => Ed25519ReceiptVerifier::from_public_key_base64(
115            kid.to_owned(),
116            public_key,
117        )
118        .map(Some)
119        .map_err(|_| {
120            HistoryCliError::InvalidReceiptVerifier(format!(
121                "{RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64_ENV} is not valid Ed25519 public key material"
122            ))
123        }),
124        _ => Err(HistoryCliError::InvalidReceiptVerifier(format!(
125            "{RUNX_RECEIPT_VERIFY_KID_ENV} and {RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64_ENV} must be set together"
126        ))),
127    }
128}
129
130fn non_empty_env<'a>(env: &'a BTreeMap<String, String>, key: &str) -> Option<&'a str> {
131    env.get(key)
132        .map(String::as_str)
133        .map(str::trim)
134        .filter(|value| !value.is_empty())
135}
136
137pub fn env_map() -> BTreeMap<String, String> {
138    crate::cli_io::env_map()
139}
140
141// rust-style-allow: long-function because this mirrors the public history CLI
142// flag grammar in one parser during the hard cutover.
143fn parse_history_args(args: &[OsString]) -> Result<ParsedHistoryArgs, HistoryCliError> {
144    if args.first().and_then(|arg| arg.to_str()) != Some("history") {
145        return Err(HistoryCliError::InvalidArgs(
146            "internal error: history dispatcher received non-history command".to_owned(),
147        ));
148    }
149    let mut parsed = ParsedHistoryArgs::default();
150    let mut positionals = Vec::new();
151    let mut index = 1;
152    while index < args.len() {
153        let token = cli_args::os_arg(args, index, "history").map_err(invalid_args)?;
154        if !token.starts_with("--") {
155            positionals.push(token.to_owned());
156            index += 1;
157            continue;
158        }
159        let (flag, inline_value) = cli_args::split_flag(token);
160        match flag {
161            "--json" => {
162                if inline_value.is_some() {
163                    return Err(invalid_args("--json does not take a value"));
164                }
165                parsed.json = true;
166                index += 1;
167            }
168            "--receipt-dir" => {
169                let (value, next_index) =
170                    cli_args::flag_value(args, index, flag, inline_value, "history")
171                        .map_err(invalid_args)?;
172                parsed.receipt_dir = Some(PathBuf::from(value));
173                index = next_index;
174            }
175            "--skill" => {
176                let (value, next_index) =
177                    cli_args::flag_value(args, index, flag, inline_value, "history")
178                        .map_err(invalid_args)?;
179                parsed.filter.skill = Some(value);
180                index = next_index;
181            }
182            "--status" => {
183                let (value, next_index) =
184                    cli_args::flag_value(args, index, flag, inline_value, "history")
185                        .map_err(invalid_args)?;
186                parsed.filter.status = Some(value);
187                index = next_index;
188            }
189            "--source" => {
190                let (value, next_index) =
191                    cli_args::flag_value(args, index, flag, inline_value, "history")
192                        .map_err(invalid_args)?;
193                parsed.filter.source = Some(value);
194                index = next_index;
195            }
196            "--actor" => {
197                let (value, next_index) =
198                    cli_args::flag_value(args, index, flag, inline_value, "history")
199                        .map_err(invalid_args)?;
200                parsed.filter.actor = Some(value);
201                index = next_index;
202            }
203            "--artifact-type" | "--artifact_type" | "--artifactType" => {
204                let (value, next_index) =
205                    cli_args::flag_value(args, index, flag, inline_value, "history")
206                        .map_err(invalid_args)?;
207                parsed.filter.artifact_type = Some(value);
208                index = next_index;
209            }
210            "--since" => {
211                let (value, next_index) =
212                    cli_args::flag_value(args, index, flag, inline_value, "history")
213                        .map_err(invalid_args)?;
214                parsed.filter.since = Some(value);
215                index = next_index;
216            }
217            "--until" => {
218                let (value, next_index) =
219                    cli_args::flag_value(args, index, flag, inline_value, "history")
220                        .map_err(invalid_args)?;
221                parsed.filter.until = Some(value);
222                index = next_index;
223            }
224            "--limit" => {
225                let (value, next_index) =
226                    cli_args::flag_value(args, index, flag, inline_value, "history")
227                        .map_err(invalid_args)?;
228                parsed.filter.limit = Some(value.parse().map_err(|_| {
229                    HistoryCliError::InvalidArgs(format!("invalid --limit value '{value}'"))
230                })?);
231                index = next_index;
232            }
233            _ => {
234                return Err(HistoryCliError::InvalidArgs(format!(
235                    "unknown history flag {flag}"
236                )));
237            }
238        }
239    }
240    parsed.query = (!positionals.is_empty()).then(|| positionals.join(" "));
241    parsed.filter.query = parsed.query.clone();
242    Ok(parsed)
243}
244
245fn render_history(
246    history: &runx_runtime::journal::LocalHistoryProjection,
247    query: Option<&str>,
248    receipt_dir: Option<&Path>,
249) -> String {
250    let total = history.receipts.len() + history.pending_runs.len();
251    if total == 0 {
252        if let Some(query) = query {
253            return format!(
254                "\n  No receipts matched {query}.\n  Try runx history to see every local run.\n\n"
255            );
256        }
257        return "\n  No receipts yet. Try a run first:\n  runx skill <skill-dir> --json\n  runx harness <fixture.yaml> --json\n\n"
258            .to_owned();
259    }
260    let mut lines = Vec::new();
261    lines.push(String::new());
262    lines.push(history_header(history));
263    lines.push(String::new());
264    for pending in &history.pending_runs {
265        push_pending_run_lines(&mut lines, pending, receipt_dir);
266    }
267    for receipt in &history.receipts {
268        push_receipt_line(&mut lines, receipt);
269    }
270    lines.push(String::new());
271    lines.push(history_next_line(history));
272    lines.push(String::new());
273    lines.join("\n")
274}
275
276fn history_header(history: &runx_runtime::journal::LocalHistoryProjection) -> String {
277    if history.pending_runs.is_empty() {
278        format!("  history  {} receipt(s)", history.receipts.len())
279    } else {
280        format!(
281            "  history  {} receipt(s), {} needs_agent",
282            history.receipts.len(),
283            history.pending_runs.len()
284        )
285    }
286}
287
288fn push_pending_run_lines(
289    lines: &mut Vec<String>,
290    pending: &runx_runtime::journal::PausedRunSummary,
291    receipt_dir: Option<&Path>,
292) {
293    let step = pending
294        .step_labels
295        .first()
296        .or_else(|| pending.step_ids.first())
297        .map_or("", String::as_str);
298    lines.push(format!(
299        "  *  {}  needs_agent  {}  {}",
300        pending.name,
301        step,
302        short_id(&pending.id)
303    ));
304    if let Some(resume_skill_ref) = pending.resume_skill_ref.as_deref() {
305        let resume_command =
306            crate::resume::render_skill_resume_command(crate::resume::SkillResumeCommand {
307                skill_ref: Some(resume_skill_ref),
308                run_id: &pending.id,
309                selected_runner: pending.selected_runner.as_deref(),
310                receipt_dir,
311                answers_path: None,
312            });
313        lines.push(format!("     next  {resume_command}"));
314    } else {
315        lines.push(format!(
316            "     next  write answers.json, then resume the pending run with runx resume {} answers.json",
317            pending.id
318        ));
319    }
320}
321
322fn push_receipt_line(
323    lines: &mut Vec<String>,
324    receipt: &runx_runtime::journal::LocalHistoryReceipt,
325) {
326    lines.push(format!(
327        "  {}  {}  {}  {}",
328        receipt.status,
329        receipt.name,
330        receipt.verification.status,
331        short_id(&receipt.id)
332    ));
333}
334
335fn history_next_line(history: &runx_runtime::journal::LocalHistoryProjection) -> String {
336    if history.pending_runs.is_empty() {
337        "  next  runx history <receipt-id> --json".to_owned()
338    } else if history
339        .pending_runs
340        .iter()
341        .any(|run| run.resume_skill_ref.is_some())
342    {
343        "  next  write answers.json, then rerun one of the commands above".to_owned()
344    } else {
345        "  next  write answers.json, then rerun the original skill with the shown run id".to_owned()
346    }
347}
348
349fn short_id(value: &str) -> &str {
350    value.get(..12).unwrap_or(value)
351}
352
353fn invalid_args(message: impl Into<String>) -> HistoryCliError {
354    HistoryCliError::InvalidArgs(message.into())
355}
356
357#[cfg(test)]
358mod tests {
359    use std::fs;
360    use std::io;
361
362    use super::*;
363    use runx_contracts::ReceiptIssuerType;
364    use runx_runtime::receipts::step_receipt_with_signature_policy;
365    use runx_runtime::{Ed25519ReceiptSigner, InvocationStatus, RuntimeError, SkillOutput};
366
367    #[test]
368    fn parses_history_args_without_comparing_against_runtime_constants() -> Result<(), io::Error> {
369        let parsed = parse_history_args(&[
370            "history".into(),
371            "sourcey".into(),
372            "--skill".into(),
373            "source".into(),
374            "--status=needs_agent".into(),
375            "--artifact-type".into(),
376            "artifact".into(),
377            "--json".into(),
378        ])
379        .map_err(|error| io::Error::other(error.to_string()))?;
380
381        assert_eq!(parsed.query.as_deref(), Some("sourcey"));
382        assert_eq!(parsed.filter.skill.as_deref(), Some("source"));
383        assert_eq!(parsed.filter.status.as_deref(), Some("needs_agent"));
384        assert_eq!(parsed.filter.artifact_type.as_deref(), Some("artifact"));
385        assert!(parsed.json);
386        Ok(())
387    }
388
389    #[test]
390    // rust-style-allow: long-function because the CLI execute oracle test keeps
391    // its ledger fixture, command invocation, and typed output assertions in
392    // one place so the parity case remains readable.
393    fn executes_history_json_against_cli_parity_oracle() -> Result<(), io::Error> {
394        let temp = tempfile_dir()?;
395        let receipt_dir = temp.join("receipts");
396        write_needs_agent_ledger(&receipt_dir)?;
397        let oracle: CliParityOracle = serde_json::from_str(include_str!(
398            "../../../fixtures/cli-parity/cases/oracle.json"
399        ))
400        .map_err(|error| io::Error::other(error.to_string()))?;
401        let execute_case = oracle
402            .cases
403            .iter()
404            .find(|case| case.id == "history.execute")
405            .ok_or_else(|| {
406                io::Error::new(
407                    io::ErrorKind::InvalidData,
408                    "missing history.execute oracle case",
409                )
410            })?;
411
412        let mut env = BTreeMap::new();
413        env.insert("RUNX_CWD".to_owned(), temp.to_string_lossy().to_string());
414        let result = run_history_command(
415            &[
416                "history".into(),
417                "--receipt-dir".into(),
418                receipt_dir.into_os_string(),
419                "--json".into(),
420            ],
421            &env,
422            &temp,
423        )
424        .map_err(|error| io::Error::other(error.to_string()))?;
425        let output: HistoryOutput = serde_json::from_str(&result.output)
426            .map_err(|error| io::Error::other(error.to_string()))?;
427        let first_pending_run = output.pending_runs.first().ok_or_else(|| {
428            io::Error::new(
429                io::ErrorKind::InvalidData,
430                "history output has no pending run",
431            )
432        })?;
433
434        assert_eq!(
435            output.pending_runs.len(),
436            execute_case.expect.pending_runs as usize
437        );
438        assert_eq!(
439            first_pending_run.id,
440            execute_case.expect.first_pending_run_id
441        );
442        assert_eq!(
443            first_pending_run.status,
444            execute_case.expect.first_pending_run_status
445        );
446        assert_eq!(
447            first_pending_run.selected_runner,
448            Some("agent-task".to_owned())
449        );
450        assert_eq!(
451            first_pending_run.resume_skill_ref,
452            Some("../skills/sourcey".to_owned())
453        );
454        Ok(())
455    }
456
457    #[test]
458    fn history_human_pending_run_includes_resume_command() -> Result<(), io::Error> {
459        let temp = tempfile_dir()?;
460        let receipt_dir = temp.join("receipts");
461        write_needs_agent_ledger(&receipt_dir)?;
462
463        let mut env = BTreeMap::new();
464        env.insert("RUNX_CWD".to_owned(), temp.to_string_lossy().to_string());
465        let result = run_history_command(
466            &[
467                "history".into(),
468                "--receipt-dir".into(),
469                receipt_dir.clone().into_os_string(),
470            ],
471            &env,
472            &temp,
473        )
474        .map_err(|error| io::Error::other(error.to_string()))?;
475
476        let receipt_dir_arg = receipt_dir.to_string_lossy();
477        assert!(
478            result
479                .output
480                .contains("next  runx resume gx_needs_agent_oracle answers.json")
481        );
482        assert!(
483            result
484                .output
485                .contains(&format!("--receipt-dir {}", receipt_dir_arg))
486        );
487        assert!(
488            result
489                .output
490                .contains("write answers.json, then rerun one of the commands above")
491        );
492        Ok(())
493    }
494
495    #[test]
496    fn history_human_pending_run_omits_default_receipt_dir_from_resume_command()
497    -> Result<(), io::Error> {
498        let temp = tempfile_dir()?;
499        let receipt_dir = temp.join(".runx").join("receipts");
500        write_needs_agent_ledger(&receipt_dir)?;
501
502        let mut env = BTreeMap::new();
503        env.insert("RUNX_CWD".to_owned(), temp.to_string_lossy().to_string());
504        let result = run_history_command(&["history".into()], &env, &temp)
505            .map_err(|error| io::Error::other(error.to_string()))?;
506
507        assert!(
508            result
509                .output
510                .contains("next  runx resume gx_needs_agent_oracle answers.json")
511        );
512        assert!(
513            !result.output.contains("--receipt-dir"),
514            "default receipt dir must not be echoed into resume commands:\n{}",
515            result.output
516        );
517        Ok(())
518    }
519
520    #[test]
521    fn history_human_pending_run_does_not_invent_resume_command() -> Result<(), io::Error> {
522        let temp = tempfile_dir()?;
523        let receipt_dir = temp.join("receipts");
524        write_needs_agent_ledger_with_resume(&receipt_dir, None)?;
525
526        let mut env = BTreeMap::new();
527        env.insert("RUNX_CWD".to_owned(), temp.to_string_lossy().to_string());
528        let result = run_history_command(
529            &[
530                "history".into(),
531                "--receipt-dir".into(),
532                receipt_dir.into_os_string(),
533            ],
534            &env,
535            &temp,
536        )
537        .map_err(|error| io::Error::other(error.to_string()))?;
538
539        assert!(!result.output.contains("runx skill sourcey"));
540        assert!(
541            result
542                .output
543                .contains("runx resume gx_needs_agent_oracle answers.json"),
544            "history output should give non-fabricated continuation guidance:\n{}",
545            result.output
546        );
547        Ok(())
548    }
549
550    #[test]
551    fn history_json_reports_production_verified_receipts_when_verifier_env_is_configured()
552    -> Result<(), io::Error> {
553        let temp = tempfile_dir()?;
554        let receipt_dir = temp.join("receipts");
555        let signer = fixture_signer().map_err(|error| io::Error::other(error.to_string()))?;
556        let receipt = production_signed_receipt(&signer)
557            .map_err(|error| io::Error::other(error.to_string()))?;
558        let store = LocalReceiptStore::new(&receipt_dir);
559        let verifier = Ed25519ReceiptVerifier::new([signer.production_key()]);
560        store
561            .write_receipt_with_policy(
562                &receipt,
563                RuntimeReceiptSignaturePolicy::production(&verifier),
564            )
565            .map_err(|error| io::Error::other(error.to_string()))?;
566
567        let mut env = BTreeMap::new();
568        env.insert("RUNX_CWD".to_owned(), temp.to_string_lossy().to_string());
569        env.insert(
570            RUNX_RECEIPT_VERIFY_KID_ENV.to_owned(),
571            FIXTURE_KID.to_owned(),
572        );
573        env.insert(
574            RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64_ENV.to_owned(),
575            base64_standard(signer.public_key()),
576        );
577
578        let result = run_history_command(
579            &[
580                "history".into(),
581                receipt.id.to_string().into(),
582                "--receipt-dir".into(),
583                receipt_dir.into_os_string(),
584                "--json".into(),
585            ],
586            &env,
587            &temp,
588        )
589        .map_err(|error| io::Error::other(error.to_string()))?;
590        let output: HistoryOutput = serde_json::from_str(&result.output)
591            .map_err(|error| io::Error::other(error.to_string()))?;
592        let first_receipt = output.receipts.first().ok_or_else(|| {
593            io::Error::new(io::ErrorKind::InvalidData, "history output has no receipt")
594        })?;
595
596        assert_eq!(first_receipt.id, receipt.id.to_string());
597        assert_eq!(first_receipt.verification.status, "verified");
598        Ok(())
599    }
600
601    #[derive(serde::Deserialize)]
602    struct CliParityOracle {
603        cases: Vec<CliParityCase>,
604    }
605
606    #[derive(serde::Deserialize)]
607    struct CliParityCase {
608        id: String,
609        #[serde(default)]
610        expect: CliParityExpectation,
611    }
612
613    #[derive(Default, serde::Deserialize)]
614    #[serde(rename_all = "camelCase")]
615    struct CliParityExpectation {
616        #[serde(default)]
617        pending_runs: u64,
618        #[serde(default)]
619        first_pending_run_id: String,
620        #[serde(default)]
621        first_pending_run_status: String,
622    }
623
624    #[derive(serde::Deserialize)]
625    #[serde(rename_all = "camelCase")]
626    struct HistoryOutput {
627        receipts: Vec<HistoryReceipt>,
628        pending_runs: Vec<HistoryPendingRun>,
629    }
630
631    #[derive(serde::Deserialize)]
632    struct HistoryReceipt {
633        id: String,
634        verification: HistoryReceiptVerification,
635    }
636
637    #[derive(serde::Deserialize)]
638    struct HistoryReceiptVerification {
639        status: String,
640    }
641
642    #[derive(serde::Deserialize)]
643    #[serde(rename_all = "camelCase")]
644    struct HistoryPendingRun {
645        id: String,
646        status: String,
647        resume_skill_ref: Option<String>,
648        selected_runner: Option<String>,
649    }
650
651    fn write_needs_agent_ledger(receipt_dir: &Path) -> Result<(), io::Error> {
652        write_needs_agent_ledger_with_resume(receipt_dir, Some("../skills/sourcey"))
653    }
654
655    fn write_needs_agent_ledger_with_resume(
656        receipt_dir: &Path,
657        resume_skill_ref: Option<&str>,
658    ) -> Result<(), io::Error> {
659        fs::create_dir_all(receipt_dir.join("ledgers"))?;
660        fs::write(
661            receipt_dir
662                .join("ledgers")
663                .join("gx_needs_agent_oracle.jsonl"),
664            format!(
665                "{}\n{}\n",
666                needs_agent_started_record(),
667                needs_agent_waiting_record(resume_skill_ref)
668            ),
669        )
670    }
671
672    fn needs_agent_started_record() -> &'static str {
673        r#"{"entry":{"type":"run_event","version":"1","data":{"kind":"run_started","status":"started","step_id":null,"detail":{}},"meta":{"artifact_id":"ax_start","run_id":"gx_needs_agent_oracle","step_id":null,"producer":{"skill":"sourcey","runner":"graph"},"created_at":"2026-04-28T01:00:00.000Z","hash":"sha256:start","size_bytes":2,"parent_artifact_id":null,"receipt_id":null,"redacted":false}}}"#
674    }
675
676    fn needs_agent_waiting_record(resume_skill_ref: Option<&str>) -> String {
677        format!(
678            r#"{{"entry":{{"type":"run_event","version":"1","data":{{"kind":"step_waiting_resolution","status":"waiting","step_id":"discover","detail":{{"request_ids":["agent_task.test-step.output"],"resolution_kinds":["agent_act"],"step_ids":["discover"],"step_labels":["inspect repo"],"inputs":{{}},"selected_runner":"agent-task"{}}}}},"meta":{{"artifact_id":"ax_wait","run_id":"gx_needs_agent_oracle","step_id":"discover","producer":{{"skill":"sourcey","runner":"graph"}},"created_at":"2026-04-28T01:00:00.000Z","hash":"sha256:wait","size_bytes":2,"parent_artifact_id":null,"receipt_id":null,"redacted":false}}}}}}"#,
679            resume_skill_ref_fragment(resume_skill_ref)
680        )
681    }
682
683    fn resume_skill_ref_fragment(resume_skill_ref: Option<&str>) -> String {
684        resume_skill_ref
685            .map(|value| format!(r#","resume_skill_ref":"{value}""#))
686            .unwrap_or_default()
687    }
688
689    fn tempfile_dir() -> Result<PathBuf, io::Error> {
690        let path = std::env::temp_dir().join(format!(
691            "runx-cli-history-{}-{}",
692            std::process::id(),
693            std::time::SystemTime::now()
694                .duration_since(std::time::UNIX_EPOCH)
695                .map_err(|error| io::Error::other(error.to_string()))?
696                .as_nanos()
697        ));
698        fs::create_dir_all(&path)?;
699        Ok(path)
700    }
701
702    const FIXTURE_KID: &str = "runx-cli-prod-history-fixture-key";
703    const FIXTURE_SEED: [u8; 32] = [0x42; 32];
704
705    fn fixture_signer() -> Result<Ed25519ReceiptSigner, runx_runtime::RuntimeReceiptSigningError> {
706        Ed25519ReceiptSigner::from_seed(FIXTURE_KID, ReceiptIssuerType::Hosted, &FIXTURE_SEED)
707    }
708
709    fn production_signed_receipt(
710        signer: &Ed25519ReceiptSigner,
711    ) -> Result<runx_contracts::Receipt, RuntimeError> {
712        let verifier = Ed25519ReceiptVerifier::new([signer.production_key()]);
713        let output = SkillOutput {
714            status: InvocationStatus::Success,
715            stdout:
716                r#"{"artifact":{"artifact_id":"artifact_cli_history","artifact_type":"artifact"}}"#
717                    .to_owned(),
718            stderr: String::new(),
719            exit_code: Some(0),
720            duration_ms: 10,
721            metadata: BTreeMap::new(),
722        };
723        step_receipt_with_signature_policy(
724            "cli-history",
725            "production-verified",
726            1,
727            &output,
728            "2026-05-25T00:00:00Z",
729            RuntimeReceiptSignaturePolicy::production_signing(signer, &verifier),
730        )
731    }
732
733    fn base64_standard(bytes: &[u8]) -> String {
734        const TABLE: &[u8; 64] =
735            b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
736        let mut encoded = String::with_capacity(bytes.len().div_ceil(3) * 4);
737        for chunk in bytes.chunks(3) {
738            let first = chunk[0];
739            let second = chunk.get(1).copied().unwrap_or(0);
740            let third = chunk.get(2).copied().unwrap_or(0);
741            let combined = ((first as u32) << 16) | ((second as u32) << 8) | third as u32;
742            encoded.push(TABLE[((combined >> 18) & 0x3f) as usize] as char);
743            encoded.push(TABLE[((combined >> 12) & 0x3f) as usize] as char);
744            if chunk.len() > 1 {
745                encoded.push(TABLE[((combined >> 6) & 0x3f) as usize] as char);
746            } else {
747                encoded.push('=');
748            }
749            if chunk.len() > 2 {
750                encoded.push(TABLE[(combined & 0x3f) as usize] as char);
751            } else {
752                encoded.push('=');
753            }
754        }
755        encoded
756    }
757}