Skip to main content

devicerail_protocol/
action.rs

1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use uuid::Uuid;
6
7use crate::{ActionExecution, AssetRef, Observation, RequestTimeoutMs};
8
9#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
10#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
11#[serde(rename_all = "camelCase")]
12pub enum ActionProtection {
13    #[default]
14    Standard,
15    Protected,
16}
17
18impl ActionProtection {
19    pub const fn is_standard(&self) -> bool {
20        matches!(self, Self::Standard)
21    }
22}
23
24const fn is_false(value: &bool) -> bool {
25    !*value
26}
27
28#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
29#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
30#[serde(rename_all = "camelCase")]
31pub struct ActionDefinition {
32    pub name: String,
33    pub description: String,
34    // JSON Schema supplied by the driver for this action's argument object.
35    // This stays structurally unconstrained on the wire because the driver
36    // owns action-specific properties. Driver conformance validates the
37    // declared dialect, self-contained references, and object root.
38    pub input_schema: Value,
39    #[serde(default, skip_serializing_if = "ActionProtection::is_standard")]
40    pub protection: ActionProtection,
41}
42
43#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
44#[derive(Clone, PartialEq, Deserialize, Serialize)]
45#[serde(rename_all = "camelCase")]
46pub struct ActionCall {
47    pub id: Uuid,
48    pub name: String,
49    #[serde(default)]
50    pub arguments: Value,
51}
52
53impl fmt::Debug for ActionCall {
54    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
55        formatter
56            .debug_struct("ActionCall")
57            .field("id", &self.id)
58            .field("name", &self.name)
59            .finish_non_exhaustive()
60    }
61}
62
63/// Durable representation of an Action invocation.
64///
65/// Standard calls preserve the historical wire shape. Protected and unknown
66/// calls retain only correlation fields and serialize `arguments` as `null`
67/// with an explicit `argumentsRedacted` marker.
68#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
69#[derive(Clone, PartialEq, Deserialize, Serialize)]
70#[serde(rename_all = "camelCase")]
71pub struct RecordedActionCall {
72    pub id: Uuid,
73    pub name: String,
74    #[serde(default)]
75    pub arguments: Value,
76    #[serde(default, skip_serializing_if = "is_false")]
77    pub arguments_redacted: bool,
78}
79
80impl RecordedActionCall {
81    pub fn from_action_call(call: &ActionCall, protection: Option<ActionProtection>) -> Self {
82        let is_standard = matches!(protection, Some(ActionProtection::Standard));
83        Self {
84            id: call.id,
85            name: call.name.clone(),
86            arguments: if is_standard {
87                call.arguments.clone()
88            } else {
89                Value::Null
90            },
91            arguments_redacted: !is_standard,
92        }
93    }
94}
95
96impl fmt::Debug for RecordedActionCall {
97    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
98        formatter
99            .debug_struct("RecordedActionCall")
100            .field("id", &self.id)
101            .field("name", &self.name)
102            .field("arguments_redacted", &self.arguments_redacted)
103            .finish_non_exhaustive()
104    }
105}
106
107/// Parameters for `device.execute`.
108///
109/// The action fields intentionally remain flat on the wire. The optional
110/// timeout controls only the Driver action, while the request envelope timeout
111/// controls the request-scoped device-operation budget. Durable terminal event
112/// finalization is shielded so cancellation cannot leave a half-open Action.
113#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
114#[derive(Clone, PartialEq, Deserialize, Serialize)]
115#[serde(rename_all = "camelCase", deny_unknown_fields)]
116pub struct DeviceExecuteParams {
117    pub id: Uuid,
118    pub name: String,
119    #[serde(default)]
120    pub arguments: Value,
121    #[serde(
122        default,
123        deserialize_with = "crate::rpc::deserialize_optional_timeout",
124        skip_serializing_if = "Option::is_none"
125    )]
126    #[cfg_attr(feature = "schema", schemars(with = "RequestTimeoutMs"))]
127    pub action_timeout_ms: Option<RequestTimeoutMs>,
128}
129
130impl fmt::Debug for DeviceExecuteParams {
131    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
132        formatter
133            .debug_struct("DeviceExecuteParams")
134            .field("id", &self.id)
135            .field("name", &self.name)
136            .field("action_timeout_ms", &self.action_timeout_ms)
137            .finish_non_exhaustive()
138    }
139}
140
141impl DeviceExecuteParams {
142    pub fn into_action_call(self) -> ActionCall {
143        ActionCall {
144            id: self.id,
145            name: self.name,
146            arguments: self.arguments,
147        }
148    }
149}
150
151#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
152#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
153#[serde(rename_all = "camelCase")]
154pub struct ActionResult {
155    pub call_id: Uuid,
156    #[serde(
157        serialize_with = "crate::wire_integer::serialize_js_safe_u64",
158        deserialize_with = "crate::wire_integer::deserialize_js_safe_u64"
159    )]
160    #[cfg_attr(feature = "schema", schemars(range(max = 9_007_199_254_740_991_u64)))]
161    pub started_at_ms: u64,
162    #[serde(
163        serialize_with = "crate::wire_integer::serialize_js_safe_u64",
164        deserialize_with = "crate::wire_integer::deserialize_js_safe_u64"
165    )]
166    #[cfg_attr(feature = "schema", schemars(range(max = 9_007_199_254_740_991_u64)))]
167    pub finished_at_ms: u64,
168    pub output: Value,
169    pub before: Option<Observation>,
170    pub after: Option<Observation>,
171    #[serde(default)]
172    pub evidence: Vec<AssetRef>,
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub execution: Option<ActionExecution>,
175}
176
177impl ActionResult {
178    /// Returns every typed Evidence reference reachable from this result.
179    pub fn asset_refs(&self) -> impl Iterator<Item = &AssetRef> {
180        self.evidence
181            .iter()
182            .chain(self.before.iter().flat_map(Observation::asset_refs))
183            .chain(self.after.iter().flat_map(Observation::asset_refs))
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use serde_json::{Value, json};
190    use uuid::Uuid;
191
192    use super::{
193        ActionCall, ActionDefinition, ActionProtection, DeviceExecuteParams, RecordedActionCall,
194    };
195    use crate::RequestTimeoutMs;
196
197    #[test]
198    fn execute_params_preserve_the_flat_action_wire_shape() {
199        let value = json!({
200            "id": "00000000-0000-0000-0000-000000000000",
201            "name": "tap",
202            "arguments": { "x": 10, "y": 20 }
203        });
204        let params: DeviceExecuteParams =
205            serde_json::from_value(value.clone()).expect("legacy flat execute params");
206        assert!(params.action_timeout_ms.is_none());
207        let call = params.clone().into_action_call();
208        assert_eq!(call.id, params.id);
209        assert_eq!(call.name, params.name);
210        assert_eq!(call.arguments, params.arguments);
211        assert_eq!(
212            serde_json::to_value(params).expect("serialize execute params"),
213            value
214        );
215    }
216
217    #[test]
218    fn execute_params_validate_timeout_and_unknown_fields() {
219        let valid: DeviceExecuteParams = serde_json::from_value(json!({
220            "id": "00000000-0000-0000-0000-000000000000",
221            "name": "tap",
222            "actionTimeoutMs": RequestTimeoutMs::MAX
223        }))
224        .expect("maximum action timeout");
225        assert_eq!(
226            valid.action_timeout_ms.map(RequestTimeoutMs::get),
227            Some(RequestTimeoutMs::MAX)
228        );
229
230        for timeout in [json!(null), json!(0), json!(RequestTimeoutMs::MAX + 1)] {
231            assert!(
232                serde_json::from_value::<DeviceExecuteParams>(json!({
233                    "id": "00000000-0000-0000-0000-000000000000",
234                    "name": "tap",
235                    "actionTimeoutMs": timeout
236                }))
237                .is_err()
238            );
239        }
240        assert!(
241            serde_json::from_value::<DeviceExecuteParams>(json!({
242                "id": "00000000-0000-0000-0000-000000000000",
243                "name": "tap",
244                "timeoutMs": 100
245            }))
246            .is_err()
247        );
248    }
249
250    #[test]
251    fn protection_is_additive_and_standard_preserves_the_legacy_wire_shape() {
252        let standard = ActionDefinition {
253            name: "tap".to_owned(),
254            description: "Tap".to_owned(),
255            input_schema: json!({ "type": "object" }),
256            protection: ActionProtection::Standard,
257        };
258        assert_eq!(
259            serde_json::to_value(&standard).expect("standard definition"),
260            json!({
261                "name": "tap",
262                "description": "Tap",
263                "inputSchema": { "type": "object" }
264            })
265        );
266        let restored: ActionDefinition = serde_json::from_value(json!({
267            "name": "tap",
268            "description": "Tap",
269            "inputSchema": { "type": "object" }
270        }))
271        .expect("legacy definition");
272        assert_eq!(restored.protection, ActionProtection::Standard);
273
274        let protected = ActionDefinition {
275            protection: ActionProtection::Protected,
276            ..standard
277        };
278        assert_eq!(
279            serde_json::to_value(protected).expect("protected definition")["protection"],
280            "protected"
281        );
282    }
283
284    #[test]
285    fn recorded_calls_preserve_standard_arguments_and_explicitly_redact_protected_or_unknown() {
286        let call = ActionCall {
287            id: Uuid::nil(),
288            name: "inputSecret".to_owned(),
289            arguments: json!({ "text": "DEVICERAIL_SECRET_SENTINEL" }),
290        };
291        let standard =
292            RecordedActionCall::from_action_call(&call, Some(ActionProtection::Standard));
293        assert_eq!(
294            serde_json::to_value(standard).expect("standard call"),
295            json!({
296                "id": Uuid::nil(),
297                "name": "inputSecret",
298                "arguments": { "text": "DEVICERAIL_SECRET_SENTINEL" }
299            })
300        );
301
302        for protection in [Some(ActionProtection::Protected), None] {
303            let recorded = RecordedActionCall::from_action_call(&call, protection);
304            assert!(recorded.arguments.is_null());
305            assert!(recorded.arguments_redacted);
306            let value = serde_json::to_value(recorded).expect("redacted call");
307            assert_eq!(value["arguments"], json!(null));
308            assert_eq!(value["argumentsRedacted"], true);
309            assert!(!value.to_string().contains("DEVICERAIL_SECRET_SENTINEL"));
310        }
311
312        let standard_null = RecordedActionCall::from_action_call(
313            &ActionCall {
314                id: Uuid::nil(),
315                name: "tap".to_owned(),
316                arguments: Value::Null,
317            },
318            Some(ActionProtection::Standard),
319        );
320        let encoded = serde_json::to_value(&standard_null).expect("standard null call");
321        assert_eq!(encoded["arguments"], Value::Null);
322        assert!(encoded.get("argumentsRedacted").is_none());
323        let decoded: RecordedActionCall =
324            serde_json::from_value(encoded).expect("standard null call round trip");
325        assert!(decoded.arguments.is_null());
326        assert!(!decoded.arguments_redacted);
327
328        let legacy_missing: RecordedActionCall = serde_json::from_value(json!({
329            "id": Uuid::nil(),
330            "name": "tap"
331        }))
332        .expect("legacy missing arguments remain accepted");
333        assert!(legacy_missing.arguments.is_null());
334        assert!(!legacy_missing.arguments_redacted);
335        assert_eq!(
336            serde_json::to_value(legacy_missing).expect("normalize legacy call")["arguments"],
337            Value::Null
338        );
339    }
340
341    #[test]
342    fn action_debug_views_never_render_argument_values() {
343        const SENTINEL: &str = "DEVICERAIL_SECRET_DEBUG_SENTINEL";
344        let call = ActionCall {
345            id: Uuid::nil(),
346            name: "inputSecret".to_owned(),
347            arguments: json!({ "text": SENTINEL }),
348        };
349        let params = DeviceExecuteParams {
350            id: call.id,
351            name: call.name.clone(),
352            arguments: call.arguments.clone(),
353            action_timeout_ms: None,
354        };
355        assert!(!format!("{call:?}").contains(SENTINEL));
356        assert!(!format!("{params:?}").contains(SENTINEL));
357    }
358}