Skip to main content

piw/
protocol.rs

1//! Strict version-1 client envelopes shared by local and remote piw transports.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6pub const PROTOCOL_ID: &str = "pi-workflows.client.v1";
7
8const OPERATIONS: &[&str] = &[
9    "run.start",
10    "run.pause",
11    "run.resume",
12    "run.cancel",
13    "run.status",
14    "run.list",
15    "checkpoint.answer",
16    "decision.answer",
17    "interaction.submit",
18    "interaction.update",
19    "notification.claim",
20    "notification.deliver",
21    "turn.claim",
22    "turn.resolve",
23    "controller.list",
24    "controller.get",
25    "controller.apply",
26    "controller.reconcile",
27    "controller.delete",
28    "host.status",
29    "host.stop",
30    "view.runs.watch",
31    "view.runs.page",
32    "view.run.get",
33    "view.run.watch",
34    "view.run.unwatch",
35    "view.page",
36    "view.content",
37    "view.session.watch",
38    "activity.report",
39    "state.status",
40    "state.verify",
41    "state.backup",
42    "state.prune",
43];
44
45const OUTCOMES: &[&str] = &[
46    "accepted",
47    "adopted",
48    "rejected",
49    "conflict",
50    "notFound",
51    "claimLost",
52    "unavailable",
53];
54
55const EVENTS: &[&str] = &[
56    "runs",
57    "run_snapshot",
58    "run_patch",
59    "run_page",
60    "session_snapshot",
61    "unavailable",
62];
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct ClientRequest {
67    pub schema: String,
68    #[serde(rename = "type")]
69    pub message_type: String,
70    #[serde(rename = "requestId")]
71    pub request_id: String,
72    #[serde(rename = "clientId")]
73    pub client_id: String,
74    pub operation: String,
75    #[serde(rename = "idempotencyKey")]
76    pub idempotency_key: String,
77    #[serde(rename = "runId", skip_serializing_if = "Option::is_none")]
78    pub run_id: Option<String>,
79    #[serde(rename = "expectedRevision", skip_serializing_if = "Option::is_none")]
80    pub expected_revision: Option<u64>,
81    pub payload: Value,
82}
83
84#[derive(Debug, Clone, Deserialize)]
85#[serde(deny_unknown_fields)]
86pub struct ServerHello {
87    pub schema: String,
88    #[serde(rename = "type")]
89    pub message_type: String,
90    #[serde(rename = "connectionId")]
91    pub connection_id: String,
92    #[serde(rename = "packageVersion")]
93    pub package_version: String,
94}
95
96#[derive(Debug, Clone, Deserialize)]
97#[serde(deny_unknown_fields)]
98pub struct ServerResponse {
99    pub schema: String,
100    #[serde(rename = "type")]
101    pub message_type: String,
102    #[serde(rename = "requestId")]
103    pub request_id: String,
104    pub outcome: String,
105    pub revision: Option<u64>,
106    pub receipt: Option<Value>,
107    pub error: Option<String>,
108}
109
110#[derive(Debug, Clone, Deserialize)]
111#[serde(deny_unknown_fields)]
112pub struct ServerEvent {
113    pub schema: String,
114    #[serde(rename = "type")]
115    pub message_type: String,
116    #[serde(rename = "subscriptionId")]
117    pub subscription_id: String,
118    pub event: String,
119    pub revision: Option<u64>,
120    #[serde(rename = "runId")]
121    pub run_id: Option<String>,
122    pub payload: Value,
123}
124
125#[derive(Debug, Clone)]
126pub enum ServerMessage {
127    Hello(ServerHello),
128    Response(ServerResponse),
129    Event(ServerEvent),
130}
131
132pub fn parse_client_request(text: &str) -> Result<ClientRequest, String> {
133    let value = parse_canonical_value(text)?;
134    let request: ClientRequest =
135        serde_json::from_value(value).map_err(|error| error.to_string())?;
136    if request.schema != PROTOCOL_ID
137        || request.message_type != "request"
138        || !valid_id(&request.request_id)
139        || !valid_id(&request.client_id)
140        || !valid_id(&request.idempotency_key)
141        || request
142            .run_id
143            .as_deref()
144            .is_some_and(|value| !valid_id(value))
145        || !OPERATIONS.contains(&request.operation.as_str())
146    {
147        return Err("invalid client request".to_string());
148    }
149    Ok(request)
150}
151
152pub fn parse_server_message(text: &str) -> Result<ServerMessage, String> {
153    let value = parse_canonical_value(text)?;
154    let message_type = value
155        .get("type")
156        .and_then(Value::as_str)
157        .ok_or_else(|| "client message has no type".to_string())?;
158    let message = match message_type {
159        "hello" => {
160            ServerMessage::Hello(serde_json::from_value(value).map_err(|error| error.to_string())?)
161        }
162        "response" => ServerMessage::Response(
163            serde_json::from_value(value).map_err(|error| error.to_string())?,
164        ),
165        "event" => {
166            ServerMessage::Event(serde_json::from_value(value).map_err(|error| error.to_string())?)
167        }
168        _ => return Err("invalid client message type".to_string()),
169    };
170    match &message {
171        ServerMessage::Hello(value)
172            if value.schema != PROTOCOL_ID
173                || value.message_type != "hello"
174                || !valid_id(&value.connection_id)
175                || value.package_version.is_empty() =>
176        {
177            Err("invalid client hello".to_string())
178        }
179        ServerMessage::Response(value)
180            if value.schema != PROTOCOL_ID
181                || value.message_type != "response"
182                || !valid_id(&value.request_id)
183                || !OUTCOMES.contains(&value.outcome.as_str())
184                || value.error.as_deref().is_some_and(str::is_empty) =>
185        {
186            Err("invalid client response".to_string())
187        }
188        ServerMessage::Event(value)
189            if value.schema != PROTOCOL_ID
190                || value.message_type != "event"
191                || !valid_id(&value.subscription_id)
192                || value
193                    .run_id
194                    .as_deref()
195                    .is_some_and(|value| !valid_id(value))
196                || !EVENTS.contains(&value.event.as_str()) =>
197        {
198            Err("invalid client event".to_string())
199        }
200        _ => Ok(message),
201    }
202}
203
204fn parse_canonical_value(text: &str) -> Result<Value, String> {
205    if text.len() > 1024 * 1024 {
206        return Err("client message exceeds 1 MiB".to_string());
207    }
208    let value: Value = serde_json::from_str(text).map_err(|error| error.to_string())?;
209    if canonical_json(&value)? != text {
210        return Err("client message is not canonical JSON".to_string());
211    }
212    Ok(value)
213}
214
215fn valid_id(value: &str) -> bool {
216    !value.is_empty() && value.len() <= 256
217}
218
219pub fn encode_request(request: &ClientRequest) -> Result<String, String> {
220    let value = serde_json::to_value(request).map_err(|error| error.to_string())?;
221    canonical_json(&value)
222}
223
224pub fn canonical_json(value: &Value) -> Result<String, String> {
225    let mut output = String::new();
226    write_canonical_json(value, &mut output)?;
227    Ok(output)
228}
229
230fn write_canonical_json(value: &Value, output: &mut String) -> Result<(), String> {
231    match value {
232        Value::Null => output.push_str("null"),
233        Value::Bool(value) => output.push_str(if *value { "true" } else { "false" }),
234        Value::Number(value) => output.push_str(&canonical_number(value)?),
235        Value::String(value) => output.push_str(
236            &serde_json::to_string(value)
237                .map_err(|error| format!("invalid JSON string: {error}"))?,
238        ),
239        Value::Array(values) => {
240            output.push('[');
241            for (index, value) in values.iter().enumerate() {
242                if index > 0 {
243                    output.push(',');
244                }
245                write_canonical_json(value, output)?;
246            }
247            output.push(']');
248        }
249        Value::Object(object) => {
250            output.push('{');
251            let mut keys = object.keys().collect::<Vec<_>>();
252            keys.sort_by(|left, right| left.encode_utf16().cmp(right.encode_utf16()));
253            for (index, key) in keys.into_iter().enumerate() {
254                if index > 0 {
255                    output.push(',');
256                }
257                output.push_str(
258                    &serde_json::to_string(key)
259                        .map_err(|error| format!("invalid JSON key: {error}"))?,
260                );
261                output.push(':');
262                write_canonical_json(&object[key], output)?;
263            }
264            output.push('}');
265        }
266    }
267    Ok(())
268}
269
270fn canonical_number(value: &serde_json::Number) -> Result<String, String> {
271    if let Some(value) = value.as_i64() {
272        return Ok(value.to_string());
273    }
274    if let Some(value) = value.as_u64() {
275        return Ok(value.to_string());
276    }
277    let value = value
278        .as_f64()
279        .filter(|value| value.is_finite())
280        .ok_or_else(|| "JSON number is not finite".to_string())?;
281    let mut buffer = ryu_js::Buffer::new();
282    Ok(buffer.format_finite(value).to_string())
283}
284
285#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
286#[serde(rename_all = "snake_case")]
287pub enum PageKind {
288    Steps,
289    Trace,
290    TraceAtStep,
291    SessionEntries,
292    SessionEvents,
293    Settings,
294    FollowUps,
295    Updates,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct TargetPatch {
300    #[serde(rename = "targetType")]
301    pub target_type: String,
302    #[serde(rename = "targetKey")]
303    pub target_key: String,
304    pub patch: Vec<PatchOp>,
305}
306
307/// RFC 6902 ops we use, plus `append` (add a batch of items to an array).
308#[derive(Debug, Clone, Serialize, Deserialize)]
309#[serde(tag = "op", rename_all = "snake_case")]
310pub enum PatchOp {
311    Add { path: String, value: Value },
312    Replace { path: String, value: Value },
313    Remove { path: String },
314    Append { path: String, value: Vec<Value> },
315}
316
317/// Apply a patch to a view document in place. Returns an error message on
318/// the first op that does not apply (the caller should resnapshot).
319pub fn apply_patch(target: &mut Value, patch: &[PatchOp]) -> Result<(), String> {
320    for op in patch {
321        match op {
322            PatchOp::Add { path, value } => {
323                set_path(target, path, value.clone(), false)?;
324            }
325            PatchOp::Replace { path, value } => {
326                set_path(target, path, value.clone(), true)?;
327            }
328            PatchOp::Remove { path } => {
329                remove_path(target, path)?;
330            }
331            PatchOp::Append { path, value } => {
332                let array = resolve_path(target, path)?
333                    .as_array_mut()
334                    .ok_or_else(|| format!("append target {path} is not an array"))?;
335                array.extend(value.iter().cloned());
336            }
337        }
338    }
339    Ok(())
340}
341
342fn unescape_token(token: &str) -> String {
343    token.replace("~1", "/").replace("~0", "~")
344}
345
346fn resolve_path<'a>(target: &'a mut Value, path: &str) -> Result<&'a mut Value, String> {
347    let mut current = target;
348    for token in path.split('/').skip(1) {
349        let token = unescape_token(token);
350        current = match current {
351            Value::Object(object) => object
352                .get_mut(&token)
353                .ok_or_else(|| format!("missing key {token} in {path}"))?,
354            Value::Array(array) => {
355                let index: usize = token
356                    .parse()
357                    .map_err(|_| format!("bad array index {token} in {path}"))?;
358                array
359                    .get_mut(index)
360                    .ok_or_else(|| format!("index {index} out of bounds in {path}"))?
361            }
362            _ => return Err(format!("cannot traverse into scalar at {token} in {path}")),
363        };
364    }
365    Ok(current)
366}
367
368fn set_path(target: &mut Value, path: &str, value: Value, replace: bool) -> Result<(), String> {
369    if path.is_empty() {
370        *target = value;
371        return Ok(());
372    }
373    let Some((parent_path, key)) = path.rsplit_once('/') else {
374        return Err(format!("bad path {path}"));
375    };
376    let parent = resolve_path(target, parent_path)?;
377    let key = unescape_token(key);
378    match parent {
379        Value::Object(object) => {
380            if replace && !object.contains_key(&key) {
381                return Err(format!("missing key {key} in {path}"));
382            }
383            object.insert(key, value);
384            Ok(())
385        }
386        Value::Array(array) => {
387            if !replace && key == "-" {
388                array.push(value);
389                return Ok(());
390            }
391            let index: usize = key
392                .parse()
393                .map_err(|_| format!("bad array index {key} in {path}"))?;
394            if replace {
395                let member = array
396                    .get_mut(index)
397                    .ok_or_else(|| format!("index {index} out of bounds in {path}"))?;
398                *member = value;
399                return Ok(());
400            }
401            if index > array.len() {
402                return Err(format!("index {index} out of bounds in {path}"));
403            }
404            array.insert(index, value);
405            Ok(())
406        }
407        _ => Err(format!("cannot set {key} on scalar in {path}")),
408    }
409}
410
411fn remove_path(target: &mut Value, path: &str) -> Result<(), String> {
412    let Some((parent_path, key)) = path.rsplit_once('/') else {
413        return Err(format!("bad path {path}"));
414    };
415    let parent = resolve_path(target, parent_path)?;
416    let key = unescape_token(key);
417    match parent {
418        Value::Object(object) => {
419            object
420                .remove(&key)
421                .ok_or_else(|| format!("missing key {key} in {path}"))?;
422            Ok(())
423        }
424        Value::Array(array) => {
425            let index: usize = key
426                .parse()
427                .map_err(|_| format!("bad array index {key} in {path}"))?;
428            if index >= array.len() {
429                return Err(format!("index {index} out of bounds in {path}"));
430            }
431            array.remove(index);
432            Ok(())
433        }
434        _ => Err(format!("cannot remove {key} from scalar in {path}")),
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use serde_json::json;
442
443    #[test]
444    fn canonical_json_matches_ecmascript_numbers_and_utf16_key_order() {
445        let numbers: Value =
446            serde_json::from_str("[1e20,1e21,1e-6,1e-7,-0,333333333.33333329]").unwrap();
447        assert_eq!(
448            canonical_json(&numbers).unwrap(),
449            "[100000000000000000000,1e+21,0.000001,1e-7,0,333333333.3333333]"
450        );
451        let keys: Value = serde_json::from_str("{\"\":1,\"𐀀\":2}").unwrap();
452        assert_eq!(canonical_json(&keys).unwrap(), "{\"𐀀\":2,\"\":1}");
453    }
454
455    #[test]
456    fn applies_replace_append_and_remove() {
457        let mut view = json!({ "state": { "status": "running" }, "events": [1] });
458        apply_patch(
459            &mut view,
460            &[
461                PatchOp::Replace {
462                    path: "/state/status".into(),
463                    value: json!("completed"),
464                },
465                PatchOp::Append {
466                    path: "/events".into(),
467                    value: vec![json!(2), json!(3)],
468                },
469                PatchOp::Add {
470                    path: "/session".into(),
471                    value: json!({ "binding": null, "entries": [] }),
472                },
473            ],
474        )
475        .unwrap();
476        assert_eq!(
477            view,
478            json!({
479                "state": { "status": "completed" },
480                "events": [1, 2, 3],
481                "session": { "binding": null, "entries": [] }
482            })
483        );
484        apply_patch(
485            &mut view,
486            &[PatchOp::Remove {
487                path: "/session".into(),
488            }],
489        )
490        .unwrap();
491        assert!(view.get("session").is_none());
492    }
493
494    #[test]
495    fn array_add_inserts_but_replace_overwrites() {
496        let mut view = json!({ "events": [1, 2] });
497        apply_patch(
498            &mut view,
499            &[
500                PatchOp::Add {
501                    path: "/events/1".into(),
502                    value: json!(3),
503                },
504                PatchOp::Replace {
505                    path: "/events/0".into(),
506                    value: json!(4),
507                },
508            ],
509        )
510        .unwrap();
511        assert_eq!(view, json!({ "events": [4, 3, 2] }));
512    }
513
514    #[test]
515    fn escapes_json_pointer_tokens() {
516        let mut view = json!({ "a/b": { "c~d": 1 } });
517        apply_patch(
518            &mut view,
519            &[PatchOp::Replace {
520                path: "/a~1b/c~0d".into(),
521                value: json!(2),
522            }],
523        )
524        .unwrap();
525        assert_eq!(view, json!({ "a/b": { "c~d": 2 } }));
526    }
527
528    #[test]
529    fn gap_or_missing_path_errors() {
530        let mut view = json!({ "events": [] });
531        assert!(apply_patch(
532            &mut view,
533            &[PatchOp::Replace {
534                path: "/missing/deep".into(),
535                value: json!(1),
536            }],
537        )
538        .is_err());
539    }
540}