Skip to main content

clawft_kernel/workload_runtime/
evidence.rs

1//! Run evidence: what an instance did, captured under its output limit.
2//!
3//! Stdout is evidence and logs, never control input (ADR-100 section 4).
4//! Chain payloads carry [`RunEvidence::audit`], which has counts and hashes
5//! but no output content.
6
7use serde::{Deserialize, Serialize};
8
9/// A byte capture that keeps the first `limit` bytes and counts the rest.
10#[derive(Debug, Clone)]
11pub struct Capture {
12    buf: Vec<u8>,
13    total: u64,
14    limit: usize,
15}
16
17impl Capture {
18    /// Empty capture with a byte limit.
19    pub fn new(limit: usize) -> Self {
20        Self {
21            buf: Vec::new(),
22            total: 0,
23            limit,
24        }
25    }
26
27    /// Append a chunk.
28    pub fn push(&mut self, chunk: &[u8]) {
29        self.total += chunk.len() as u64;
30        let room = self.limit.saturating_sub(self.buf.len());
31        self.buf.extend_from_slice(&chunk[..chunk.len().min(room)]);
32    }
33
34    /// Captured bytes, lossily decoded.
35    pub fn text(&self) -> String {
36        String::from_utf8_lossy(&self.buf).into_owned()
37    }
38
39    /// Total bytes seen.
40    pub fn total(&self) -> u64 {
41        self.total
42    }
43
44    /// Whether bytes were dropped.
45    pub fn truncated(&self) -> bool {
46        self.total > self.buf.len() as u64
47    }
48}
49
50/// What one run (or one stopped instance) produced.
51#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
52pub struct RunEvidence {
53    /// Adapter id.
54    pub runtime: String,
55    /// Instance id.
56    pub instance_id: String,
57    /// Arguments passed (binary path omitted).
58    pub args: Vec<String>,
59    /// Exit code, when the process exited normally.
60    pub exit_code: Option<i32>,
61    /// Terminating signal, when killed.
62    pub signal: Option<i32>,
63    /// Killed because it exceeded `[console].max_runtime_secs`.
64    pub killed_for_timeout: bool,
65    /// Wall time.
66    pub elapsed_ms: u64,
67    /// Captured stdout (capped).
68    pub stdout: String,
69    /// Captured stderr (capped).
70    pub stderr: String,
71    /// Total stdout bytes produced.
72    pub stdout_bytes: u64,
73    /// Total stderr bytes produced.
74    pub stderr_bytes: u64,
75    /// Whether either stream exceeded the output limit.
76    pub truncated: bool,
77    /// Instances stopped to free the sensor feed before a console run.
78    #[serde(default, skip_serializing_if = "Vec::is_empty")]
79    pub stopped_for_console: Vec<String>,
80}
81
82impl RunEvidence {
83    /// Fill output fields from captures.
84    pub fn with_output(mut self, out: &Capture, err: &Capture) -> Self {
85        self.stdout = out.text();
86        self.stderr = err.text();
87        self.stdout_bytes = out.total();
88        self.stderr_bytes = err.total();
89        self.truncated = out.truncated() || err.truncated();
90        self
91    }
92
93    /// Stdout lines that parse as JSON objects (a cog's report lines).
94    pub fn json_lines(&self) -> Vec<serde_json::Value> {
95        self.stdout
96            .lines()
97            .filter_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok())
98            .filter(|v| v.is_object())
99            .collect()
100    }
101
102    /// Exited 0 and was not killed.
103    pub fn succeeded(&self) -> bool {
104        self.exit_code == Some(0) && !self.killed_for_timeout
105    }
106
107    /// Chain-safe summary: no output content.
108    pub fn audit(&self) -> serde_json::Value {
109        serde_json::json!({
110            "runtime": self.runtime,
111            "instance_id": self.instance_id,
112            "exit_code": self.exit_code,
113            "signal": self.signal,
114            "killed_for_timeout": self.killed_for_timeout,
115            "elapsed_ms": self.elapsed_ms,
116            "stdout_bytes": self.stdout_bytes,
117            "stderr_bytes": self.stderr_bytes,
118            "truncated": self.truncated,
119            "stopped_for_console": self.stopped_for_console,
120            "json_lines": self.json_lines().len(),
121            "stdout_blake3": blake3::hash(self.stdout.as_bytes()).to_hex().to_string(),
122        })
123    }
124}