Skip to main content

bamboo_engine/runtime/execution/
workspace_diagnostics.rs

1//! Privacy-preserving execution/Workspace correlation; never authority.
2
3use bamboo_agent_core::{Session, ToolError};
4use serde_json::{json, Value};
5use sha2::{Digest, Sha256};
6use std::error::Error;
7use std::sync::OnceLock;
8
9/// The salt is process-local and never logged. These identifiers are for log
10/// correlation only; they are neither persistent scope pins nor capabilities.
11fn opaque(value: &str) -> String {
12    static SALT: OnceLock<uuid::Uuid> = OnceLock::new();
13    let salt = SALT.get_or_init(uuid::Uuid::new_v4);
14    let mut digest = Sha256::new();
15    digest.update(salt.as_bytes());
16    digest.update(value.as_bytes());
17    hex::encode(digest.finalize())[..24].to_owned()
18}
19
20pub fn safe_identifier(value: &str) -> String {
21    if !value.is_empty()
22        && value.len() <= 128
23        && value
24            .bytes()
25            .all(|byte| byte.is_ascii_alphanumeric() || b"-_:".contains(&byte))
26    {
27        value.to_owned()
28    } else {
29        format!("opaque-{}", opaque(value))
30    }
31}
32
33pub fn scope_fingerprint(session: &Session) -> String {
34    let source = session
35        .metadata
36        .get(crate::project_context::WORKSPACE_SOURCE_METADATA_KEY);
37    // Only these three scope fields enter the digest; never the transcript or
38    // the rest of Session metadata, even as a diagnostic serialization.
39    let identity = json!([
40        session.project_id_meta(),
41        session.workspace_path_meta(),
42        source
43    ]);
44    format!("scope-{}", opaque(&identity.to_string()))
45}
46
47pub fn fence_fingerprint(fence: &bamboo_domain::ActorActivationFence) -> String {
48    // Owner and activation fields remain opaque. Epoch/attempt may be logged
49    // separately as numeric observations, not as a write capability.
50    format!("fence-{}", opaque(&format!("{fence:?}")))
51}
52
53/// Only fixed diagnostic labels enter default logs. Arbitrary Display text can
54/// contain a relative path, an escaped JSON value, or a credential without any
55/// recognizable delimiter, so a best-effort path scanner cannot make it safe.
56/// The opaque fingerprint correlates the original detail without disclosing it.
57pub fn redacted(value: &str) -> String {
58    format!(
59        "{} <detail:{}>",
60        reason_tags(value).join(","),
61        opaque(value)
62    )
63}
64
65/// Display-derived hints are deliberately not an authority classification.
66/// Typed io_kind/os_error/authority_conflict remain separate fields below.
67fn reason_tags(value: &str) -> Vec<&'static str> {
68    let lower: String = value
69        .chars()
70        .take(8192)
71        .flat_map(char::to_lowercase)
72        .collect();
73    let patterns = [
74        ("publication", "publication_rejected"),
75        ("fence", "fence_detail"),
76        ("expired", "expired"),
77        ("stale", "stale"),
78        ("scope", "scope_detail"),
79        ("permission", "permission_detail"),
80        ("checkpoint", "checkpoint_detail"),
81        ("workspace", "workspace_detail"),
82        ("project", "project_detail"),
83        ("version", "version_detail"),
84        ("not found", "not_found"),
85        ("timed out", "timeout"),
86        ("cancel", "cancelled"),
87    ];
88    let mut tags: Vec<_> = patterns
89        .into_iter()
90        .filter_map(|(needle, tag)| lower.contains(needle).then_some(tag))
91        .collect();
92    if tags.is_empty() {
93        tags.push("unclassified_error");
94    }
95    tags
96}
97
98pub fn error_chain(error: &(dyn Error + 'static)) -> Vec<Value> {
99    let mut chain = Vec::new();
100    let mut current = Some(error);
101    for _ in 0..8 {
102        let Some(cause) = current else {
103            break;
104        };
105        let io = cause.downcast_ref::<std::io::Error>();
106        chain.push(json!({
107            "message": redacted(&cause.to_string()),
108            "display_hints": reason_tags(&cause.to_string()),
109            "fingerprint": opaque(&cause.to_string()),
110            "io_kind": io.map(|error| format!("{:?}", error.kind())),
111            "os_error": io.and_then(std::io::Error::raw_os_error),
112            "authority_conflict": cause.is::<bamboo_domain::SessionAuthorityConflict>(),
113        }));
114        current = io
115            .and_then(std::io::Error::get_ref)
116            .map(|inner| inner as &(dyn Error + 'static))
117            .or_else(|| cause.source());
118    }
119    chain
120}
121
122/// Used inside an inherited Workspace span before an existing API flattens a
123/// typed filesystem/Project error. It does not create another operation ID.
124pub fn record_current_error(phase: &'static str, error: &(dyn Error + 'static)) {
125    tracing::warn!(target: "bamboo.workspace_handoff", phase,
126        error_chain = %serde_json::Value::Array(error_chain(error)),
127        "Workspace operation failed before error conversion");
128}
129
130pub struct WorkspaceLogContext<'a> {
131    pub session_id: &'a str,
132    pub run_id: Option<&'a str>,
133    pub request_id: Option<&'a str>,
134    pub diagnostic_id: Option<&'a str>,
135}
136
137/// Preserve the source chain before callers flatten an I/O error into ToolError.
138/// A returned number can be shown to the user and searched in server logs.
139pub fn record_failure(
140    context: WorkspaceLogContext<'_>,
141    phase: &'static str,
142    error: &(dyn Error + 'static),
143) -> String {
144    let id = context
145        .diagnostic_id
146        .map(safe_identifier)
147        .unwrap_or_else(|| format!("WSH-{}", uuid::Uuid::new_v4().simple()));
148    tracing::warn!(target: "bamboo.workspace_handoff",
149        diagnostic_id = %id,
150        session_id = %safe_identifier(context.session_id),
151        run_id = ?context.run_id.map(safe_identifier),
152        request_id = ?context.request_id.map(safe_identifier),
153        phase,
154        error_chain = %serde_json::Value::Array(error_chain(error)),
155        "Execution scope operation rejected");
156    id
157}
158
159pub fn annotated_error(error: ToolError, diagnostic_id: &str) -> ToolError {
160    let annotate = |message: String| {
161        if message.contains(diagnostic_id) {
162            message
163        } else {
164            format!("{message} [{diagnostic_id}]")
165        }
166    };
167    match error {
168        ToolError::Execution(message) => ToolError::Execution(annotate(message)),
169        ToolError::InvalidArguments(message) => ToolError::InvalidArguments(annotate(message)),
170        ToolError::NotFound(message) => ToolError::NotFound(annotate(message)),
171    }
172}