use async_nats::Client;
use kanade_shared::default_paths;
use kanade_shared::wire::{Command, ExecResult, FinalizeCommand, Shell};
use serde_json::json;
use tracing::{debug, info, warn};
use crate::process::{ExecOutcome, run_command_with_kill};
pub fn collect_result_json(bundles: &[crate::collect::BundleResult]) -> String {
let arr: Vec<_> = bundles
.iter()
.map(|b| {
json!({
"label": b.label,
"key": b.key,
"uploaded": true,
"files": b.files,
})
})
.collect();
json!({ "ok": !arr.is_empty(), "bundles": arr }).to_string()
}
fn powershell_prelude(result_json: &str) -> String {
format!(
"$env:KANADE_COLLECT_RESULT = '{}'\n",
result_json.replace('\'', "''")
)
}
fn with_finalize_note(stderr: &str, note: &str) -> String {
let trimmed = stderr.trim_end();
if trimmed.is_empty() {
note.to_string()
} else {
format!("{trimmed}\n{note}")
}
}
pub async fn run_finalize(
client: &Client,
parent: &Command,
fin: &FinalizeCommand,
pc_id: &str,
parent_result_id: &str,
disc: Option<&str>,
result_json: Option<&str>,
) {
if matches!(fin.shell, Shell::Cmd) {
warn!(job = %parent.id, "finalize: cmd shell is not supported (injection risk); skipping hook");
return;
}
let prelude = result_json.map(powershell_prelude).unwrap_or_default();
let script = format!("{prelude}{}", fin.script);
let fin_cmd = Command {
id: format!("{}__finalize", parent.id),
version: parent.version.clone(),
request_id: parent.request_id.clone(),
exec_id: parent.exec_id.clone(),
shell: fin.shell,
script,
script_object: None,
script_object_sha256: None,
timeout_secs: fin.timeout_secs,
jitter_secs: None,
run_as: fin.run_as,
cwd: fin.cwd.clone(),
deadline_at: None,
staleness: parent.staleness.clone(),
emit: None,
check: None,
collect: None,
retry: None,
finalize: None,
};
let started_at = chrono::Utc::now();
let outcome = run_command_with_kill(client, &fin_cmd, None).await;
let finished_at = chrono::Utc::now();
let (exit_code, stdout, stderr) = match outcome {
Ok(ExecOutcome::Completed {
exit_code: 0,
stdout,
stderr,
}) => {
info!(job = %parent.id, "finalize: hook completed");
(0, stdout, stderr)
}
Ok(ExecOutcome::Completed {
exit_code,
stdout,
stderr,
}) => {
warn!(job = %parent.id, exit_code, stderr = %stderr, "finalize: hook exited non-zero (ignored)");
(exit_code, stdout, stderr)
}
Ok(ExecOutcome::Killed { stdout, stderr }) => {
warn!(job = %parent.id, "finalize: hook killed (ignored)");
(
-1,
stdout,
with_finalize_note(&stderr, "finalize: hook killed"),
)
}
Ok(ExecOutcome::Timeout { stdout, stderr }) => {
warn!(job = %parent.id, "finalize: hook timed out (ignored)");
(
-1,
stdout,
with_finalize_note(
&stderr,
&format!("finalize: hook timed out after {}s", fin.timeout_secs),
),
)
}
Err(e) => {
warn!(job = %parent.id, error = %e, "finalize: hook spawn failed (ignored)");
(
-1,
String::new(),
format!("finalize: hook spawn failed: {e}"),
)
}
};
let result = build_finalize_result(
parent,
&fin_cmd,
pc_id,
parent_result_id,
disc,
exit_code,
stdout,
stderr,
started_at,
finished_at,
);
let outbox_dir = default_paths::data_dir().join("outbox");
let job = parent.id.clone();
let pc = pc_id.to_string();
tokio::task::spawn_blocking(move || match crate::outbox::enqueue(&outbox_dir, &result) {
Ok(path) => debug!(
%job,
pc_id = %pc,
exit_code,
outbox = %path.display(),
"finalize: outcome enqueued to outbox (#955 observability)",
),
Err(e) => warn!(
%job,
pc_id = %pc,
error = %e,
"finalize: outcome enqueue failed (hook still ran)",
),
});
}
#[allow(clippy::too_many_arguments)]
fn build_finalize_result(
parent: &Command,
fin_cmd: &Command,
pc_id: &str,
parent_result_id: &str,
disc: Option<&str>,
exit_code: i32,
stdout: String,
stderr: String,
started_at: chrono::DateTime<chrono::Utc>,
finished_at: chrono::DateTime<chrono::Utc>,
) -> ExecResult {
let request_id = match disc {
Some(d) => format!("{}__finalize-{d}", parent.request_id),
None => format!("{}__finalize", parent.request_id),
};
ExecResult {
result_id: uuid::Uuid::new_v4().to_string(),
request_id,
exec_id: None,
parent_result_id: Some(parent_result_id.to_string()),
pc_id: pc_id.to_string(),
exit_code,
stdout,
stderr,
started_at,
finished_at,
stdout_object: None,
stderr_object: None,
manifest_id: Some(fin_cmd.id.clone()),
collect_object: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parent_fixture(id: &str, request_id: &str) -> Command {
Command {
id: id.into(),
version: "1".into(),
request_id: request_id.into(),
exec_id: Some("exec-uuid".into()),
shell: Shell::Powershell,
script: String::new(),
script_object: None,
script_object_sha256: None,
timeout_secs: 60,
jitter_secs: None,
run_as: Default::default(),
cwd: None,
deadline_at: None,
staleness: Default::default(),
emit: None,
check: None,
collect: None,
retry: None,
finalize: None,
}
}
#[test]
fn finalize_result_row_holds_its_invariants() {
let parent = parent_fixture("screenshot-collect", "req-123");
let fin_cmd = parent_fixture("screenshot-collect__finalize", "req-123");
let now = chrono::Utc::now();
let r = build_finalize_result(
&parent,
&fin_cmd,
"pc-9",
"parent-run-uuid",
None,
1,
"out".into(),
"err".into(),
now,
now,
);
assert!(
r.exec_id.is_none(),
"finalize row must be ad-hoc (no #19 bump)"
);
assert_eq!(r.request_id, "req-123__finalize");
assert_eq!(
r.manifest_id.as_deref(),
Some("screenshot-collect__finalize")
);
assert_eq!(r.parent_result_id.as_deref(), Some("parent-run-uuid"));
assert_eq!(r.pc_id, "pc-9");
assert_eq!(r.exit_code, 1);
assert!(!r.result_id.is_empty(), "result_id minted");
}
#[test]
fn per_bundle_finalize_request_ids_are_distinct() {
let parent = parent_fixture("screenshot-collect", "req-123");
let fin_cmd = parent_fixture("screenshot-collect__finalize", "req-123");
let now = chrono::Utc::now();
let mk = |disc: Option<&str>| {
build_finalize_result(
&parent,
&fin_cmd,
"pc-9",
"parent-run-uuid",
disc,
0,
String::new(),
String::new(),
now,
now,
)
.request_id
};
assert_eq!(mk(None), "req-123__finalize");
assert_eq!(mk(Some("0")), "req-123__finalize-0");
assert_ne!(
mk(Some("0")),
mk(Some("1")),
"per-bundle finalize rows must have distinct request_ids (outbox filename)",
);
}
#[test]
fn result_json_marks_uploaded_when_collected() {
let json = collect_result_json(&[
crate::collect::BundleResult {
label: Some("20260101".into()),
key: "pc/job/20260101__ts.zip".into(),
files: vec!["C:/a.png".into(), "C:/b.png".into()],
},
crate::collect::BundleResult {
label: Some("20260102".into()),
key: "pc/job/20260102__ts.zip".into(),
files: vec!["C:/c.png".into()],
},
]);
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["ok"], true);
assert_eq!(v["bundles"].as_array().unwrap().len(), 2);
assert_eq!(v["bundles"][0]["label"], "20260101");
assert_eq!(v["bundles"][0]["key"], "pc/job/20260101__ts.zip");
assert_eq!(v["bundles"][0]["uploaded"], true);
assert_eq!(v["bundles"][0]["files"].as_array().unwrap().len(), 2);
}
#[test]
fn powershell_prelude_doubles_single_quotes() {
let p = powershell_prelude(r#"{"files":["C:/it's here.png"]}"#);
assert!(
p.contains("it''s here"),
"single quote must be doubled: {p}"
);
assert!(p.starts_with("$env:KANADE_COLLECT_RESULT = '"));
assert!(p.ends_with("'\n"));
let inner = p
.trim_start_matches("$env:KANADE_COLLECT_RESULT = '")
.trim_end_matches("'\n");
assert!(!inner.contains('\'') || inner.contains("''"));
}
#[test]
fn finalize_note_appends_or_stands_alone() {
assert_eq!(
with_finalize_note("", "finalize: hook killed"),
"finalize: hook killed"
);
assert_eq!(
with_finalize_note(
"Remove-Item: denied\n",
"finalize: hook timed out after 30s"
),
"Remove-Item: denied\nfinalize: hook timed out after 30s"
);
}
#[test]
fn result_json_is_empty_when_nothing_collected() {
let json = collect_result_json(&[]);
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["ok"], false);
assert_eq!(v["bundles"].as_array().unwrap().len(), 0);
}
}