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#[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
141fn 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 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}