Skip to main content

everruns_capability/
reference.rs

1//! Capability references: identity plus per-agent JSON object configuration.
2
3use serde::de::Deserializer;
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value};
6use std::fmt;
7
8use crate::error::CapabilityError;
9use crate::id::CapabilityId;
10
11/// A reference to a capability implementation plus per-agent JSON.
12///
13/// This is the one semantic model for "a capability attached to an agent":
14/// the Framework activates it, the product persists it (the historical
15/// `AgentCapabilityConfig` attachment row and `BuiltInCapabilityDefinition`
16/// provisioning entry are this type), and worker resolution consumes it. It
17/// serializes as `{"ref": "<id>", "config": {…}}` everywhere.
18///
19/// IDs are open strings rather than variants in a central enum. They must use
20/// a stable identifier made from ASCII letters, digits, `_`, `-`, `.`, or
21/// `:`, start with a letter or `_`, and fit within 128 bytes. The
22/// `__everruns_` namespace is reserved.
23///
24/// Configuration defaults to `{}` and must be a JSON object. Boundaries that
25/// accept new values (Framework agent build, product write paths) enforce
26/// both rules via [`CapabilityRef::validate`]; the referenced implementation
27/// owns the inner schema.
28///
29/// Configuration is redacted from `Debug`, but it is not a secret store:
30/// hosts may persist or otherwise inspect it. Put credentials in a
31/// provider-owned secret mechanism and pass only a non-secret handle here.
32#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct CapabilityRef {
34    #[serde(rename = "ref")]
35    id: CapabilityId,
36    #[serde(default = "empty_object", deserialize_with = "object_or_default")]
37    config: Value,
38}
39
40fn empty_object() -> Value {
41    Value::Object(Map::new())
42}
43
44/// Normalize absent/`null` configuration to `{}` on deserialize so persisted
45/// attachments written before the object boundary was enforced keep loading.
46fn object_or_default<'de, D>(deserializer: D) -> Result<Value, D::Error>
47where
48    D: Deserializer<'de>,
49{
50    let value = Value::deserialize(deserializer)?;
51    Ok(match value {
52        Value::Null => empty_object(),
53        other => other,
54    })
55}
56
57impl fmt::Debug for CapabilityRef {
58    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
59        formatter
60            .debug_struct("CapabilityRef")
61            .field("id", &self.id)
62            .field("config", &"<redacted>")
63            .finish()
64    }
65}
66
67impl CapabilityRef {
68    /// Reference a stable capability ID with default (`{}`) configuration.
69    pub fn new(id: impl Into<CapabilityId>) -> Self {
70        Self {
71            id: id.into(),
72            config: empty_object(),
73        }
74    }
75
76    /// Reference a capability ID with explicit configuration.
77    pub fn with_config(id: impl Into<CapabilityId>, config: Value) -> Self {
78        Self {
79            id: id.into(),
80            config,
81        }
82    }
83
84    /// Attach implementation-defined per-agent JSON configuration.
85    ///
86    /// Validation is deferred to the consuming boundary (e.g. the Framework's
87    /// `AgentBuilder::build`) so capability values remain easy to compose and
88    /// pass through application configuration layers.
89    pub fn config(mut self, config: impl Into<Value>) -> Self {
90        self.config = config.into();
91        self
92    }
93
94    /// The stable capability identifier.
95    pub fn id(&self) -> &str {
96        self.id.as_str()
97    }
98
99    /// The stable capability identifier (alias of [`CapabilityRef::id`]).
100    pub fn capability_id(&self) -> &str {
101        self.id.as_str()
102    }
103
104    /// The typed capability identifier.
105    pub fn typed_id(&self) -> &CapabilityId {
106        &self.id
107    }
108
109    /// The implementation-defined JSON configuration.
110    pub fn config_value(&self) -> &Value {
111        &self.config
112    }
113
114    /// Mutable access to the configuration (host hydration paths).
115    pub fn config_mut(&mut self) -> &mut Value {
116        &mut self.config
117    }
118
119    /// Replace the configuration in place.
120    pub fn set_config(&mut self, config: impl Into<Value>) {
121        self.config = config.into();
122    }
123
124    /// Replace the identifier in place (host alias-canonicalization paths).
125    pub fn set_id(&mut self, id: impl Into<CapabilityId>) {
126        self.id = id.into();
127    }
128
129    /// Split the reference into its identity and configuration.
130    pub fn into_parts(self) -> (CapabilityId, Value) {
131        (self.id, self.config)
132    }
133
134    /// Validate the identifier grammar and the JSON object config boundary.
135    pub fn validate(&self) -> Result<(), CapabilityError> {
136        self.id.validate()?;
137        validate_capability_config(self.id.as_str(), &self.config)
138    }
139}
140
141impl From<CapabilityId> for CapabilityRef {
142    fn from(id: CapabilityId) -> Self {
143        Self::new(id)
144    }
145}
146
147impl From<&str> for CapabilityRef {
148    fn from(id: &str) -> Self {
149        Self::new(id)
150    }
151}
152
153impl From<String> for CapabilityRef {
154    fn from(id: String) -> Self {
155        Self::new(id)
156    }
157}
158
159/// Validate the shared JSON object configuration boundary.
160///
161/// Capability configuration must be a JSON object; the referenced
162/// implementation owns the inner schema.
163pub fn validate_capability_config(id: &str, config: &Value) -> Result<(), CapabilityError> {
164    if config.is_object() {
165        Ok(())
166    } else {
167        Err(CapabilityError::InvalidConfig {
168            id: id.to_string(),
169            reason: "capability config must be a JSON object".to_string(),
170        })
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use serde_json::json;
178
179    #[test]
180    fn new_defaults_to_empty_object() {
181        for cap in [
182            CapabilityRef::new("current_time"),
183            CapabilityId::new("current_time").into(),
184            "current_time".into(),
185            String::from("current_time").into(),
186        ] {
187            assert_eq!(cap.capability_id(), "current_time");
188            assert_eq!(cap.config_value(), &json!({}));
189            assert_eq!(
190                serde_json::to_value(&cap).unwrap(),
191                json!({"ref": "current_time", "config": {}})
192            );
193        }
194    }
195
196    #[test]
197    fn serializes_as_persisted_attachment_shape() {
198        let cap = CapabilityRef::with_config("current_time", json!({"timezone": "UTC"}));
199        let value = serde_json::to_value(&cap).unwrap();
200        assert_eq!(
201            value,
202            json!({"ref": "current_time", "config": {"timezone": "UTC"}})
203        );
204
205        let parsed: CapabilityRef = serde_json::from_value(value).unwrap();
206        assert_eq!(parsed, cap);
207    }
208
209    #[test]
210    fn missing_and_null_config_deserialize_to_empty_object() {
211        let missing: CapabilityRef = serde_json::from_str(r#"{"ref":"noop"}"#).unwrap();
212        assert_eq!(missing.config_value(), &json!({}));
213
214        let null: CapabilityRef = serde_json::from_str(r#"{"ref":"noop","config":null}"#).unwrap();
215        assert_eq!(null.config_value(), &json!({}));
216    }
217
218    #[test]
219    fn debug_redacts_config_values() {
220        let cap = CapabilityRef::with_config(
221            "vendor.search",
222            json!({"api_key": "sk-super-secret", "index": "prod"}),
223        );
224        let debug = format!("{cap:?}");
225        assert!(debug.contains("vendor.search"));
226        assert!(!debug.contains("sk-super-secret"));
227        assert!(!debug.contains("api_key"));
228        assert!(debug.contains("<redacted>"));
229    }
230
231    #[test]
232    fn validate_enforces_id_and_object_boundary() {
233        CapabilityRef::new("current_time").validate().unwrap();
234        assert!(CapabilityRef::new("2fast").validate().is_err());
235        CapabilityRef::with_config("noop", json!({"enabled": true}))
236            .validate()
237            .unwrap();
238        for config in [
239            json!("string"),
240            json!(null),
241            json!([]),
242            json!(42),
243            json!(true),
244        ] {
245            let err = CapabilityRef::with_config("noop", config)
246                .validate()
247                .unwrap_err();
248            assert_eq!(err.id(), "noop");
249            assert!(err.reason().contains("JSON object"));
250        }
251    }
252
253    #[test]
254    fn builder_and_mutation() {
255        let mut cap = CapabilityRef::new("noop").config(json!({"a": 1}));
256        assert_eq!(cap.config_value(), &json!({"a": 1}));
257        cap.set_config(json!({"b": 2}));
258        assert_eq!(cap.config_value(), &json!({"b": 2}));
259        let (id, config) = cap.into_parts();
260        assert_eq!(id.as_str(), "noop");
261        assert_eq!(config, json!({"b": 2}));
262    }
263}