Skip to main content

piw/
protocol.rs

1//! Wire types for the live replay protocol (`pi-workflows.replay.v1`), plus
2//! the JSON Patch subset (with the `append` extension op) used for view
3//! synchronization. See `docs/live-replay-protocol.md`.
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8pub const PROTOCOL_ID: &str = "pi-workflows.replay.v1";
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(tag = "type", rename_all = "snake_case")]
12pub enum ClientMessage {
13    WatchRuns,
14    WatchRun {
15        #[serde(rename = "runId")]
16        run_id: String,
17    },
18    UnwatchRun {
19        #[serde(rename = "runId")]
20        run_id: String,
21    },
22    FetchArtifact {
23        #[serde(rename = "runId")]
24        run_id: String,
25        path: String,
26    },
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(tag = "type", rename_all = "snake_case")]
31pub enum ServerMessage {
32    Hello {
33        protocol: String,
34    },
35    Runs {
36        runs: Vec<Value>,
37    },
38    RunSnapshot {
39        #[serde(rename = "runId")]
40        run_id: String,
41        revision: u64,
42        view: Value,
43    },
44    RunPatch {
45        #[serde(rename = "runId")]
46        run_id: String,
47        revision: u64,
48        patch: Vec<PatchOp>,
49    },
50    Artifact {
51        #[serde(rename = "runId")]
52        run_id: String,
53        path: String,
54        content: String,
55    },
56    Error {
57        message: String,
58        #[serde(rename = "runId", skip_serializing_if = "Option::is_none")]
59        run_id: Option<String>,
60    },
61}
62
63/// RFC 6902 ops we use, plus `append` (add a batch of items to an array).
64#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(tag = "op", rename_all = "snake_case")]
66pub enum PatchOp {
67    Add { path: String, value: Value },
68    Replace { path: String, value: Value },
69    Remove { path: String },
70    Append { path: String, value: Vec<Value> },
71}
72
73/// Apply a patch to a view document in place. Returns an error message on
74/// the first op that does not apply (the caller should resnapshot).
75pub fn apply_patch(target: &mut Value, patch: &[PatchOp]) -> Result<(), String> {
76    for op in patch {
77        match op {
78            PatchOp::Add { path, value } => {
79                set_path(target, path, value.clone(), false)?;
80            }
81            PatchOp::Replace { path, value } => {
82                set_path(target, path, value.clone(), true)?;
83            }
84            PatchOp::Remove { path } => {
85                remove_path(target, path)?;
86            }
87            PatchOp::Append { path, value } => {
88                let array = resolve_path(target, path)?
89                    .as_array_mut()
90                    .ok_or_else(|| format!("append target {path} is not an array"))?;
91                array.extend(value.iter().cloned());
92            }
93        }
94    }
95    Ok(())
96}
97
98fn unescape_token(token: &str) -> String {
99    token.replace("~1", "/").replace("~0", "~")
100}
101
102fn resolve_path<'a>(target: &'a mut Value, path: &str) -> Result<&'a mut Value, String> {
103    let mut current = target;
104    for token in path.split('/').skip(1) {
105        let token = unescape_token(token);
106        current = match current {
107            Value::Object(object) => object
108                .get_mut(&token)
109                .ok_or_else(|| format!("missing key {token} in {path}"))?,
110            Value::Array(array) => {
111                let index: usize = token
112                    .parse()
113                    .map_err(|_| format!("bad array index {token} in {path}"))?;
114                array
115                    .get_mut(index)
116                    .ok_or_else(|| format!("index {index} out of bounds in {path}"))?
117            }
118            _ => return Err(format!("cannot traverse into scalar at {token} in {path}")),
119        };
120    }
121    Ok(current)
122}
123
124fn set_path(target: &mut Value, path: &str, value: Value, replace: bool) -> Result<(), String> {
125    if path.is_empty() {
126        *target = value;
127        return Ok(());
128    }
129    let Some((parent_path, key)) = path.rsplit_once('/') else {
130        return Err(format!("bad path {path}"));
131    };
132    let parent = resolve_path(target, parent_path)?;
133    let key = unescape_token(key);
134    match parent {
135        Value::Object(object) => {
136            if replace && !object.contains_key(&key) {
137                return Err(format!("missing key {key} in {path}"));
138            }
139            object.insert(key, value);
140            Ok(())
141        }
142        Value::Array(array) => {
143            if !replace && key == "-" {
144                array.push(value);
145                return Ok(());
146            }
147            let index: usize = key
148                .parse()
149                .map_err(|_| format!("bad array index {key} in {path}"))?;
150            if replace {
151                let member = array
152                    .get_mut(index)
153                    .ok_or_else(|| format!("index {index} out of bounds in {path}"))?;
154                *member = value;
155                return Ok(());
156            }
157            if index > array.len() {
158                return Err(format!("index {index} out of bounds in {path}"));
159            }
160            array.insert(index, value);
161            Ok(())
162        }
163        _ => Err(format!("cannot set {key} on scalar in {path}")),
164    }
165}
166
167fn remove_path(target: &mut Value, path: &str) -> Result<(), String> {
168    let Some((parent_path, key)) = path.rsplit_once('/') else {
169        return Err(format!("bad path {path}"));
170    };
171    let parent = resolve_path(target, parent_path)?;
172    let key = unescape_token(key);
173    match parent {
174        Value::Object(object) => {
175            object
176                .remove(&key)
177                .ok_or_else(|| format!("missing key {key} in {path}"))?;
178            Ok(())
179        }
180        Value::Array(array) => {
181            let index: usize = key
182                .parse()
183                .map_err(|_| format!("bad array index {key} in {path}"))?;
184            if index >= array.len() {
185                return Err(format!("index {index} out of bounds in {path}"));
186            }
187            array.remove(index);
188            Ok(())
189        }
190        _ => Err(format!("cannot remove {key} from scalar in {path}")),
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use serde_json::json;
198
199    #[test]
200    fn applies_replace_append_and_remove() {
201        let mut view = json!({ "state": { "status": "running" }, "events": [1] });
202        apply_patch(
203            &mut view,
204            &[
205                PatchOp::Replace {
206                    path: "/state/status".into(),
207                    value: json!("completed"),
208                },
209                PatchOp::Append {
210                    path: "/events".into(),
211                    value: vec![json!(2), json!(3)],
212                },
213                PatchOp::Add {
214                    path: "/session".into(),
215                    value: json!({ "binding": null, "entries": [] }),
216                },
217            ],
218        )
219        .unwrap();
220        assert_eq!(
221            view,
222            json!({
223                "state": { "status": "completed" },
224                "events": [1, 2, 3],
225                "session": { "binding": null, "entries": [] }
226            })
227        );
228        apply_patch(
229            &mut view,
230            &[PatchOp::Remove {
231                path: "/session".into(),
232            }],
233        )
234        .unwrap();
235        assert!(view.get("session").is_none());
236    }
237
238    #[test]
239    fn array_add_inserts_but_replace_overwrites() {
240        let mut view = json!({ "events": [1, 2] });
241        apply_patch(
242            &mut view,
243            &[
244                PatchOp::Add {
245                    path: "/events/1".into(),
246                    value: json!(3),
247                },
248                PatchOp::Replace {
249                    path: "/events/0".into(),
250                    value: json!(4),
251                },
252            ],
253        )
254        .unwrap();
255        assert_eq!(view, json!({ "events": [4, 3, 2] }));
256    }
257
258    #[test]
259    fn escapes_json_pointer_tokens() {
260        let mut view = json!({ "a/b": { "c~d": 1 } });
261        apply_patch(
262            &mut view,
263            &[PatchOp::Replace {
264                path: "/a~1b/c~0d".into(),
265                value: json!(2),
266            }],
267        )
268        .unwrap();
269        assert_eq!(view, json!({ "a/b": { "c~d": 2 } }));
270    }
271
272    #[test]
273    fn gap_or_missing_path_errors() {
274        let mut view = json!({ "events": [] });
275        assert!(apply_patch(
276            &mut view,
277            &[PatchOp::Replace {
278                path: "/missing/deep".into(),
279                value: json!(1),
280            }],
281        )
282        .is_err());
283    }
284}