1use 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#[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
73pub 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}