use serde::{Deserialize, Serialize};
use uuid::Uuid;
const LEGACY_RESULT_ID_PREFIX: &str = "kanade-issue-19/legacy-result-id:";
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ExecResult {
#[serde(default)]
pub result_id: String,
pub request_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exec_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_result_id: Option<String>,
pub pc_id: String,
pub exit_code: i32,
#[serde(default)]
pub skipped: Option<bool>,
pub stdout: String,
pub stderr: String,
pub started_at: chrono::DateTime<chrono::Utc>,
pub finished_at: chrono::DateTime<chrono::Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stdout_object: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stderr_object: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manifest_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub collect_object: Option<String>,
}
pub const EXIT_SKIP_VERSION_PIN: i32 = 124;
pub const EXIT_SKIP_DEADLINE: i32 = 125;
pub const EXIT_SKIP_REVOKED: i32 = 126;
pub const EXIT_SKIP_STALENESS: i32 = 127;
pub const EXIT_REJECTED_UNSIGNED: i32 = 123;
pub const EXIT_SKIP_UNSUPPORTED: i32 = 122;
pub const EXIT_RESTARTED_OUTCOME_UNKNOWN: i32 = 121;
pub fn signature_refusal_result_id(request_id: &str, pc_id: &str) -> String {
Uuid::new_v5(
&Uuid::NAMESPACE_OID,
format!("{request_id}|{pc_id}|signature-refused").as_bytes(),
)
.to_string()
}
impl ExecResult {
pub fn stable_result_id(&self) -> String {
if !self.result_id.is_empty() {
return self.result_id.clone();
}
let name = format!(
"{LEGACY_RESULT_ID_PREFIX}{}:{}",
self.request_id, self.pc_id
);
Uuid::new_v5(&Uuid::NAMESPACE_OID, name.as_bytes()).to_string()
}
pub fn is_reported_skip(&self) -> bool {
self.skipped == Some(true)
}
pub fn skips_check_projection(&self) -> bool {
match self.skipped {
Some(skipped) => skipped,
None => (EXIT_SKIP_UNSUPPORTED..=EXIT_SKIP_STALENESS).contains(&self.exit_code),
}
}
pub fn is_signature_refusal(&self) -> bool {
self.exit_code == EXIT_REJECTED_UNSIGNED
&& self.result_id == signature_refusal_result_id(&self.request_id, &self.pc_id)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn sample(exit_code: i32, skipped: Option<bool>) -> ExecResult {
let t0 = chrono::Utc.with_ymd_and_hms(2026, 9, 27, 0, 0, 0).unwrap();
ExecResult {
result_id: "r1".into(),
request_id: "req".into(),
exec_id: None,
parent_result_id: None,
pc_id: "PC1".into(),
exit_code,
skipped,
stdout: String::new(),
stderr: String::new(),
started_at: t0,
finished_at: t0,
stdout_object: None,
stderr_object: None,
manifest_id: None,
collect_object: None,
}
}
#[test]
fn restart_outcome_unknown_is_a_failure_outside_the_skip_block() {
assert!(
!(EXIT_SKIP_UNSUPPORTED..=EXIT_SKIP_STALENESS)
.contains(&EXIT_RESTARTED_OUTCOME_UNKNOWN)
);
let r = sample(EXIT_RESTARTED_OUTCOME_UNKNOWN, Some(false));
assert!(!r.is_reported_skip());
assert!(!r.skips_check_projection());
}
#[test]
fn skipped_is_always_on_the_wire_and_absent_means_legacy() {
for flag in [Some(true), Some(false)] {
let json = serde_json::to_string(&sample(127, flag)).unwrap();
let expected = format!("\"skipped\":{}", flag.unwrap());
assert!(json.contains(&expected), "{expected} missing: {json}");
let back: ExecResult = serde_json::from_str(&json).unwrap();
assert_eq!(back.skipped, flag);
}
let json = r#"{
"request_id":"r","pc_id":"x","exit_code":125,
"stdout":"","stderr":"",
"started_at":"2026-05-16T00:00:00Z",
"finished_at":"2026-05-16T00:00:00Z"
}"#;
let legacy: ExecResult = serde_json::from_str(json).unwrap();
assert_eq!(legacy.skipped, None);
}
#[test]
fn reporting_follows_the_flag_and_check_projection_falls_back_for_legacy() {
assert!(sample(125, Some(true)).is_reported_skip());
assert!(!sample(127, Some(false)).is_reported_skip());
assert!(!sample(125, None).is_reported_skip());
assert!(sample(0, Some(true)).skips_check_projection());
assert!(!sample(127, Some(false)).skips_check_projection());
for code in [122, 123, 125, 127] {
assert!(sample(code, None).skips_check_projection(), "{code}");
}
for code in [0, 1, 121, 128] {
assert!(!sample(code, None).skips_check_projection(), "{code}");
}
}
#[test]
fn signature_refusal_is_recognised_by_its_derived_id_not_by_exit_123() {
let t0 = chrono::Utc.with_ymd_and_hms(2026, 9, 27, 0, 0, 0).unwrap();
let mut r = ExecResult {
result_id: signature_refusal_result_id("req-1", "PC1"),
request_id: "req-1".into(),
exec_id: None,
parent_result_id: None,
pc_id: "PC1".into(),
exit_code: EXIT_REJECTED_UNSIGNED,
skipped: Some(false),
stdout: String::new(),
stderr: "refused: unsigned".into(),
started_at: t0,
finished_at: t0,
stdout_object: None,
stderr_object: None,
manifest_id: None,
collect_object: None,
};
assert!(r.is_signature_refusal());
r.result_id = "3f0e6a52-1b7c-4c1e-9d55-0d8f1f2b7a10".into();
assert!(!r.is_signature_refusal());
r.result_id = signature_refusal_result_id("req-1", "PC1");
r.exit_code = 1;
assert!(!r.is_signature_refusal());
}
#[test]
fn exec_result_round_trips_through_json() {
let t0 = chrono::Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap();
let t1 = chrono::Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 5).unwrap();
let r = ExecResult {
result_id: "result-uuid-1".into(),
request_id: "req-1".into(),
exec_id: Some("exec-uuid-1".into()),
parent_result_id: None,
pc_id: "pc-01".into(),
exit_code: 0,
skipped: Some(false),
stdout: "hello\n".into(),
stderr: String::new(),
started_at: t0,
finished_at: t1,
stdout_object: None,
stderr_object: None,
manifest_id: Some("inventory-hw".into()),
collect_object: None,
};
let json = serde_json::to_string(&r).unwrap();
let back: ExecResult = serde_json::from_str(&json).unwrap();
assert_eq!(back.result_id, r.result_id);
assert_eq!(back.request_id, r.request_id);
assert_eq!(back.exec_id.as_deref(), Some("exec-uuid-1"));
assert_eq!(back.exit_code, r.exit_code);
assert_eq!(back.stdout, r.stdout);
assert_eq!(back.started_at, t0);
assert_eq!(back.finished_at, t1);
assert_eq!(back.manifest_id.as_deref(), Some("inventory-hw"));
}
#[test]
fn exec_result_without_manifest_id_decodes() {
let json = r#"{
"request_id":"r","pc_id":"x","exit_code":0,
"stdout":"","stderr":"",
"started_at":"2026-05-16T00:00:00Z",
"finished_at":"2026-05-16T00:00:00Z"
}"#;
let r: ExecResult = serde_json::from_str(json).unwrap();
assert_eq!(r.manifest_id, None);
}
#[test]
fn exec_result_without_result_id_decodes_empty() {
let json = r#"{
"request_id":"r","pc_id":"x","exit_code":0,
"stdout":"","stderr":"",
"started_at":"2026-05-16T00:00:00Z",
"finished_at":"2026-05-16T00:00:00Z"
}"#;
let r: ExecResult = serde_json::from_str(json).unwrap();
assert_eq!(r.result_id, "");
assert!(r.exec_id.is_none());
}
#[test]
fn stable_result_id_is_deterministic_for_legacy_payload() {
let json = r#"{
"request_id":"r","pc_id":"x","exit_code":0,
"stdout":"","stderr":"",
"started_at":"2026-05-16T00:00:00Z",
"finished_at":"2026-05-16T00:00:00Z"
}"#;
let a: ExecResult = serde_json::from_str(json).unwrap();
let b: ExecResult = serde_json::from_str(json).unwrap();
assert_eq!(
a.stable_result_id(),
b.stable_result_id(),
"same legacy payload must hash to the same result_id",
);
}
#[test]
fn stable_result_id_differs_across_pcs_for_broadcast() {
let json_a = r#"{
"request_id":"shared","pc_id":"pc-1","exit_code":0,
"stdout":"","stderr":"",
"started_at":"2026-05-16T00:00:00Z",
"finished_at":"2026-05-16T00:00:00Z"
}"#;
let json_b = r#"{
"request_id":"shared","pc_id":"pc-2","exit_code":0,
"stdout":"","stderr":"",
"started_at":"2026-05-16T00:00:00Z",
"finished_at":"2026-05-16T00:00:00Z"
}"#;
let a: ExecResult = serde_json::from_str(json_a).unwrap();
let b: ExecResult = serde_json::from_str(json_b).unwrap();
assert_ne!(
a.stable_result_id(),
b.stable_result_id(),
"different pc_id must produce a different result_id",
);
}
#[test]
fn stable_result_id_passes_through_explicit_value() {
let r = ExecResult {
result_id: "agent-minted-uuid".into(),
request_id: "r".into(),
exec_id: None,
parent_result_id: None,
pc_id: "x".into(),
exit_code: 0,
skipped: Some(false),
stdout: String::new(),
stderr: String::new(),
started_at: chrono::Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap(),
finished_at: chrono::Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap(),
stdout_object: None,
stderr_object: None,
manifest_id: None,
collect_object: None,
};
assert_eq!(r.stable_result_id(), "agent-minted-uuid");
}
#[test]
fn exec_result_collect_object_round_trips_and_omits_when_absent() {
let t0 = chrono::Utc.with_ymd_and_hms(2026, 6, 15, 0, 0, 0).unwrap();
let mut r = ExecResult {
result_id: "r1".into(),
request_id: "req".into(),
exec_id: None,
parent_result_id: None,
pc_id: "PC1".into(),
exit_code: 0,
skipped: Some(false),
stdout: String::new(),
stderr: String::new(),
started_at: t0,
finished_at: t0,
stdout_object: None,
stderr_object: None,
manifest_id: Some("collect-diagnostics".into()),
collect_object: None,
};
let json = serde_json::to_string(&r).unwrap();
assert!(
!json.contains("collect_object"),
"collect_object must be absent when None: {json}"
);
r.collect_object = Some("PC1/collect-diagnostics/20260615T000000Z.zip".into());
let back: ExecResult = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
assert_eq!(
back.collect_object.as_deref(),
Some("PC1/collect-diagnostics/20260615T000000Z.zip"),
);
}
#[test]
fn exec_result_parent_result_id_round_trips_and_omits_when_absent() {
let t0 = chrono::Utc.with_ymd_and_hms(2026, 7, 4, 0, 0, 0).unwrap();
let mut r = ExecResult {
result_id: "fin-1".into(),
request_id: "req__finalize".into(),
exec_id: None,
parent_result_id: None,
pc_id: "PC1".into(),
exit_code: 0,
skipped: Some(false),
stdout: String::new(),
stderr: String::new(),
started_at: t0,
finished_at: t0,
stdout_object: None,
stderr_object: None,
manifest_id: Some("screenshot-collect__finalize".into()),
collect_object: None,
};
let json = serde_json::to_string(&r).unwrap();
assert!(
!json.contains("parent_result_id"),
"parent_result_id must be absent when None: {json}"
);
r.parent_result_id = Some("parent-run-uuid".into());
let back: ExecResult = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
assert_eq!(back.parent_result_id.as_deref(), Some("parent-run-uuid"));
}
}