Skip to main content

pixelactions_core/
protocol.rs

1//! The line protocol: one JSON object per line, in and out.
2//!
3//! This is what makes a bot in *any* language possible without this
4//! crate knowing anything about that language. Every language can spawn
5//! a process and write lines to a pipe; none of them need FFI, native
6//! modules, or a package we have to release in lockstep.
7//!
8//! The framing is the one LSP, esbuild, and MCP all converged on:
9//!
10//! - one JSON object per line, no embedded newlines
11//! - **stdout carries only protocol messages; stderr is for logs** — a
12//!   client must never read stderr output as failure
13//! - closing stdin is the graceful shutdown; there is no daemon, no PID
14//!   file, and no lifetime for anyone to manage
15//! - a version handshake on the first exchange, so changing the protocol
16//!   later does not break every existing bot (ripgrep's `--json` shipped
17//!   without one, and that is the cautionary tale)
18//!
19//! Requests carry an `id` that responses echo. Only one request is in
20//! flight at a time today — that keeps the implementation free of an
21//! async runtime — but the `id` means concurrency can arrive later
22//! without a breaking change.
23
24use serde::{Deserialize, Serialize};
25
26use crate::convert::ResolvedPoint;
27use crate::flow::{Settings, Step};
28
29/// The protocol version this build speaks. Bumped only for changes a
30/// client could notice.
31pub const PROTOCOL_VERSION: u32 = 1;
32
33/// A request from the client.
34#[derive(Debug, Clone, PartialEq)]
35pub struct Request {
36    /// Echoed on the response. Any value the client finds useful.
37    pub id: Option<u64>,
38    pub body: RequestBody,
39}
40
41/// What the client wants done.
42///
43/// `do` names either a control message or a step, using the same
44/// vocabulary as flow files and chained argv — so `{"do":"click"}` is
45/// the wire form of `action = "click"`. Because one key names both
46/// kinds, the two are separated by hand in [`parse_request`] rather
47/// than by a derive; the payoff is that a client writes the verb it
48/// already knows instead of nesting it under a wrapper.
49#[derive(Debug, Clone, PartialEq)]
50pub enum RequestBody {
51    /// First message: agree on a version before anything else happens,
52    /// and optionally set the run settings for the whole session — the
53    /// same fields a flow file's `[settings]` table carries, so there is
54    /// nothing new to learn and no CLI flag to keep in sync.
55    Hello {
56        version: u32,
57        settings: Option<Settings>,
58    },
59    /// Perform one step.
60    Step { step: Step },
61    /// Re-locate every region and report where they are, without acting.
62    Relocate,
63    /// End the session. Closing stdin does the same thing.
64    Bye,
65}
66
67/// A response to the client. Always exactly one per request.
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69pub struct Response {
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub id: Option<u64>,
72    #[serde(flatten)]
73    pub body: ResponseBody,
74}
75
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77#[serde(tag = "result", rename_all = "snake_case")]
78pub enum ResponseBody {
79    /// Answer to `hello`: what this build speaks and what it can do.
80    Welcome {
81        version: u32,
82        /// Step names this build understands, so a client can degrade
83        /// gracefully instead of guessing.
84        verbs: Vec<String>,
85        session: String,
86    },
87    /// A step ran. `outcome` is the same vocabulary a run report uses:
88    /// `verified` · `executed` · `failed`.
89    ///
90    /// A step that ran and failed honestly is a `done` with
91    /// `outcome: "failed"` and a `detail`; a request that could not be
92    /// understood or resolved is an `error`. That is the same line the
93    /// exit codes draw between 1 and 2.
94    Done {
95        outcome: String,
96        #[serde(skip_serializing_if = "Vec::is_empty", default)]
97        points: Vec<ResolvedPoint>,
98        /// Why it failed, in words — including the evidence a timeout
99        /// gathered. Absent when nothing went wrong.
100        #[serde(skip_serializing_if = "Option::is_none", default)]
101        detail: Option<String>,
102        elapsed_ms: u64,
103    },
104    /// Regions were re-located.
105    Located {
106        /// Labels whose current position differs from the session.
107        moved: Vec<String>,
108        /// Labels that could not be found unambiguously — acting on
109        /// these would be acting blind.
110        missing: Vec<String>,
111    },
112    /// Acknowledgment of `bye`. The last line before stdout closes.
113    Closed,
114    /// The request could not be honored. `detail` is written for a human
115    /// to read and act on, not for a stack trace.
116    Error { detail: String },
117}
118
119impl Response {
120    pub fn error(id: Option<u64>, detail: impl Into<String>) -> Self {
121        Self {
122            id,
123            body: ResponseBody::Error {
124                detail: detail.into(),
125            },
126        }
127    }
128
129    /// Serialize to a single line, newline included.
130    ///
131    /// Serialization cannot fail for these types, but a client would be
132    /// left hanging if it ever did — so a failure becomes a protocol
133    /// error rather than a panic or a silent drop.
134    pub fn to_line(&self) -> String {
135        match serde_json::to_string(self) {
136            Ok(json) => format!("{json}\n"),
137            Err(error) => format!(
138                "{{\"result\":\"error\",\"detail\":\"could not serialize response: {error}\"}}\n"
139            ),
140        }
141    }
142}
143
144/// Best-effort read of a line's `id`, for correlating an error with the
145/// request that caused it.
146///
147/// [`parse_request`] fails before it can build a `Request`, which would
148/// otherwise leave a client with several requests outstanding unable to
149/// tell which one was rejected. A line too broken to yield an `id` gets
150/// `None` — the one case where a client has to fall back to order.
151pub fn id_in(line: &str) -> Option<u64> {
152    serde_json::from_str::<serde_json::Value>(line)
153        .ok()?
154        .get("id")?
155        .as_u64()
156}
157
158/// Parse one line into a request.
159///
160/// Control verbs are recognized first; anything else is handed to the
161/// step deserializer under its own tag, so step shapes stay defined in
162/// exactly one place (`flow::Step`) and can never drift from the flow
163/// file format.
164pub fn parse_request(line: &str) -> Result<Request, String> {
165    let mut value: serde_json::Value = serde_json::from_str(line).map_err(|error| {
166        format!(
167            "not a valid request: {error}. \
168             Expected one JSON object per line, e.g. \
169             {{\"id\":1,\"do\":\"click\",\"target\":\"submit\"}}"
170        )
171    })?;
172    let object = value
173        .as_object_mut()
174        .ok_or_else(|| "a request must be a JSON object".to_string())?;
175
176    let id = object.get("id").and_then(serde_json::Value::as_u64);
177    let verb = object
178        .remove("do")
179        .ok_or_else(|| "a request needs a \"do\" naming the action, e.g. \"click\"".to_string())?;
180    let verb = verb
181        .as_str()
182        .ok_or_else(|| "\"do\" must be a string".to_string())?
183        .to_string();
184
185    let body = match verb.as_str() {
186        "hello" => {
187            let version = object
188                .get("version")
189                .and_then(serde_json::Value::as_u64)
190                .and_then(|version| u32::try_from(version).ok())
191                .ok_or_else(|| {
192                    format!(
193                        "hello needs a \"version\" number; \
194                         this build speaks version {PROTOCOL_VERSION}"
195                    )
196                })?;
197            let settings = match object.remove("settings") {
198                None => None,
199                Some(value) => {
200                    Some(serde_json::from_value::<Settings>(value).map_err(|error| {
201                        format!("hello carried settings this build cannot read: {error}")
202                    })?)
203                }
204            };
205            RequestBody::Hello { version, settings }
206        }
207        "relocate" => RequestBody::Relocate,
208        "bye" => RequestBody::Bye,
209        step_name => {
210            object.remove("id");
211            object.insert(
212                "action".to_string(),
213                serde_json::Value::String(step_name.to_string()),
214            );
215            // serde already names every known action on an unknown
216            // variant, and names the missing key on a malformed one —
217            // appending our own list would only say it twice.
218            let step: Step = serde_json::from_value(value)
219                .map_err(|error| format!("cannot read {step_name:?} as an action: {error}"))?;
220            RequestBody::Step { step }
221        }
222    };
223    Ok(Request { id, body })
224}
225
226/// Every step name this build understands — the handshake's answer to
227/// "what can you do".
228///
229/// **This must list every [`Step`] variant.** A client is told to read it
230/// and degrade gracefully instead of guessing, so a verb missing here is a
231/// verb nobody sends — the executor runs it fine, but the handshake said it
232/// could not. `every_step_variant_is_advertised` is what keeps the two in
233/// step, and it is an exhaustive match so a new variant will not compile
234/// until it is added here.
235pub fn supported_verbs() -> Vec<String> {
236    [
237        "click",
238        "double_click",
239        "drag",
240        "scroll",
241        "type",
242        "key",
243        "verify",
244        "wait_for",
245        "wait_gone",
246        "changed",
247        "pause",
248    ]
249    .iter()
250    .map(|s| (*s).to_string())
251    .collect()
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::flow::Axis;
258
259    /// The handshake advertised nine verbs while the executor ran eleven:
260    /// `scroll` and `changed` were missing, so a client that trusted the
261    /// list — which the protocol tells it to do — would never send either,
262    /// though both worked if it guessed.
263    ///
264    /// The match is exhaustive on purpose. A twelfth variant does not
265    /// compile until someone decides what the handshake calls it.
266    #[test]
267    fn every_step_variant_is_advertised() {
268        let one_of_each = [
269            Step::Click {
270                target: String::new(),
271            },
272            Step::DoubleClick {
273                target: String::new(),
274            },
275            Step::Type {
276                text: String::new(),
277            },
278            Step::Key {
279                chord: String::new(),
280            },
281            Step::Drag {
282                from: String::new(),
283                to: String::new(),
284            },
285            Step::Scroll {
286                target: String::new(),
287                amount: 0,
288                axis: Axis::Vertical,
289            },
290            Step::Verify {
291                target: String::new(),
292            },
293            Step::WaitFor {
294                target: String::new(),
295            },
296            Step::WaitGone {
297                target: String::new(),
298            },
299            Step::Changed {
300                target: String::new(),
301                tolerance: 0.0,
302            },
303            Step::Pause { ms: 0 },
304        ];
305        let advertised = supported_verbs();
306
307        for step in one_of_each {
308            // The wire name is whatever serde tags it with, not a second
309            // list written by hand — the point is to compare the handshake
310            // against the vocabulary itself.
311            let value = serde_json::to_value(&step).expect("a step serialises");
312            let name = value["action"]
313                .as_str()
314                .expect("a tagged action")
315                .to_string();
316            assert!(
317                advertised.contains(&name),
318                "the executor runs {name:?} but the handshake does not offer it: {advertised:?}"
319            );
320        }
321    }
322
323    /// And nothing is advertised that cannot be read back — a name in the
324    /// list that no variant answers to would be the same bug pointing the
325    /// other way.
326    #[test]
327    fn every_advertised_verb_is_a_real_step() {
328        for verb in supported_verbs() {
329            let request = format!(r#"{{"do":"{verb}"}}"#);
330            let Err(error) = parse_request(&request) else {
331                continue;
332            };
333            assert!(
334                !error.contains("unknown"),
335                "{verb:?} is advertised but not understood: {error}"
336            );
337        }
338    }
339
340    #[test]
341    fn a_step_request_reads_the_same_vocabulary_as_flows() {
342        let request = parse_request(r#"{"id":7,"do":"click","target":"submit"}"#).expect("valid");
343        assert_eq!(request.id, Some(7));
344        assert_eq!(
345            request.body,
346            RequestBody::Step {
347                step: Step::Click {
348                    target: "submit".into()
349                }
350            }
351        );
352    }
353
354    #[test]
355    fn every_step_kind_crosses_the_wire() {
356        for (line, expected) in [
357            (
358                r#"{"do":"type","text":"hello"}"#,
359                Step::Type {
360                    text: "hello".into(),
361                },
362            ),
363            (
364                r#"{"do":"key","chord":"cmd+s"}"#,
365                Step::Key {
366                    chord: "cmd+s".into(),
367                },
368            ),
369            (r#"{"do":"pause","ms":250}"#, Step::Pause { ms: 250 }),
370            (
371                r#"{"do":"drag","from":"a","to":"b"}"#,
372                Step::Drag {
373                    from: "a".into(),
374                    to: "b".into(),
375                },
376            ),
377            (
378                r#"{"do":"wait_for","target":"dialog"}"#,
379                Step::WaitFor {
380                    target: "dialog".into(),
381                },
382            ),
383        ] {
384            let request = parse_request(line).expect("valid");
385            assert_eq!(
386                request.body,
387                RequestBody::Step { step: expected },
388                "line: {line}"
389            );
390        }
391    }
392
393    #[test]
394    fn an_id_is_optional_and_echoed_when_present() {
395        assert_eq!(
396            parse_request(r#"{"do":"relocate"}"#).expect("valid").id,
397            None
398        );
399        let response = Response::error(Some(3), "nope");
400        assert!(response.to_line().contains("\"id\":3"));
401    }
402
403    #[test]
404    fn a_response_is_exactly_one_line() {
405        let response = Response {
406            id: Some(1),
407            body: ResponseBody::Done {
408                outcome: "verified".into(),
409                points: Vec::new(),
410                detail: None,
411                elapsed_ms: 12,
412            },
413        };
414        let line = response.to_line();
415        assert_eq!(line.matches('\n').count(), 1, "exactly one newline");
416        assert!(line.ends_with('\n'));
417        assert!(!line.trim_end().contains('\n'), "no embedded newlines");
418    }
419
420    #[test]
421    fn garbage_becomes_a_readable_error_not_a_crash() {
422        let error = parse_request("not json at all").expect_err("should fail");
423        assert!(
424            error.contains("one JSON object per line"),
425            "teaches the shape: {error}"
426        );
427    }
428
429    #[test]
430    fn an_unknown_verb_is_refused_rather_than_guessed() {
431        assert!(parse_request(r#"{"do":"teleport","target":"home"}"#).is_err());
432    }
433
434    #[test]
435    fn a_request_without_a_verb_says_so() {
436        let error = parse_request(r#"{"id":1}"#).expect_err("no verb");
437        assert!(error.contains("\"do\""), "names the missing key: {error}");
438    }
439
440    #[test]
441    fn a_malformed_step_names_the_field_it_wanted() {
442        // Right verb, wrong shape: click needs a target.
443        let error = parse_request(r#"{"do":"click"}"#).expect_err("missing target");
444        assert!(error.contains("target"), "names the missing field: {error}");
445    }
446
447    #[test]
448    fn an_unknown_verb_lists_the_ones_that_exist() {
449        let error = parse_request(r#"{"do":"teleport"}"#).expect_err("unknown");
450        assert!(error.contains("wait_for"), "lists what exists: {error}");
451    }
452
453    #[test]
454    fn the_handshake_advertises_what_this_build_can_do() {
455        let request = parse_request(r#"{"id":0,"do":"hello","version":1}"#).expect("valid");
456        assert_eq!(
457            request.body,
458            RequestBody::Hello {
459                version: PROTOCOL_VERSION,
460                settings: None
461            }
462        );
463        let verbs = supported_verbs();
464        assert!(verbs.contains(&"click".to_string()));
465        assert!(verbs.contains(&"wait_for".to_string()));
466    }
467
468    #[test]
469    fn a_handshake_can_carry_run_settings() {
470        let request =
471            parse_request(r#"{"do":"hello","version":1,"settings":{"timeout_ms":30000}}"#)
472                .expect("valid");
473        let RequestBody::Hello { settings, .. } = request.body else {
474            panic!("expected a hello");
475        };
476        let settings = settings.expect("settings were sent");
477        assert_eq!(settings.timeout_ms, 30_000);
478        // Unnamed fields keep their defaults rather than becoming zero.
479        assert!(settings.relocate);
480    }
481
482    #[test]
483    fn a_handshake_without_a_version_says_which_one_we_speak() {
484        let error = parse_request(r#"{"do":"hello"}"#).expect_err("no version");
485        assert!(
486            error.contains(&PROTOCOL_VERSION.to_string()),
487            "names the version to send: {error}"
488        );
489    }
490
491    #[test]
492    fn a_typo_in_settings_is_an_error_not_a_silent_default() {
493        // Our own config is parsed strictly — see AGENTS.md.
494        let error = parse_request(r#"{"do":"hello","version":1,"settings":{"timeout":1}}"#)
495            .expect_err("unknown field");
496        assert!(error.contains("settings"), "says where: {error}");
497    }
498
499    #[test]
500    fn a_failed_step_carries_its_reason() {
501        let response = Response {
502            id: None,
503            body: ResponseBody::Done {
504                outcome: "failed".into(),
505                points: Vec::new(),
506                detail: Some("timed out after 10000ms".into()),
507                elapsed_ms: 10_004,
508            },
509        };
510        assert!(response.to_line().contains("timed out"));
511    }
512
513    #[test]
514    fn a_rejected_request_can_still_be_matched_to_its_id() {
515        // The step is malformed, so parsing fails — but a client with
516        // several requests in flight still needs to know which one.
517        assert_eq!(id_in(r#"{"id":42,"do":"click"}"#), Some(42));
518        assert_eq!(id_in(r#"{"do":"click"}"#), None);
519        assert_eq!(id_in("not json"), None);
520    }
521
522    #[test]
523    fn responses_round_trip() {
524        let response = Response {
525            id: Some(9),
526            body: ResponseBody::Located {
527                moved: vec!["submit".into()],
528                missing: Vec::new(),
529            },
530        };
531        let line = response.to_line();
532        let back: Response = serde_json::from_str(line.trim_end()).expect("round trip");
533        assert_eq!(back, response);
534    }
535}