use std::collections::BTreeMap;
use std::ffi::OsString;
use std::fmt;
use std::path::{Path, PathBuf};
use crate::cli_args;
use runx_runtime::journal::{
HistoryFilter, JournalProjectionError, list_local_history, list_local_history_with_policy,
};
use runx_runtime::{
Ed25519ReceiptVerifier, LocalReceiptStore, ReceiptPathInputs, RuntimeReceiptConfig,
RuntimeReceiptSignaturePolicy, resolve_receipt_path,
};
// rust-style-allow: large-file because the native history CLI slice keeps
// parsing, rendering, and CLI parity tests together until the rest of the Rust
// command routing settles.
#[derive(Debug)]
pub enum HistoryCliError {
InvalidArgs(String),
InvalidReceiptVerifier(String),
Projection(JournalProjectionError),
Serialize(serde_json::Error),
}
impl fmt::Display for HistoryCliError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidArgs(message) => formatter.write_str(message),
Self::InvalidReceiptVerifier(message) => formatter.write_str(message),
Self::Projection(error) => write!(formatter, "{error}"),
Self::Serialize(error) => write!(formatter, "failed to serialize history: {error}"),
}
}
}
impl std::error::Error for HistoryCliError {}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HistoryCliResult {
pub output: String,
pub error_is_usage: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct ParsedHistoryArgs {
receipt_dir: Option<PathBuf>,
query: Option<String>,
filter: HistoryFilter,
json: bool,
}
pub fn run_history_command(
args: &[OsString],
env: &BTreeMap<String, String>,
cwd: &Path,
) -> Result<HistoryCliResult, HistoryCliError> {
let parsed = parse_history_args(args)?;
let receipt_config = RuntimeReceiptConfig::default();
let resolved = resolve_receipt_path(ReceiptPathInputs {
explicit_dir: parsed.receipt_dir.as_deref(),
runtime_config: Some(&receipt_config),
env,
cwd,
});
let store = LocalReceiptStore::new(&resolved.path);
let verifier = history_production_verifier(env)?;
let history = if let Some(verifier) = verifier.as_ref() {
list_local_history_with_policy(
&store,
&resolved.workspace_base,
&resolved.project_runx_dir,
&parsed.filter,
RuntimeReceiptSignaturePolicy::production(verifier),
)
} else {
list_local_history(
&store,
&resolved.workspace_base,
&resolved.project_runx_dir,
&parsed.filter,
)
}
.map_err(HistoryCliError::Projection)?;
let output = if parsed.json {
format!(
"{}\n",
serde_json::to_string_pretty(&history).map_err(HistoryCliError::Serialize)?
)
} else {
render_history(
&history,
parsed.query.as_deref(),
parsed.receipt_dir.as_deref(),
)
};
Ok(HistoryCliResult {
output,
error_is_usage: false,
})
}
pub(crate) const RUNX_RECEIPT_VERIFY_KID_ENV: &str = "RUNX_RECEIPT_VERIFY_KID";
pub(crate) const RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64_ENV: &str =
"RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64";
fn history_production_verifier(
env: &BTreeMap<String, String>,
) -> Result<Option<Ed25519ReceiptVerifier>, HistoryCliError> {
let kid = non_empty_env(env, RUNX_RECEIPT_VERIFY_KID_ENV);
let public_key = non_empty_env(env, RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64_ENV);
match (kid, public_key) {
(None, None) => Ok(None),
(Some(kid), Some(public_key)) => Ed25519ReceiptVerifier::from_public_key_base64(
kid.to_owned(),
public_key,
)
.map(Some)
.map_err(|_| {
HistoryCliError::InvalidReceiptVerifier(format!(
"{RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64_ENV} is not valid Ed25519 public key material"
))
}),
_ => Err(HistoryCliError::InvalidReceiptVerifier(format!(
"{RUNX_RECEIPT_VERIFY_KID_ENV} and {RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64_ENV} must be set together"
))),
}
}
fn non_empty_env<'a>(env: &'a BTreeMap<String, String>, key: &str) -> Option<&'a str> {
env.get(key)
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
pub fn env_map() -> BTreeMap<String, String> {
crate::cli_io::env_map()
}
// rust-style-allow: long-function because this mirrors the public history CLI
// flag grammar in one parser during the hard cutover.
fn parse_history_args(args: &[OsString]) -> Result<ParsedHistoryArgs, HistoryCliError> {
if args.first().and_then(|arg| arg.to_str()) != Some("history") {
return Err(HistoryCliError::InvalidArgs(
"internal error: history dispatcher received non-history command".to_owned(),
));
}
let mut parsed = ParsedHistoryArgs::default();
let mut positionals = Vec::new();
let mut index = 1;
while index < args.len() {
let token = cli_args::os_arg(args, index, "history").map_err(invalid_args)?;
if !token.starts_with("--") {
positionals.push(token.to_owned());
index += 1;
continue;
}
let (flag, inline_value) = cli_args::split_flag(token);
match flag {
"--json" => {
if inline_value.is_some() {
return Err(invalid_args("--json does not take a value"));
}
parsed.json = true;
index += 1;
}
"--receipt-dir" => {
let (value, next_index) =
cli_args::flag_value(args, index, flag, inline_value, "history")
.map_err(invalid_args)?;
parsed.receipt_dir = Some(PathBuf::from(value));
index = next_index;
}
"--skill" => {
let (value, next_index) =
cli_args::flag_value(args, index, flag, inline_value, "history")
.map_err(invalid_args)?;
parsed.filter.skill = Some(value);
index = next_index;
}
"--status" => {
let (value, next_index) =
cli_args::flag_value(args, index, flag, inline_value, "history")
.map_err(invalid_args)?;
parsed.filter.status = Some(value);
index = next_index;
}
"--source" => {
let (value, next_index) =
cli_args::flag_value(args, index, flag, inline_value, "history")
.map_err(invalid_args)?;
parsed.filter.source = Some(value);
index = next_index;
}
"--actor" => {
let (value, next_index) =
cli_args::flag_value(args, index, flag, inline_value, "history")
.map_err(invalid_args)?;
parsed.filter.actor = Some(value);
index = next_index;
}
"--artifact-type" | "--artifact_type" | "--artifactType" => {
let (value, next_index) =
cli_args::flag_value(args, index, flag, inline_value, "history")
.map_err(invalid_args)?;
parsed.filter.artifact_type = Some(value);
index = next_index;
}
"--since" => {
let (value, next_index) =
cli_args::flag_value(args, index, flag, inline_value, "history")
.map_err(invalid_args)?;
parsed.filter.since = Some(value);
index = next_index;
}
"--until" => {
let (value, next_index) =
cli_args::flag_value(args, index, flag, inline_value, "history")
.map_err(invalid_args)?;
parsed.filter.until = Some(value);
index = next_index;
}
"--limit" => {
let (value, next_index) =
cli_args::flag_value(args, index, flag, inline_value, "history")
.map_err(invalid_args)?;
parsed.filter.limit = Some(value.parse().map_err(|_| {
HistoryCliError::InvalidArgs(format!("invalid --limit value '{value}'"))
})?);
index = next_index;
}
_ => {
return Err(HistoryCliError::InvalidArgs(format!(
"unknown history flag {flag}"
)));
}
}
}
parsed.query = (!positionals.is_empty()).then(|| positionals.join(" "));
parsed.filter.query = parsed.query.clone();
Ok(parsed)
}
fn render_history(
history: &runx_runtime::journal::LocalHistoryProjection,
query: Option<&str>,
receipt_dir: Option<&Path>,
) -> String {
let total = history.receipts.len() + history.pending_runs.len();
if total == 0 {
if let Some(query) = query {
return format!(
"\n No receipts matched {query}.\n Try runx history to see every local run.\n\n"
);
}
return "\n No receipts yet. Try a run first:\n runx skill <skill-dir> --json\n runx harness <fixture.yaml> --json\n\n"
.to_owned();
}
let mut lines = Vec::new();
lines.push(String::new());
lines.push(history_header(history));
lines.push(String::new());
for pending in &history.pending_runs {
push_pending_run_lines(&mut lines, pending, receipt_dir);
}
for receipt in &history.receipts {
push_receipt_line(&mut lines, receipt);
}
lines.push(String::new());
lines.push(history_next_line(history));
lines.push(String::new());
lines.join("\n")
}
fn history_header(history: &runx_runtime::journal::LocalHistoryProjection) -> String {
if history.pending_runs.is_empty() {
format!(" history {} receipt(s)", history.receipts.len())
} else {
format!(
" history {} receipt(s), {} needs_agent",
history.receipts.len(),
history.pending_runs.len()
)
}
}
fn push_pending_run_lines(
lines: &mut Vec<String>,
pending: &runx_runtime::journal::PausedRunSummary,
receipt_dir: Option<&Path>,
) {
let step = pending
.step_labels
.first()
.or_else(|| pending.step_ids.first())
.map_or("", String::as_str);
lines.push(format!(
" * {} needs_agent {} {}",
pending.name,
step,
short_id(&pending.id)
));
if let Some(resume_skill_ref) = pending.resume_skill_ref.as_deref() {
let resume_command =
crate::resume::render_skill_resume_command(crate::resume::SkillResumeCommand {
skill_ref: Some(resume_skill_ref),
run_id: &pending.id,
selected_runner: pending.selected_runner.as_deref(),
receipt_dir,
answers_path: None,
});
lines.push(format!(" next {resume_command}"));
} else {
lines.push(format!(
" next write answers.json, then resume the pending run with runx resume {} answers.json",
pending.id
));
}
}
fn push_receipt_line(
lines: &mut Vec<String>,
receipt: &runx_runtime::journal::LocalHistoryReceipt,
) {
lines.push(format!(
" {} {} {} {}",
receipt.status,
receipt.name,
receipt.verification.status,
short_id(&receipt.id)
));
}
fn history_next_line(history: &runx_runtime::journal::LocalHistoryProjection) -> String {
if history.pending_runs.is_empty() {
" next runx history <receipt-id> --json".to_owned()
} else if history
.pending_runs
.iter()
.any(|run| run.resume_skill_ref.is_some())
{
" next write answers.json, then rerun one of the commands above".to_owned()
} else {
" next write answers.json, then rerun the original skill with the shown run id".to_owned()
}
}
fn short_id(value: &str) -> &str {
value.get(..12).unwrap_or(value)
}
fn invalid_args(message: impl Into<String>) -> HistoryCliError {
HistoryCliError::InvalidArgs(message.into())
}
#[cfg(test)]
mod tests {
use std::fs;
use std::io;
use super::*;
use runx_contracts::ReceiptIssuerType;
use runx_runtime::receipts::step_receipt_with_signature_policy;
use runx_runtime::{Ed25519ReceiptSigner, InvocationStatus, RuntimeError, SkillOutput};
#[test]
fn parses_history_args_without_comparing_against_runtime_constants() -> Result<(), io::Error> {
let parsed = parse_history_args(&[
"history".into(),
"sourcey".into(),
"--skill".into(),
"source".into(),
"--status=needs_agent".into(),
"--artifact-type".into(),
"artifact".into(),
"--json".into(),
])
.map_err(|error| io::Error::other(error.to_string()))?;
assert_eq!(parsed.query.as_deref(), Some("sourcey"));
assert_eq!(parsed.filter.skill.as_deref(), Some("source"));
assert_eq!(parsed.filter.status.as_deref(), Some("needs_agent"));
assert_eq!(parsed.filter.artifact_type.as_deref(), Some("artifact"));
assert!(parsed.json);
Ok(())
}
#[test]
// rust-style-allow: long-function because the CLI execute oracle test keeps
// its ledger fixture, command invocation, and typed output assertions in
// one place so the parity case remains readable.
fn executes_history_json_against_cli_parity_oracle() -> Result<(), io::Error> {
let temp = tempfile_dir()?;
let receipt_dir = temp.join("receipts");
write_needs_agent_ledger(&receipt_dir)?;
let oracle: CliParityOracle = serde_json::from_str(include_str!(
"../../../fixtures/cli-parity/cases/oracle.json"
))
.map_err(|error| io::Error::other(error.to_string()))?;
let execute_case = oracle
.cases
.iter()
.find(|case| case.id == "history.execute")
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"missing history.execute oracle case",
)
})?;
let mut env = BTreeMap::new();
env.insert("RUNX_CWD".to_owned(), temp.to_string_lossy().to_string());
let result = run_history_command(
&[
"history".into(),
"--receipt-dir".into(),
receipt_dir.into_os_string(),
"--json".into(),
],
&env,
&temp,
)
.map_err(|error| io::Error::other(error.to_string()))?;
let output: HistoryOutput = serde_json::from_str(&result.output)
.map_err(|error| io::Error::other(error.to_string()))?;
let first_pending_run = output.pending_runs.first().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"history output has no pending run",
)
})?;
assert_eq!(
output.pending_runs.len(),
execute_case.expect.pending_runs as usize
);
assert_eq!(
first_pending_run.id,
execute_case.expect.first_pending_run_id
);
assert_eq!(
first_pending_run.status,
execute_case.expect.first_pending_run_status
);
assert_eq!(
first_pending_run.selected_runner,
Some("agent-task".to_owned())
);
assert_eq!(
first_pending_run.resume_skill_ref,
Some("../skills/sourcey".to_owned())
);
Ok(())
}
#[test]
fn history_human_pending_run_includes_resume_command() -> Result<(), io::Error> {
let temp = tempfile_dir()?;
let receipt_dir = temp.join("receipts");
write_needs_agent_ledger(&receipt_dir)?;
let mut env = BTreeMap::new();
env.insert("RUNX_CWD".to_owned(), temp.to_string_lossy().to_string());
let result = run_history_command(
&[
"history".into(),
"--receipt-dir".into(),
receipt_dir.clone().into_os_string(),
],
&env,
&temp,
)
.map_err(|error| io::Error::other(error.to_string()))?;
let receipt_dir_arg = receipt_dir.to_string_lossy();
assert!(
result
.output
.contains("next runx resume gx_needs_agent_oracle answers.json")
);
assert!(
result
.output
.contains(&format!("--receipt-dir {}", receipt_dir_arg))
);
assert!(
result
.output
.contains("write answers.json, then rerun one of the commands above")
);
Ok(())
}
#[test]
fn history_human_pending_run_omits_default_receipt_dir_from_resume_command()
-> Result<(), io::Error> {
let temp = tempfile_dir()?;
let receipt_dir = temp.join(".runx").join("receipts");
write_needs_agent_ledger(&receipt_dir)?;
let mut env = BTreeMap::new();
env.insert("RUNX_CWD".to_owned(), temp.to_string_lossy().to_string());
let result = run_history_command(&["history".into()], &env, &temp)
.map_err(|error| io::Error::other(error.to_string()))?;
assert!(
result
.output
.contains("next runx resume gx_needs_agent_oracle answers.json")
);
assert!(
!result.output.contains("--receipt-dir"),
"default receipt dir must not be echoed into resume commands:\n{}",
result.output
);
Ok(())
}
#[test]
fn history_human_pending_run_does_not_invent_resume_command() -> Result<(), io::Error> {
let temp = tempfile_dir()?;
let receipt_dir = temp.join("receipts");
write_needs_agent_ledger_with_resume(&receipt_dir, None)?;
let mut env = BTreeMap::new();
env.insert("RUNX_CWD".to_owned(), temp.to_string_lossy().to_string());
let result = run_history_command(
&[
"history".into(),
"--receipt-dir".into(),
receipt_dir.into_os_string(),
],
&env,
&temp,
)
.map_err(|error| io::Error::other(error.to_string()))?;
assert!(!result.output.contains("runx skill sourcey"));
assert!(
result
.output
.contains("runx resume gx_needs_agent_oracle answers.json"),
"history output should give non-fabricated continuation guidance:\n{}",
result.output
);
Ok(())
}
#[test]
fn history_json_reports_production_verified_receipts_when_verifier_env_is_configured()
-> Result<(), io::Error> {
let temp = tempfile_dir()?;
let receipt_dir = temp.join("receipts");
let signer = fixture_signer().map_err(|error| io::Error::other(error.to_string()))?;
let receipt = production_signed_receipt(&signer)
.map_err(|error| io::Error::other(error.to_string()))?;
let store = LocalReceiptStore::new(&receipt_dir);
let verifier = Ed25519ReceiptVerifier::new([signer.production_key()]);
store
.write_receipt_with_policy(
&receipt,
RuntimeReceiptSignaturePolicy::production(&verifier),
)
.map_err(|error| io::Error::other(error.to_string()))?;
let mut env = BTreeMap::new();
env.insert("RUNX_CWD".to_owned(), temp.to_string_lossy().to_string());
env.insert(
RUNX_RECEIPT_VERIFY_KID_ENV.to_owned(),
FIXTURE_KID.to_owned(),
);
env.insert(
RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64_ENV.to_owned(),
base64_standard(signer.public_key()),
);
let result = run_history_command(
&[
"history".into(),
receipt.id.to_string().into(),
"--receipt-dir".into(),
receipt_dir.into_os_string(),
"--json".into(),
],
&env,
&temp,
)
.map_err(|error| io::Error::other(error.to_string()))?;
let output: HistoryOutput = serde_json::from_str(&result.output)
.map_err(|error| io::Error::other(error.to_string()))?;
let first_receipt = output.receipts.first().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "history output has no receipt")
})?;
assert_eq!(first_receipt.id, receipt.id.to_string());
assert_eq!(first_receipt.verification.status, "verified");
Ok(())
}
#[derive(serde::Deserialize)]
struct CliParityOracle {
cases: Vec<CliParityCase>,
}
#[derive(serde::Deserialize)]
struct CliParityCase {
id: String,
#[serde(default)]
expect: CliParityExpectation,
}
#[derive(Default, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct CliParityExpectation {
#[serde(default)]
pending_runs: u64,
#[serde(default)]
first_pending_run_id: String,
#[serde(default)]
first_pending_run_status: String,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct HistoryOutput {
receipts: Vec<HistoryReceipt>,
pending_runs: Vec<HistoryPendingRun>,
}
#[derive(serde::Deserialize)]
struct HistoryReceipt {
id: String,
verification: HistoryReceiptVerification,
}
#[derive(serde::Deserialize)]
struct HistoryReceiptVerification {
status: String,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct HistoryPendingRun {
id: String,
status: String,
resume_skill_ref: Option<String>,
selected_runner: Option<String>,
}
fn write_needs_agent_ledger(receipt_dir: &Path) -> Result<(), io::Error> {
write_needs_agent_ledger_with_resume(receipt_dir, Some("../skills/sourcey"))
}
fn write_needs_agent_ledger_with_resume(
receipt_dir: &Path,
resume_skill_ref: Option<&str>,
) -> Result<(), io::Error> {
fs::create_dir_all(receipt_dir.join("ledgers"))?;
fs::write(
receipt_dir
.join("ledgers")
.join("gx_needs_agent_oracle.jsonl"),
format!(
"{}\n{}\n",
needs_agent_started_record(),
needs_agent_waiting_record(resume_skill_ref)
),
)
}
fn needs_agent_started_record() -> &'static str {
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}}}"#
}
fn needs_agent_waiting_record(resume_skill_ref: Option<&str>) -> String {
format!(
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}}}}}}"#,
resume_skill_ref_fragment(resume_skill_ref)
)
}
fn resume_skill_ref_fragment(resume_skill_ref: Option<&str>) -> String {
resume_skill_ref
.map(|value| format!(r#","resume_skill_ref":"{value}""#))
.unwrap_or_default()
}
fn tempfile_dir() -> Result<PathBuf, io::Error> {
let path = std::env::temp_dir().join(format!(
"runx-cli-history-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|error| io::Error::other(error.to_string()))?
.as_nanos()
));
fs::create_dir_all(&path)?;
Ok(path)
}
const FIXTURE_KID: &str = "runx-cli-prod-history-fixture-key";
const FIXTURE_SEED: [u8; 32] = [0x42; 32];
fn fixture_signer() -> Result<Ed25519ReceiptSigner, runx_runtime::RuntimeReceiptSigningError> {
Ed25519ReceiptSigner::from_seed(FIXTURE_KID, ReceiptIssuerType::Hosted, &FIXTURE_SEED)
}
fn production_signed_receipt(
signer: &Ed25519ReceiptSigner,
) -> Result<runx_contracts::Receipt, RuntimeError> {
let verifier = Ed25519ReceiptVerifier::new([signer.production_key()]);
let output = SkillOutput {
status: InvocationStatus::Success,
stdout:
r#"{"artifact":{"artifact_id":"artifact_cli_history","artifact_type":"artifact"}}"#
.to_owned(),
stderr: String::new(),
exit_code: Some(0),
duration_ms: 10,
metadata: BTreeMap::new(),
};
step_receipt_with_signature_policy(
"cli-history",
"production-verified",
1,
&output,
"2026-05-25T00:00:00Z",
RuntimeReceiptSignaturePolicy::production_signing(signer, &verifier),
)
}
fn base64_standard(bytes: &[u8]) -> String {
const TABLE: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut encoded = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let first = chunk[0];
let second = chunk.get(1).copied().unwrap_or(0);
let third = chunk.get(2).copied().unwrap_or(0);
let combined = ((first as u32) << 16) | ((second as u32) << 8) | third as u32;
encoded.push(TABLE[((combined >> 18) & 0x3f) as usize] as char);
encoded.push(TABLE[((combined >> 12) & 0x3f) as usize] as char);
if chunk.len() > 1 {
encoded.push(TABLE[((combined >> 6) & 0x3f) as usize] as char);
} else {
encoded.push('=');
}
if chunk.len() > 2 {
encoded.push(TABLE[(combined & 0x3f) as usize] as char);
} else {
encoded.push('=');
}
}
encoded
}
}