|
18 | 18 | use std::fs; |
19 | 19 | use std::path::{Path, PathBuf}; |
20 | 20 |
|
21 | | -use serde::Serialize; |
| 21 | +use serde::{Deserialize, Serialize}; |
22 | 22 | use serde_json::Value; |
23 | 23 |
|
24 | 24 | use super::{run_owner_pid, running_status_note, ArtifactRecord, RunRecord}; |
@@ -249,6 +249,81 @@ pub struct EvidenceFailureSummary { |
249 | 249 | pub hints: Vec<String>, |
250 | 250 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
251 | 251 | pub child_command_failures: Vec<Value>, |
| 252 | + /// Runner-owned terminal diagnostics projected into controller evidence. |
| 253 | + /// Missing for local and pre-projection records to preserve their schema. |
| 254 | + #[serde(skip_serializing_if = "Option::is_none")] |
| 255 | + pub runner_failure: Option<RunnerFailureEvidence>, |
| 256 | +} |
| 257 | + |
| 258 | +/// Versioned, controller-owned projection of a runner terminal failure. |
| 259 | +/// Unknown or malformed legacy metadata is omitted from evidence rather than |
| 260 | +/// making `runs evidence` unreadable. |
| 261 | +#[derive(Debug, Clone, Serialize, Deserialize)] |
| 262 | +pub struct RunnerFailureEvidence { |
| 263 | + pub schema: String, |
| 264 | + #[serde(default, skip_serializing_if = "Option::is_none")] |
| 265 | + pub failure_code: Option<String>, |
| 266 | + #[serde(default, skip_serializing_if = "Option::is_none")] |
| 267 | + pub message: Option<String>, |
| 268 | + #[serde(default, skip_serializing_if = "Option::is_none")] |
| 269 | + pub details: Option<Value>, |
| 270 | + #[serde(default, skip_serializing_if = "Option::is_none")] |
| 271 | + pub phase: Option<String>, |
| 272 | + pub exit_code: i64, |
| 273 | + #[serde(default, skip_serializing_if = "Option::is_none")] |
| 274 | + pub signal: Option<String>, |
| 275 | + pub stderr_tail: String, |
| 276 | + /// Digest input is the original runner stderr bytes, before redaction. |
| 277 | + pub stderr_sha256: String, |
| 278 | + pub runner_id: String, |
| 279 | + pub runner_job_id: String, |
| 280 | + pub runner_job_logs_command: String, |
| 281 | + pub remote_command_result_command: String, |
| 282 | + #[serde(default, skip_serializing_if = "Option::is_none")] |
| 283 | + pub source_snapshot: Option<Value>, |
| 284 | + #[serde(default, skip_serializing_if = "Option::is_none")] |
| 285 | + pub path_materialization_plan: Option<Value>, |
| 286 | + #[serde(default, skip_serializing_if = "Option::is_none")] |
| 287 | + pub runner_job_projection: Option<Value>, |
| 288 | + #[serde(default, skip_serializing_if = "Option::is_none")] |
| 289 | + pub execution_record: Option<Value>, |
| 290 | + #[serde(default, skip_serializing_if = "Option::is_none")] |
| 291 | + pub orchestration_provenance: Option<Value>, |
| 292 | + #[serde(default, skip_serializing_if = "Vec::is_empty")] |
| 293 | + pub artifact_refs: Vec<RunnerFailureArtifactRef>, |
| 294 | +} |
| 295 | + |
| 296 | +#[derive(Debug, Clone, Serialize, Deserialize)] |
| 297 | +pub struct RunnerFailureArtifactRef { |
| 298 | + pub id: String, |
| 299 | + pub kind: String, |
| 300 | + pub path: String, |
| 301 | + pub sha256: String, |
| 302 | + pub size_bytes: i64, |
| 303 | +} |
| 304 | + |
| 305 | +impl RunnerFailureEvidence { |
| 306 | + pub const SCHEMA: &'static str = "homeboy/runner-exec-failure-projection/v1"; |
| 307 | + |
| 308 | + pub fn from_metadata(value: &Value) -> Option<Self> { |
| 309 | + let evidence: Self = serde_json::from_value(value.clone()).ok()?; |
| 310 | + (evidence.schema == Self::SCHEMA |
| 311 | + && !evidence.runner_id.is_empty() |
| 312 | + && !evidence.runner_job_id.is_empty() |
| 313 | + && evidence.stderr_sha256.len() == 64 |
| 314 | + && evidence |
| 315 | + .stderr_sha256 |
| 316 | + .bytes() |
| 317 | + .all(|byte| byte.is_ascii_hexdigit()) |
| 318 | + && evidence.artifact_refs.iter().all(|artifact| { |
| 319 | + !artifact.id.is_empty() |
| 320 | + && !artifact.path.is_empty() |
| 321 | + && artifact.sha256.len() == 64 |
| 322 | + && artifact.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) |
| 323 | + && artifact.size_bytes >= 0 |
| 324 | + })) |
| 325 | + .then_some(evidence) |
| 326 | + } |
252 | 327 | } |
253 | 328 |
|
254 | 329 | #[derive(Serialize)] |
@@ -643,6 +718,9 @@ pub fn evidence_failure_summary(run: &RunRecord) -> EvidenceFailureSummary { |
643 | 718 | gate_failures: string_array(metadata.get("gate_failures")), |
644 | 719 | hints: string_array(metadata.get("hints")), |
645 | 720 | child_command_failures: child_command_failures(metadata), |
| 721 | + runner_failure: metadata |
| 722 | + .pointer("/lab/failure") |
| 723 | + .and_then(RunnerFailureEvidence::from_metadata), |
646 | 724 | } |
647 | 725 | } |
648 | 726 |
|
|
0 commit comments