bamboo_engine/runtime/execution/
workspace_diagnostics.rs1use bamboo_agent_core::{Session, ToolError};
4use serde_json::{json, Value};
5use sha2::{Digest, Sha256};
6use std::error::Error;
7use std::sync::OnceLock;
8
9fn 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 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 format!("fence-{}", opaque(&format!("{fence:?}")))
51}
52
53pub fn redacted(value: &str) -> String {
58 format!(
59 "{} <detail:{}>",
60 reason_tags(value).join(","),
61 opaque(value)
62 )
63}
64
65fn 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
122pub 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
137pub 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}