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 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#[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#[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 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}