Skip to main content

glass/
results.rs

1//! Bounded, redacted result contracts and local diagnostic artifacts.
2//!
3//! Agent-facing responses default to [`crate::results::ResponseMode::Minimal`]. Detailed
4//! evidence stays in a local result artifact and is referenced by ID.
5
6use crate::protocol::{ErrorPhase, RetryGuidance};
7use chrono::Utc;
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value};
10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::time::SystemTime;
13
14pub const RESULT_SCHEMA_VERSION: u32 = 1;
15const MAX_RESULT_ID_BYTES: usize = 96;
16const MAX_RESULT_BYTES: usize = 512 * 1024;
17const MAX_DETAIL_BYTES: usize = 256 * 1024;
18static RESULT_SEQUENCE: AtomicU64 = AtomicU64::new(1);
19
20/// Agent-facing result detail level.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum, Default)]
22#[serde(rename_all = "lowercase")]
23pub enum ResponseMode {
24    #[default]
25    Minimal,
26    Normal,
27    Diagnostic,
28}
29
30/// Whether detailed evidence can be retrieved locally.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct DetailAvailability {
34    pub result_id: String,
35    pub details_available: bool,
36    pub details_truncated: bool,
37}
38
39/// Canonical operation result before a response mode projection.
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct OperationResult {
43    pub status: String,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub execution_id: Option<String>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub revision: Option<u64>,
48    pub phase: ErrorPhase,
49    pub mutation_possible: bool,
50    pub retry: RetryGuidance,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub effect: Option<Value>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub verification: Option<Value>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub target: Option<Value>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub policy: Option<Value>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub details: Option<Value>,
61    pub detail_availability: DetailAvailability,
62}
63
64impl OperationResult {
65    /// Project the same result into the requested bounded response mode.
66    pub fn project(&self, mode: ResponseMode) -> Value {
67        let mut output = Map::new();
68        for key in [
69            "status",
70            "executionId",
71            "revision",
72            "phase",
73            "mutationPossible",
74            "retry",
75            "effect",
76            "verification",
77        ] {
78            if let Some(value) = serde_json::to_value(self)
79                .ok()
80                .and_then(|value| value.get(key).cloned())
81            {
82                output.insert(key.into(), value);
83            }
84        }
85        if matches!(mode, ResponseMode::Normal | ResponseMode::Diagnostic) {
86            for key in ["target", "policy", "detailAvailability"] {
87                if let Some(value) = serde_json::to_value(self)
88                    .ok()
89                    .and_then(|value| value.get(key).cloned())
90                {
91                    output.insert(key.into(), value);
92                }
93            }
94        }
95        if mode == ResponseMode::Diagnostic
96            && let Some(details) = &self.details
97        {
98            output.insert("details".into(), details.clone());
99        }
100        Value::Object(output)
101    }
102}
103
104/// Project one serialized result into the bounded agent-facing response modes.
105///
106/// The complete value is retained by [`ResultStore`]; minimal and normal
107/// projections omit only diagnostic-heavy fields and advertise the artifact
108/// through `detailAvailability`.
109pub fn project_value(mut value: Value, mode: ResponseMode) -> Value {
110    if mode != ResponseMode::Diagnostic
111        && let Value::Object(object) = &mut value
112    {
113        object.remove("details");
114        if mode == ResponseMode::Minimal {
115            for key in ["trace", "diagnostics", "screenshot", "dom", "formValues"] {
116                object.remove(key);
117            }
118        }
119    }
120    value
121}
122
123/// Persist complete details and return the selected bounded projection.
124pub fn project_and_store(
125    value: Value,
126    mode: ResponseMode,
127    prefix: &str,
128    root: impl Into<PathBuf>,
129) -> Result<Value, ResultStoreError> {
130    let store = ResultStore::new(root);
131    let result_id = ResultStore::next_id(prefix)?;
132    let availability = store.save(&result_id, &value)?;
133    let mut projected = project_value(value, mode);
134    if let Value::Object(object) = &mut projected {
135        object.insert(
136            "detailAvailability".into(),
137            serde_json::to_value(availability)?,
138        );
139    } else {
140        projected = serde_json::json!({
141            "result": projected,
142            "detailAvailability": availability,
143        });
144    }
145    Ok(projected)
146}
147
148/// Stored diagnostic result with a versioned, redacted payload.
149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct ResultArtifact {
152    pub schema_version: u32,
153    pub result_id: String,
154    pub created_at: String,
155    pub details_truncated: bool,
156    pub details: Value,
157}
158
159/// Local bounded result artifact store.
160#[derive(Debug, Clone)]
161pub struct ResultStore {
162    root: PathBuf,
163    max_bytes: usize,
164}
165
166impl ResultStore {
167    pub fn new(root: impl Into<PathBuf>) -> Self {
168        Self {
169            root: root.into(),
170            max_bytes: MAX_RESULT_BYTES,
171        }
172    }
173
174    pub fn with_max_bytes(mut self, max_bytes: usize) -> Self {
175        self.max_bytes = max_bytes.max(1024);
176        self
177    }
178
179    pub fn root(&self) -> &Path {
180        &self.root
181    }
182
183    pub fn next_id(prefix: &str) -> Result<String, ResultStoreError> {
184        let id = format!(
185            "{}_{}_{}",
186            sanitize_id(prefix)?,
187            Utc::now().timestamp_millis(),
188            RESULT_SEQUENCE.fetch_add(1, Ordering::Relaxed)
189        );
190        validate_id(&id)?;
191        Ok(id)
192    }
193
194    pub fn save(
195        &self,
196        result_id: &str,
197        details: &Value,
198    ) -> Result<DetailAvailability, ResultStoreError> {
199        validate_id(result_id)?;
200        std::fs::create_dir_all(&self.root)?;
201        let redacted = redact_value(details);
202        let raw = serde_json::to_vec(&redacted)?;
203        let (details, details_truncated) = if raw.len() > MAX_DETAIL_BYTES {
204            (
205                Value::String(String::from("diagnostic details truncated")),
206                true,
207            )
208        } else {
209            (redacted, false)
210        };
211        let artifact = ResultArtifact {
212            schema_version: RESULT_SCHEMA_VERSION,
213            result_id: result_id.to_string(),
214            created_at: Utc::now().to_rfc3339(),
215            details_truncated,
216            details,
217        };
218        let bytes = serde_json::to_vec(&artifact)?;
219        if bytes.len() > self.max_bytes {
220            return Err(ResultStoreError::TooLarge(bytes.len(), self.max_bytes));
221        }
222        let destination = self.path_for(result_id);
223        let temporary = destination.with_extension("json.tmp");
224        std::fs::write(&temporary, bytes)?;
225        std::fs::rename(&temporary, &destination)?;
226        Ok(DetailAvailability {
227            result_id: result_id.to_string(),
228            details_available: true,
229            details_truncated,
230        })
231    }
232
233    pub fn load(&self, result_id: &str) -> Result<ResultArtifact, ResultStoreError> {
234        validate_id(result_id)?;
235        let artifact: ResultArtifact =
236            serde_json::from_slice(&std::fs::read(self.path_for(result_id))?)?;
237        if artifact.schema_version != RESULT_SCHEMA_VERSION || artifact.result_id != result_id {
238            return Err(ResultStoreError::InvalidArtifact(result_id.to_string()));
239        }
240        Ok(artifact)
241    }
242
243    pub fn purge_older_than(&self, age: std::time::Duration) -> Result<usize, ResultStoreError> {
244        if !self.root.exists() {
245            return Ok(0);
246        }
247        let cutoff = SystemTime::now()
248            .checked_sub(age)
249            .unwrap_or(SystemTime::UNIX_EPOCH);
250        let mut removed = 0;
251        for entry in std::fs::read_dir(&self.root)? {
252            let entry = entry?;
253            if entry.path().extension().and_then(|value| value.to_str()) != Some("json") {
254                continue;
255            }
256            if entry.metadata()?.modified().unwrap_or(SystemTime::now()) < cutoff {
257                std::fs::remove_file(entry.path())?;
258                removed += 1;
259            }
260        }
261        Ok(removed)
262    }
263
264    fn path_for(&self, result_id: &str) -> PathBuf {
265        self.root.join(format!("{result_id}.json"))
266    }
267}
268
269/// Default local diagnostic artifact directory.
270pub fn default_result_store_path() -> PathBuf {
271    dirs::cache_dir()
272        .unwrap_or_else(std::env::temp_dir)
273        .join("glass")
274        .join("results")
275}
276
277#[derive(Debug)]
278pub enum ResultStoreError {
279    InvalidId,
280    InvalidArtifact(String),
281    TooLarge(usize, usize),
282    Io(std::io::Error),
283    Json(serde_json::Error),
284}
285
286impl std::fmt::Display for ResultStoreError {
287    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288        match self {
289            Self::InvalidId => formatter.write_str("invalid result ID"),
290            Self::InvalidArtifact(id) => write!(formatter, "result artifact is invalid: {id}"),
291            Self::TooLarge(actual, limit) => write!(
292                formatter,
293                "result artifact is {actual} bytes, exceeding the {limit}-byte budget"
294            ),
295            Self::Io(error) => error.fmt(formatter),
296            Self::Json(error) => error.fmt(formatter),
297        }
298    }
299}
300
301impl std::error::Error for ResultStoreError {}
302
303impl From<std::io::Error> for ResultStoreError {
304    fn from(error: std::io::Error) -> Self {
305        Self::Io(error)
306    }
307}
308
309impl From<serde_json::Error> for ResultStoreError {
310    fn from(error: serde_json::Error) -> Self {
311        Self::Json(error)
312    }
313}
314
315fn sanitize_id(value: &str) -> Result<String, ResultStoreError> {
316    if value.is_empty() || value.len() > MAX_RESULT_ID_BYTES {
317        return Err(ResultStoreError::InvalidId);
318    }
319    let sanitized = value
320        .chars()
321        .map(|character| {
322            if character.is_ascii_alphanumeric() || character == '-' {
323                character
324            } else {
325                '_'
326            }
327        })
328        .collect::<String>();
329    validate_id(&sanitized)?;
330    Ok(sanitized)
331}
332
333fn validate_id(value: &str) -> Result<(), ResultStoreError> {
334    if value.is_empty()
335        || value.len() > MAX_RESULT_ID_BYTES
336        || value == "."
337        || value == ".."
338        || value.contains('/')
339        || value.contains('\\')
340        || value.chars().any(char::is_whitespace)
341    {
342        return Err(ResultStoreError::InvalidId);
343    }
344    Ok(())
345}
346
347fn redact_value(value: &Value) -> Value {
348    match value {
349        Value::Object(object) => {
350            let mut result = Map::new();
351            for (key, value) in object {
352                if is_sensitive_key(key) {
353                    result.insert(key.clone(), Value::String("[REDACTED]".into()));
354                } else {
355                    result.insert(key.clone(), redact_value(value));
356                }
357            }
358            Value::Object(result)
359        }
360        Value::Array(values) => Value::Array(values.iter().map(redact_value).collect()),
361        other => other.clone(),
362    }
363}
364
365fn is_sensitive_key(key: &str) -> bool {
366    let key = key.to_ascii_lowercase();
367    [
368        "cookie",
369        "cookies",
370        "authorization",
371        "headers",
372        "password",
373        "payment",
374        "typedformvalues",
375        "formvalues",
376        "screenshot",
377        "dom",
378        "console",
379        "network",
380    ]
381    .iter()
382    .any(|sensitive| key.contains(sensitive))
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn response_modes_preserve_recovery_fields_and_bound_details() {
391        let result = OperationResult {
392            status: "indeterminate".into(),
393            execution_id: Some("run_1".into()),
394            revision: Some(4),
395            phase: ErrorPhase::PostDispatch,
396            mutation_possible: true,
397            retry: RetryGuidance {
398                classification: crate::protocol::RetryClassification::UnsafeUntilReconciled,
399                recommended_operation: "recover_run".into(),
400            },
401            effect: Some(serde_json::json!({"observed": false})),
402            verification: None,
403            target: Some(serde_json::json!({"id": "submit"})),
404            policy: Some(serde_json::json!({"allowed": true})),
405            details: Some(serde_json::json!({"trace": [1, 2, 3]})),
406            detail_availability: DetailAvailability {
407                result_id: "run_1".into(),
408                details_available: true,
409                details_truncated: false,
410            },
411        };
412        assert!(result.project(ResponseMode::Minimal)["retry"].is_object());
413        assert!(
414            result
415                .project(ResponseMode::Minimal)
416                .get("details")
417                .is_none()
418        );
419        assert!(result.project(ResponseMode::Normal)["target"].is_object());
420        assert!(result.project(ResponseMode::Diagnostic)["details"].is_object());
421    }
422
423    #[test]
424    fn result_store_redacts_and_round_trips_atomically() {
425        let root = std::env::temp_dir().join(format!("glass-results-{}", std::process::id()));
426        let _ = std::fs::remove_dir_all(&root);
427        let store = ResultStore::new(&root);
428        let availability = store
429            .save(
430                "run_1",
431                &serde_json::json!({"cookie": "secret", "nested": {"password": "secret"}, "value": 3}),
432            )
433            .unwrap();
434        assert!(availability.details_available);
435        let artifact = store.load("run_1").unwrap();
436        assert_eq!(artifact.details["cookie"], "[REDACTED]");
437        assert_eq!(artifact.details["nested"]["password"], "[REDACTED]");
438        assert_eq!(artifact.details["value"], 3);
439        let _ = std::fs::remove_dir_all(root);
440    }
441}