Skip to main content

piw/
client.rs

1//! Reconnecting client used by local Unix-socket and remote WebSocket modes.
2
3use crate::layout::{layout_graph, GraphLayout};
4use crate::protocol::{
5    apply_patch, encode_request, parse_server_message, ClientRequest, PageKind, ServerMessage,
6    TargetPatch, PROTOCOL_ID,
7};
8use crate::state::types::{
9    as_artifact_ref, ArtifactRef, DefinitionSnapshot, Manifest, RunState, StepRecord,
10    WorkflowDisplay,
11};
12use anyhow::{Context, Result};
13use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
14use futures_util::{SinkExt, StreamExt};
15use serde_json::{json, Value};
16use sha2::{Digest, Sha256};
17use std::collections::{HashMap, HashSet};
18use std::path::{Path, PathBuf};
19use std::sync::{Arc, Mutex};
20use std::thread::JoinHandle;
21use std::time::Duration;
22use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
23#[cfg(windows)]
24use tokio::net::windows::named_pipe::ClientOptions;
25#[cfg(unix)]
26use tokio::net::UnixStream;
27use tokio::sync::mpsc;
28use tokio_tungstenite::tungstenite::Message;
29
30pub struct RemoteView {
31    pub revision: u64,
32    pub graph_revision: u64,
33    generation: u64,
34    pub manifest: Manifest,
35    pub state: RunState,
36    pub display: WorkflowDisplay,
37    pub graph_steps: Vec<StepRecord>,
38    pub taken_transitions: Vec<String>,
39    pub graph_cursor: u64,
40    pub step_start: u64,
41    pub step_total: u64,
42    pub snapshot: Option<DefinitionSnapshot>,
43    pub graph_layout: Option<GraphLayout>,
44    pub events: Vec<Value>,
45    pub trace_start: u64,
46    pub trace_total: u64,
47    pub session_binding: Option<Value>,
48    pub session_entries: Vec<Value>,
49    pub session_entry_start: u64,
50    pub session_entry_total: u64,
51    pub session_events: Vec<Value>,
52    pub session_event_start: u64,
53    pub session_event_total: u64,
54    pub session_events_malformed: bool,
55    pub session_events_torn_tail: bool,
56    pub session_capture: Option<Value>,
57    pub session_replay_checkpoint: Option<Value>,
58    pub settings_scopes: Vec<Value>,
59    pub settings_start: u64,
60    pub settings_total: u64,
61    pub follow_up_queue: Option<Value>,
62    pub follow_up_start: u64,
63    pub follow_up_total: u64,
64    pub update_start: u64,
65    pub update_total: u64,
66    pub live: bool,
67    pub possibly_interrupted: bool,
68}
69
70fn decode_view(
71    revision: u64,
72    generation: u64,
73    raw: &Value,
74    definition_content: Option<&str>,
75    graph_history_content: Option<&str>,
76    display_reason_content: Option<&str>,
77) -> Option<RemoteView> {
78    let graph_revision = raw
79        .get("graphRevision")
80        .and_then(Value::as_u64)
81        .unwrap_or(revision);
82    let manifest: Manifest = serde_json::from_value(raw.get("manifest")?.clone()).ok()?;
83    let state: RunState = serde_json::from_value(raw.get("state")?.clone()).ok()?;
84    let mut display: WorkflowDisplay = serde_json::from_value(raw.get("display")?.clone()).ok()?;
85    if display_reason_artifact(raw).is_some() {
86        display.reason_content = Some(display_reason_value(raw, display_reason_content)?);
87    }
88    let graph_history = graph_history_value(raw, graph_history_content);
89    let graph_steps = graph_history
90        .as_ref()
91        .and_then(|value| value.get("steps"))
92        .and_then(|value| serde_json::from_value(value.clone()).ok())
93        .or_else(|| {
94            raw.get("graphSteps")
95                .and_then(|value| serde_json::from_value(value.clone()).ok())
96        })
97        .unwrap_or_else(|| state.steps.clone());
98    let taken_transitions = graph_history
99        .as_ref()
100        .and_then(|value| value.get("transitions"))
101        .and_then(|value| serde_json::from_value(value.clone()).ok())
102        .or_else(|| {
103            raw.get("takenTransitions")
104                .and_then(|value| serde_json::from_value(value.clone()).ok())
105        })
106        .unwrap_or_default();
107    let graph_cursor = raw
108        .get("graphCursor")
109        .and_then(Value::as_u64)
110        .unwrap_or_else(|| state.steps.len().saturating_sub(1) as u64);
111    let step_start = raw.get("stepStart").and_then(Value::as_u64).unwrap_or(0);
112    let step_total = raw
113        .get("stepTotal")
114        .and_then(Value::as_u64)
115        .unwrap_or(state.steps.len() as u64);
116    let snapshot: Option<DefinitionSnapshot> = workflow_definition_value(raw, definition_content)
117        .and_then(|value| serde_json::from_value(value).ok());
118    let graph_layout = raw
119        .get("graphScene")
120        .and_then(|value| serde_json::from_value(value.clone()).ok())
121        .or_else(|| snapshot.as_ref().map(layout_graph));
122    let events = page_items(raw, "/tracePage/items");
123    let trace_start = pointer_u64(raw, "/tracePage/start");
124    let trace_total = pointer_u64(raw, "/tracePage/total").max(events.len() as u64);
125    let session_binding = raw
126        .pointer("/session/binding")
127        .cloned()
128        .filter(|v| !v.is_null());
129    let session_entries = page_items(raw, "/session/entryPage/items");
130    let session_entry_start = pointer_u64(raw, "/session/entryPage/start");
131    let session_entry_total =
132        pointer_u64(raw, "/session/entryPage/total").max(session_entries.len() as u64);
133    let session_events = page_items(raw, "/session/eventPage/items");
134    let session_event_start = pointer_u64(raw, "/session/eventPage/start");
135    let session_event_total =
136        pointer_u64(raw, "/session/eventPage/total").max(session_events.len() as u64);
137    let settings_scopes = raw
138        .get("settingsScopes")
139        .and_then(Value::as_array)
140        .cloned()
141        .unwrap_or_default();
142    let follow_up_queue = raw.get("followUpQueue").cloned().filter(|v| !v.is_null());
143    let update_total = raw
144        .get("updateTotal")
145        .and_then(Value::as_u64)
146        .unwrap_or_else(|| {
147            state
148                .updates
149                .as_ref()
150                .map_or(0, |updates| updates.len() as u64)
151        });
152    Some(RemoteView {
153        revision,
154        graph_revision,
155        generation,
156        manifest,
157        state,
158        display,
159        graph_steps,
160        taken_transitions,
161        graph_cursor,
162        step_start,
163        step_total,
164        snapshot,
165        graph_layout,
166        events,
167        trace_start,
168        trace_total,
169        session_binding,
170        session_entries,
171        session_entry_start,
172        session_entry_total,
173        session_events,
174        session_event_start,
175        session_event_total,
176        session_events_malformed: raw
177            .pointer("/session/integrity/malformed")
178            .and_then(Value::as_bool)
179            .unwrap_or(false),
180        session_events_torn_tail: raw
181            .pointer("/session/integrity/tornTail")
182            .and_then(Value::as_bool)
183            .unwrap_or(false),
184        session_capture: raw
185            .pointer("/session/capture")
186            .cloned()
187            .filter(|v| !v.is_null()),
188        session_replay_checkpoint: raw
189            .pointer("/session/replayCheckpoint")
190            .cloned()
191            .filter(|v| !v.is_null()),
192        settings_start: raw
193            .get("settingsStart")
194            .and_then(Value::as_u64)
195            .unwrap_or(0),
196        settings_total: raw
197            .get("settingsTotal")
198            .and_then(Value::as_u64)
199            .unwrap_or(settings_scopes.len() as u64),
200        settings_scopes,
201        follow_up_start: raw
202            .get("followUpStart")
203            .and_then(Value::as_u64)
204            .unwrap_or(0),
205        follow_up_total: raw
206            .get("followUpTotal")
207            .and_then(Value::as_u64)
208            .unwrap_or(0),
209        follow_up_queue,
210        update_start: raw.get("updateStart").and_then(Value::as_u64).unwrap_or(0),
211        update_total,
212        live: raw.get("live").and_then(Value::as_bool).unwrap_or(false),
213        possibly_interrupted: raw
214            .get("possiblyInterrupted")
215            .and_then(Value::as_bool)
216            .unwrap_or(false),
217    })
218}
219
220fn page_items(raw: &Value, pointer: &str) -> Vec<Value> {
221    raw.pointer(pointer)
222        .and_then(Value::as_array)
223        .cloned()
224        .unwrap_or_default()
225}
226
227fn pointer_u64(raw: &Value, pointer: &str) -> u64 {
228    raw.pointer(pointer).and_then(Value::as_u64).unwrap_or(0)
229}
230
231fn workflow_definition_artifact(raw: &Value) -> Option<ArtifactRef> {
232    let workflow = raw.get("workflow")?;
233    as_artifact_ref(workflow).or_else(|| workflow.get("content").and_then(as_artifact_ref))
234}
235
236fn workflow_definition_value(raw: &Value, content: Option<&str>) -> Option<Value> {
237    let workflow = raw.get("workflow")?;
238    if workflow_definition_artifact(raw).is_some() {
239        return serde_json::from_str(content?).ok();
240    }
241    Some(workflow.clone())
242}
243
244fn graph_history_artifact(raw: &Value) -> Option<ArtifactRef> {
245    raw.get("graphHistory").and_then(as_artifact_ref)
246}
247
248fn graph_history_value(raw: &Value, content: Option<&str>) -> Option<Value> {
249    let history = raw.get("graphHistory")?;
250    if graph_history_artifact(raw).is_some() {
251        return serde_json::from_str(content?).ok();
252    }
253    Some(history.clone())
254}
255
256fn display_reason_artifact(raw: &Value) -> Option<ArtifactRef> {
257    raw.pointer("/display/reasonContent")
258        .and_then(as_artifact_ref)
259}
260
261fn display_reason_value(raw: &Value, content: Option<&str>) -> Option<Value> {
262    let reason = raw.pointer("/display/reasonContent")?;
263    let Some(artifact) = display_reason_artifact(raw) else {
264        return Some(reason.clone());
265    };
266    match artifact.media_type.as_str() {
267        "text/plain" => Some(Value::String(content?.to_string())),
268        "application/json" => serde_json::from_str(content?).ok(),
269        _ => None,
270    }
271}
272
273fn workflow_definition_content(
274    state: &mut Shared,
275    run_id: &str,
276    raw: &Value,
277) -> (Option<String>, bool) {
278    let Some(artifact) = workflow_definition_artifact(raw) else {
279        return (None, false);
280    };
281    referenced_content(
282        state,
283        run_id,
284        artifact,
285        &["application/json"],
286        "workflow definition",
287    )
288}
289
290fn graph_history_content(state: &mut Shared, run_id: &str, raw: &Value) -> (Option<String>, bool) {
291    let Some(artifact) = graph_history_artifact(raw) else {
292        return (None, false);
293    };
294    referenced_content(
295        state,
296        run_id,
297        artifact,
298        &["application/json"],
299        "workflow graph history",
300    )
301}
302
303fn display_reason_content(state: &mut Shared, run_id: &str, raw: &Value) -> (Option<String>, bool) {
304    let Some(artifact) = display_reason_artifact(raw) else {
305        return (None, false);
306    };
307    referenced_content(
308        state,
309        run_id,
310        artifact,
311        &["text/plain", "application/json"],
312        "workflow display reason",
313    )
314}
315
316fn referenced_content(
317    state: &mut Shared,
318    run_id: &str,
319    artifact: ArtifactRef,
320    accepted_media_types: &[&str],
321    label: &str,
322) -> (Option<String>, bool) {
323    let key = (run_id.to_string(), artifact.path.clone());
324    match state.artifacts.get(&key).cloned() {
325        Some(ArtifactEntry::Ready(content)) => {
326            let valid = accepted_media_types.contains(&artifact.media_type.as_str())
327                && content.len() as u64 == artifact.bytes
328                && hex_sha256(content.as_bytes()) == artifact.sha256;
329            if valid {
330                return (Some(content), false);
331            }
332            let error = format!("{label} content does not match its reference");
333            state
334                .artifacts
335                .insert(key, ArtifactEntry::Error(error.clone()));
336            state.error = Some(error);
337            (None, false)
338        }
339        Some(ArtifactEntry::Error(error)) => {
340            state.error = Some(format!("{label} content is unavailable: {error}"));
341            (None, false)
342        }
343        Some(ArtifactEntry::Loading(_)) => (None, false),
344        None => {
345            state
346                .artifacts
347                .insert(key.clone(), ArtifactEntry::Loading(Vec::new()));
348            state.content_requests.insert(key, 0);
349            (None, true)
350        }
351    }
352}
353
354fn apply_target_patches(view: &mut Value, targets: &[TargetPatch]) -> Result<()> {
355    let mut next = view.clone();
356    for target in targets {
357        if target.target_key.ends_with(":tail") {
358            let pointer = if target.target_type == "timeline" {
359                if target.target_key.starts_with("session:") {
360                    "/session/eventPage"
361                } else {
362                    "/tracePage"
363                }
364            } else if target.target_key.starts_with("entries:") {
365                "/session/entryPage"
366            } else {
367                "/session/eventPage"
368            };
369            if next.pointer(pointer).is_none_or(|page| !is_tail_page(page)) {
370                continue;
371            }
372        }
373        let document = match target.target_type.as_str() {
374            "timeline" if target.target_key.starts_with("session:") => {
375                next.pointer_mut("/session/eventPage")
376            }
377            "timeline" => next.pointer_mut("/tracePage"),
378            "conversation" if target.target_key.starts_with("entries:") => {
379                next.pointer_mut("/session/entryPage")
380            }
381            "conversation" if target.target_key.starts_with("events:") => {
382                next.pointer_mut("/session/eventPage")
383            }
384            "conversation" => next.pointer_mut("/session"),
385            "summary" | "graph" | "replay" | "inspector" => Some(&mut next),
386            _ => None,
387        }
388        .with_context(|| format!("projection target is not loaded: {}", target.target_key))?;
389        apply_patch(document, &target.patch).map_err(anyhow::Error::msg)?;
390    }
391    *view = next;
392    Ok(())
393}
394
395fn is_tail_page(page: &Value) -> bool {
396    let Some(start) = page.get("start").and_then(Value::as_u64) else {
397        return false;
398    };
399    let Some(total) = page.get("total").and_then(Value::as_u64) else {
400        return false;
401    };
402    let Some(items) = page.get("items").and_then(Value::as_array) else {
403        return false;
404    };
405    start.saturating_add(items.len() as u64) == total
406}
407
408#[derive(Debug, Clone)]
409enum ArtifactEntry {
410    Loading(Vec<u8>),
411    Ready(String),
412    Error(String),
413}
414
415struct RunListAssembly {
416    revision: String,
417    total: u64,
418    items: Vec<Value>,
419}
420
421#[derive(Default)]
422struct Shared {
423    connected: bool,
424    connecting: bool,
425    reconnect_attempt: u32,
426    error: Option<String>,
427    summaries: Vec<Value>,
428    run_list: Option<RunListAssembly>,
429    run_list_request: Option<(String, u64)>,
430    raw_views: HashMap<String, (u64, u64, u64, Value)>,
431    next_view_generation: u64,
432    watched: HashSet<String>,
433    page_requests: HashMap<(String, PageKind), u64>,
434    content_requests: HashMap<(String, String), u64>,
435    artifacts: HashMap<(String, String), ArtifactEntry>,
436}
437
438#[derive(Clone)]
439enum Endpoint {
440    WebSocket(String),
441    Local(PathBuf),
442}
443
444fn discard_run_state(shared: &mut Shared, run_id: &str) {
445    shared.raw_views.remove(run_id);
446    shared
447        .page_requests
448        .retain(|(candidate, _), _| candidate != run_id);
449    shared
450        .content_requests
451        .retain(|(candidate, _), _| candidate != run_id);
452    shared
453        .artifacts
454        .retain(|(candidate, _), _| candidate != run_id);
455}
456
457pub struct RemoteRuns {
458    shared: Arc<Mutex<Shared>>,
459    wake: Option<mpsc::UnboundedSender<()>>,
460    worker: Option<JoinHandle<()>>,
461    decoded: HashMap<String, RemoteView>,
462}
463
464impl RemoteRuns {
465    pub fn connect(url: &str) -> Result<Self> {
466        Self::start(Endpoint::WebSocket(url.to_string()))
467    }
468
469    pub fn connect_local(path: &Path) -> Result<Self> {
470        Self::start(Endpoint::Local(path.to_path_buf()))
471    }
472
473    fn start(endpoint: Endpoint) -> Result<Self> {
474        let shared = Arc::new(Mutex::new(Shared {
475            connecting: true,
476            ..Shared::default()
477        }));
478        let (wake_tx, wake_rx) = mpsc::unbounded_channel();
479        let task_shared = Arc::clone(&shared);
480        let worker = std::thread::spawn(move || {
481            let runtime = tokio::runtime::Builder::new_current_thread()
482                .enable_all()
483                .build()
484                .expect("tokio runtime");
485            runtime.block_on(run_reconnecting(endpoint, task_shared, wake_rx));
486        });
487        let _ = wake_tx.send(());
488        Ok(Self {
489            shared,
490            wake: Some(wake_tx),
491            worker: Some(worker),
492            decoded: HashMap::new(),
493        })
494    }
495
496    pub fn connected(&self) -> bool {
497        self.shared.lock().unwrap().connected
498    }
499
500    pub fn status_label(&self) -> &'static str {
501        let shared = self.shared.lock().unwrap();
502        if shared.connected {
503            "connected"
504        } else if shared.reconnect_attempt > 0 {
505            "reconnecting"
506        } else if shared.connecting {
507            "connecting"
508        } else {
509            "disconnected"
510        }
511    }
512
513    pub fn error(&self) -> Option<String> {
514        self.shared.lock().unwrap().error.clone()
515    }
516
517    pub fn summaries(&self) -> Vec<Value> {
518        self.shared.lock().unwrap().summaries.clone()
519    }
520
521    pub fn watch(&mut self, run_id: &str) {
522        let mut shared = self.shared.lock().unwrap();
523        let old: Vec<String> = shared.watched.drain().collect();
524        for old_id in old {
525            if old_id != run_id {
526                discard_run_state(&mut shared, &old_id);
527                self.decoded.remove(&old_id);
528            }
529        }
530        shared.watched.insert(run_id.to_string());
531        drop(shared);
532        self.wake();
533    }
534
535    pub fn request_page(&self, run_id: &str, kind: PageKind, cursor: u64) {
536        self.shared
537            .lock()
538            .unwrap()
539            .page_requests
540            .insert((run_id.to_string(), kind), cursor);
541        self.wake();
542    }
543
544    pub fn request_artifact(&self, run_id: &str, path: &str) {
545        let key = (run_id.to_string(), path.to_string());
546        let mut shared = self.shared.lock().unwrap();
547        if shared.artifacts.contains_key(&key) {
548            return;
549        }
550        shared
551            .artifacts
552            .insert(key.clone(), ArtifactEntry::Loading(Vec::new()));
553        shared.content_requests.insert(key, 0);
554        drop(shared);
555        self.wake();
556    }
557
558    pub fn artifact_snapshot(
559        &self,
560        run_id: &str,
561    ) -> HashMap<String, std::result::Result<String, String>> {
562        self.shared
563            .lock()
564            .unwrap()
565            .artifacts
566            .iter()
567            .filter(|((candidate, _), _)| candidate == run_id)
568            .filter_map(|((_, path), entry)| {
569                let value = match entry {
570                    ArtifactEntry::Loading(_) => return None,
571                    ArtifactEntry::Ready(content) => Ok(content.clone()),
572                    ArtifactEntry::Error(error) => Err(error.clone()),
573                };
574                Some((path.clone(), value))
575            })
576            .collect()
577    }
578
579    pub fn view(&mut self, run_id: &str) -> Option<&RemoteView> {
580        let raw = self.shared.lock().unwrap().raw_views.get(run_id).cloned();
581        let Some((_, revision, generation, raw)) = raw else {
582            self.decoded.remove(run_id);
583            return None;
584        };
585        let (definition_content, graph_history_content, display_reason_content, requested) = {
586            let mut shared = self.shared.lock().unwrap();
587            let (definition, definition_requested) =
588                workflow_definition_content(&mut shared, run_id, &raw);
589            let (graph_history, graph_requested) = graph_history_content(&mut shared, run_id, &raw);
590            let (display_reason, display_reason_requested) =
591                display_reason_content(&mut shared, run_id, &raw);
592            (
593                definition,
594                graph_history,
595                display_reason,
596                definition_requested || graph_requested || display_reason_requested,
597            )
598        };
599        if requested {
600            self.wake();
601        }
602        let stale = self
603            .decoded
604            .get(run_id)
605            .is_none_or(|view| view.generation != generation);
606        if stale {
607            if let Some(decoded) = decode_view(
608                revision,
609                generation,
610                &raw,
611                definition_content.as_deref(),
612                graph_history_content.as_deref(),
613                display_reason_content.as_deref(),
614            ) {
615                self.decoded.insert(run_id.to_string(), decoded);
616            }
617        }
618        self.decoded.get(run_id)
619    }
620
621    fn wake(&self) {
622        if let Some(wake) = &self.wake {
623            let _ = wake.send(());
624        }
625    }
626}
627
628impl Drop for RemoteRuns {
629    fn drop(&mut self) {
630        self.wake.take();
631        if let Some(worker) = self.worker.take() {
632            let _ = worker.join();
633        }
634    }
635}
636
637async fn run_reconnecting(
638    endpoint: Endpoint,
639    shared: Arc<Mutex<Shared>>,
640    mut wake: mpsc::UnboundedReceiver<()>,
641) {
642    let mut attempt = 0u32;
643    loop {
644        {
645            let mut state = shared.lock().unwrap();
646            state.connected = false;
647            state.connecting = true;
648            state.reconnect_attempt = attempt;
649        }
650        let result = match &endpoint {
651            Endpoint::WebSocket(url) => run_websocket(url, Arc::clone(&shared), &mut wake).await,
652            Endpoint::Local(path) => run_local(path, Arc::clone(&shared), &mut wake).await,
653        };
654        if wake.is_closed() {
655            return;
656        }
657        {
658            let mut state = shared.lock().unwrap();
659            state.connected = false;
660            state.connecting = false;
661            state.error = Some(match result {
662                Ok(()) => "connection closed".to_string(),
663                Err(error) => format!("{error:#}"),
664            });
665        }
666        attempt = attempt.saturating_add(1);
667        let delay = (250u64.saturating_mul(1u64 << attempt.min(5))).min(10_000);
668        tokio::select! {
669            _ = tokio::time::sleep(Duration::from_millis(delay)) => {}
670            message = wake.recv() => if message.is_none() { return; }
671        }
672    }
673}
674
675async fn run_websocket(
676    url: &str,
677    shared: Arc<Mutex<Shared>>,
678    wake: &mut mpsc::UnboundedReceiver<()>,
679) -> Result<()> {
680    let (socket, _) = tokio_tungstenite::connect_async(url)
681        .await
682        .with_context(|| format!("connecting to {url}"))?;
683    let (mut sink, mut stream) = socket.split();
684    let mut sent = HashSet::new();
685    let mut counter = 0u64;
686    loop {
687        for request in reconcile_requests(&shared, &mut sent, &mut counter) {
688            let text = encode_request(&request).map_err(anyhow::Error::msg)?;
689            sink.send(Message::Text(text.into())).await?;
690        }
691        tokio::select! {
692            message = stream.next() => {
693                let Some(message) = message else { return Ok(()) };
694                match message? {
695                    Message::Text(text) => handle_server_message(text.as_ref(), &shared)?,
696                    Message::Close(_) => return Ok(()),
697                    Message::Ping(payload) => sink.send(Message::Pong(payload)).await?,
698                    Message::Pong(_) => {}
699                    Message::Binary(_) | Message::Frame(_) => anyhow::bail!("server frame must be text"),
700                }
701            }
702            message = wake.recv() => {
703                if message.is_none() { return Ok(()); }
704            }
705            _ = tokio::time::sleep(Duration::from_millis(250)) => {}
706        }
707    }
708}
709
710async fn run_local(
711    path: &Path,
712    shared: Arc<Mutex<Shared>>,
713    wake: &mut mpsc::UnboundedReceiver<()>,
714) -> Result<()> {
715    #[cfg(unix)]
716    {
717        let socket = UnixStream::connect(path)
718            .await
719            .with_context(|| format!("connecting to workflow server {}", path.display()))?;
720        run_local_connection(socket, shared, wake).await
721    }
722    #[cfg(windows)]
723    {
724        let socket = ClientOptions::new()
725            .open(path)
726            .with_context(|| format!("connecting to workflow server {}", path.display()))?;
727        run_local_connection(socket, shared, wake).await
728    }
729    #[cfg(not(any(unix, windows)))]
730    anyhow::bail!("local workflow server transport is not supported on this platform");
731}
732
733async fn run_local_connection<S>(
734    socket: S,
735    shared: Arc<Mutex<Shared>>,
736    wake: &mut mpsc::UnboundedReceiver<()>,
737) -> Result<()>
738where
739    S: AsyncRead + AsyncWrite + Unpin,
740{
741    let (read, mut write) = tokio::io::split(socket);
742    let mut lines = BufReader::new(read).lines();
743    let mut sent = HashSet::new();
744    let mut counter = 0u64;
745    loop {
746        for request in reconcile_requests(&shared, &mut sent, &mut counter) {
747            let text = encode_request(&request).map_err(anyhow::Error::msg)?;
748            write.write_all(text.as_bytes()).await?;
749            write.write_all(b"\n").await?;
750            write.flush().await?;
751        }
752        tokio::select! {
753            line = lines.next_line() => {
754                let Some(line) = line? else { return Ok(()) };
755                handle_server_message(&line, &shared)?;
756            }
757            message = wake.recv() => {
758                if message.is_none() { return Ok(()); }
759            }
760            _ = tokio::time::sleep(Duration::from_millis(250)) => {}
761        }
762    }
763}
764
765fn reconcile_requests(
766    shared: &Arc<Mutex<Shared>>,
767    sent: &mut HashSet<String>,
768    counter: &mut u64,
769) -> Vec<ClientRequest> {
770    let (watched, pages, contents, revisions, run_list_request) = {
771        let state = shared.lock().unwrap();
772        (
773            state.watched.clone(),
774            state.page_requests.clone(),
775            state.content_requests.clone(),
776            state
777                .raw_views
778                .iter()
779                .map(|(run_id, (_, revision, _, _))| (run_id.clone(), *revision))
780                .collect::<HashMap<_, _>>(),
781            state.run_list_request.clone(),
782        )
783    };
784    let mut requests = Vec::new();
785    if !sent.contains("runs") {
786        requests.push(request(
787            counter,
788            "view.runs.watch",
789            None,
790            json!({"subscriptionId":"runs"}),
791        ));
792        sent.insert("runs".to_string());
793    }
794    sent.retain(|key| {
795        !key.starts_with("runs-page:")
796            || run_list_request
797                .as_ref()
798                .is_some_and(|(revision, cursor)| key == &format!("runs-page:{revision}:{cursor}"))
799    });
800    if let Some((revision, cursor)) = run_list_request {
801        let key = format!("runs-page:{revision}:{cursor}");
802        if sent.insert(key) {
803            requests.push(request(
804                counter,
805                "view.runs.page",
806                None,
807                json!({"revision":revision,"cursor":cursor}),
808            ));
809        }
810    }
811    let removals: Vec<String> = sent
812        .iter()
813        .filter_map(|id| id.strip_prefix("run:").map(str::to_string))
814        .filter(|run_id| !watched.contains(run_id))
815        .collect();
816    for run_id in removals {
817        requests.push(request(
818            counter,
819            "view.run.unwatch",
820            Some(&run_id),
821            json!({"subscriptionId":format!("run:{run_id}")}),
822        ));
823        sent.remove(&format!("run:{run_id}"));
824    }
825    for run_id in watched {
826        let subscription = format!("run:{run_id}");
827        if sent.insert(subscription.clone()) {
828            requests.push(request(
829                counter,
830                "view.run.watch",
831                Some(&run_id),
832                json!({"subscriptionId":subscription}),
833            ));
834        }
835    }
836    let desired_page_keys = pages
837        .iter()
838        .map(|((run_id, kind), cursor)| {
839            let revision = revisions.get(run_id).copied().unwrap_or(0);
840            format!("page:{run_id}:{}:{cursor}:{revision}", page_name(*kind))
841        })
842        .collect::<HashSet<_>>();
843    sent.retain(|key| !key.starts_with("page:") || desired_page_keys.contains(key));
844    for ((run_id, kind), cursor) in pages {
845        let revision = revisions.get(&run_id).copied().unwrap_or(0);
846        let key = format!("page:{run_id}:{}:{cursor}:{revision}", page_name(kind));
847        if sent.insert(key) {
848            requests.push(request(
849                counter,
850                "view.page",
851                Some(&run_id),
852                json!({"kind":page_name(kind),"cursor":cursor}),
853            ));
854        }
855    }
856    let desired_content_keys = contents
857        .iter()
858        .map(|((run_id, path), offset)| format!("content:{run_id}:{path}:{offset}"))
859        .collect::<HashSet<_>>();
860    sent.retain(|key| !key.starts_with("content:") || desired_content_keys.contains(key));
861    for ((run_id, path), offset) in contents {
862        let key = format!("content:{run_id}:{path}:{offset}");
863        if sent.insert(key) {
864            requests.push(request(
865                counter,
866                "view.content",
867                Some(&run_id),
868                json!({"path":path,"offset":offset}),
869            ));
870        }
871    }
872    requests
873}
874
875fn request(
876    counter: &mut u64,
877    operation: &str,
878    run_id: Option<&str>,
879    payload: Value,
880) -> ClientRequest {
881    *counter = counter.wrapping_add(1);
882    let id = format!("piw-{}-{counter}", std::process::id());
883    ClientRequest {
884        schema: PROTOCOL_ID.to_string(),
885        message_type: "request".to_string(),
886        request_id: id.clone(),
887        client_id: format!("piw-{}", std::process::id()),
888        operation: operation.to_string(),
889        idempotency_key: id,
890        run_id: run_id.map(str::to_string),
891        expected_revision: None,
892        payload,
893    }
894}
895
896fn handle_server_message(text: &str, shared: &Arc<Mutex<Shared>>) -> Result<()> {
897    match parse_server_message(text).map_err(anyhow::Error::msg)? {
898        ServerMessage::Hello(hello) => {
899            anyhow::ensure!(
900                hello.package_version == env!("CARGO_PKG_VERSION"),
901                "workflow client version mismatch: server {}, piw {}",
902                hello.package_version,
903                env!("CARGO_PKG_VERSION")
904            );
905            let mut state = shared.lock().unwrap();
906            state.connected = true;
907            state.connecting = false;
908            state.reconnect_attempt = 0;
909            state.error = None;
910        }
911        ServerMessage::Event(event) => match event.event.as_str() {
912            "runs" => {
913                start_run_list(&mut shared.lock().unwrap(), &event.payload)?;
914            }
915            "run_snapshot" => {
916                let Some(run_id) = event.run_id else {
917                    return Ok(());
918                };
919                store_snapshot(
920                    &mut shared.lock().unwrap(),
921                    run_id,
922                    event.revision.unwrap_or(0),
923                    event.payload,
924                );
925            }
926            "run_patch" => {
927                let Some(run_id) = event.run_id else {
928                    return Ok(());
929                };
930                let targets: Vec<TargetPatch> = serde_json::from_value(event.payload)?;
931                let mut state = shared.lock().unwrap();
932                if let Some((event_revision, view_revision, generation, view)) =
933                    state.raw_views.get_mut(&run_id)
934                {
935                    if event.revision == Some(*event_revision + 1)
936                        && apply_target_patches(view, &targets).is_ok()
937                    {
938                        *event_revision += 1;
939                        *view_revision = view
940                            .get("revision")
941                            .and_then(Value::as_u64)
942                            .unwrap_or(view_revision.saturating_add(1));
943                        *generation = generation.wrapping_add(1);
944                    }
945                }
946            }
947            _ => {}
948        },
949        ServerMessage::Response(response) => {
950            if response.outcome == "unavailable"
951                || response.outcome == "rejected"
952                || response.outcome == "notFound"
953            {
954                shared.lock().unwrap().error = Some(
955                    response
956                        .error
957                        .unwrap_or_else(|| format!("workflow request was {}", response.outcome)),
958                );
959            } else if let Some(receipt) = response.receipt {
960                match receipt.get("schema").and_then(Value::as_str) {
961                    Some("pi-workflows.run-list-page.v1") => {
962                        if response.outcome == "accepted" {
963                            merge_run_list(&mut shared.lock().unwrap(), &receipt)?;
964                        } else {
965                            shared.lock().unwrap().run_list_request = None;
966                        }
967                    }
968                    Some("pi-workflows.run-page.v1") => {
969                        if response.outcome == "accepted" {
970                            merge_page(&mut shared.lock().unwrap(), &receipt)?;
971                        }
972                    }
973                    Some("pi-workflows.content-chunk.v1") => {
974                        merge_content(&mut shared.lock().unwrap(), &receipt)?;
975                    }
976                    _ => {}
977                }
978            }
979        }
980    }
981    Ok(())
982}
983
984fn start_run_list(state: &mut Shared, page: &Value) -> Result<()> {
985    let revision = page
986        .get("revision")
987        .and_then(Value::as_str)
988        .context("run list page has no revision")?
989        .to_string();
990    let start = page
991        .get("start")
992        .and_then(Value::as_u64)
993        .context("run list page has no start")?;
994    let total = page
995        .get("total")
996        .and_then(Value::as_u64)
997        .context("run list page has no total")?;
998    let items = page
999        .get("items")
1000        .and_then(Value::as_array)
1001        .cloned()
1002        .context("run list page items are not an array")?;
1003    anyhow::ensure!(start == 0, "run list snapshot does not start at zero");
1004    anyhow::ensure!(
1005        items.len() as u64 <= total,
1006        "run list page exceeds its total"
1007    );
1008    if items.len() as u64 == total {
1009        state.summaries = items;
1010        state.run_list = None;
1011        state.run_list_request = None;
1012    } else {
1013        anyhow::ensure!(!items.is_empty(), "run list page made no progress");
1014        let cursor = items.len() as u64;
1015        state.run_list = Some(RunListAssembly {
1016            revision: revision.clone(),
1017            total,
1018            items,
1019        });
1020        state.run_list_request = Some((revision, cursor));
1021    }
1022    Ok(())
1023}
1024
1025fn merge_run_list(state: &mut Shared, page: &Value) -> Result<()> {
1026    let revision = page
1027        .get("revision")
1028        .and_then(Value::as_str)
1029        .context("run list page has no revision")?;
1030    let start = page
1031        .get("start")
1032        .and_then(Value::as_u64)
1033        .context("run list page has no start")?;
1034    let total = page
1035        .get("total")
1036        .and_then(Value::as_u64)
1037        .context("run list page has no total")?;
1038    let items = page
1039        .get("items")
1040        .and_then(Value::as_array)
1041        .cloned()
1042        .context("run list page items are not an array")?;
1043    let Some(assembly) = state.run_list.as_mut() else {
1044        return Ok(());
1045    };
1046    if assembly.revision != revision {
1047        return Ok(());
1048    }
1049    let expected = assembly.items.len() as u64;
1050    anyhow::ensure!(
1051        start == expected,
1052        "run list page does not continue the snapshot"
1053    );
1054    anyhow::ensure!(
1055        total == assembly.total,
1056        "run list total changed while paging"
1057    );
1058    anyhow::ensure!(!items.is_empty(), "run list page made no progress");
1059    assembly.items.extend(items);
1060    anyhow::ensure!(
1061        assembly.items.len() as u64 <= assembly.total,
1062        "run list page exceeds its total"
1063    );
1064    if assembly.items.len() as u64 == assembly.total {
1065        state.summaries = std::mem::take(&mut assembly.items);
1066        state.run_list = None;
1067        state.run_list_request = None;
1068    } else {
1069        state.run_list_request = Some((revision.to_string(), assembly.items.len() as u64));
1070    }
1071    Ok(())
1072}
1073
1074fn merge_content(state: &mut Shared, receipt: &Value) -> Result<()> {
1075    let run_id = receipt
1076        .get("runId")
1077        .and_then(Value::as_str)
1078        .context("content chunk has no runId")?;
1079    let path = receipt
1080        .get("path")
1081        .and_then(Value::as_str)
1082        .context("content chunk has no path")?;
1083    let offset = receipt
1084        .get("offset")
1085        .and_then(Value::as_u64)
1086        .context("content chunk has no offset")?;
1087    let next_offset = receipt
1088        .get("nextOffset")
1089        .and_then(Value::as_u64)
1090        .context("content chunk has no nextOffset")?;
1091    let total = receipt
1092        .get("bytes")
1093        .and_then(Value::as_u64)
1094        .context("content chunk has no byte total")?;
1095    let sha256 = receipt
1096        .get("sha256")
1097        .and_then(Value::as_str)
1098        .context("content chunk has no digest")?;
1099    let complete = receipt
1100        .get("complete")
1101        .and_then(Value::as_bool)
1102        .context("content chunk has no completion marker")?;
1103    let data = BASE64
1104        .decode(
1105            receipt
1106                .get("data")
1107                .and_then(Value::as_str)
1108                .context("content chunk has no data")?,
1109        )
1110        .context("content chunk is not valid base64")?;
1111    let key = (run_id.to_string(), path.to_string());
1112    let Some(entry) = state.artifacts.remove(&key) else {
1113        return Ok(());
1114    };
1115    let ArtifactEntry::Loading(mut bytes) = entry else {
1116        state.artifacts.insert(key, entry);
1117        return Ok(());
1118    };
1119    if bytes.len() as u64 != offset || offset.saturating_add(data.len() as u64) != next_offset {
1120        state.artifacts.insert(
1121            key.clone(),
1122            ArtifactEntry::Error("workflow content chunk offset is invalid".to_string()),
1123        );
1124        state.content_requests.remove(&key);
1125        bump_view_generation(state, run_id);
1126        return Ok(());
1127    }
1128    bytes.extend_from_slice(&data);
1129    if complete {
1130        let digest = hex_sha256(&bytes);
1131        if bytes.len() as u64 != total || next_offset != total || digest != sha256 {
1132            state.artifacts.insert(
1133                key.clone(),
1134                ArtifactEntry::Error("workflow content digest does not match".to_string()),
1135            );
1136        } else {
1137            match String::from_utf8(bytes) {
1138                Ok(content) => {
1139                    state
1140                        .artifacts
1141                        .insert(key.clone(), ArtifactEntry::Ready(content));
1142                }
1143                Err(_) => {
1144                    state.artifacts.insert(
1145                        key.clone(),
1146                        ArtifactEntry::Error("workflow content is not UTF-8".to_string()),
1147                    );
1148                }
1149            }
1150        }
1151        state.content_requests.remove(&key);
1152        bump_view_generation(state, run_id);
1153    } else {
1154        state
1155            .artifacts
1156            .insert(key.clone(), ArtifactEntry::Loading(bytes));
1157        state.content_requests.insert(key, next_offset);
1158    }
1159    Ok(())
1160}
1161
1162fn hex_sha256(bytes: &[u8]) -> String {
1163    Sha256::digest(bytes)
1164        .iter()
1165        .map(|byte| format!("{byte:02x}"))
1166        .collect()
1167}
1168
1169fn merge_page(state: &mut Shared, receipt: &Value) -> Result<()> {
1170    let run_id = receipt
1171        .get("runId")
1172        .and_then(Value::as_str)
1173        .context("run page has no runId")?;
1174    let kind = receipt
1175        .get("kind")
1176        .and_then(Value::as_str)
1177        .context("run page has no kind")?;
1178    let start = receipt.get("start").and_then(Value::as_u64).unwrap_or(0);
1179    let total = receipt.get("total").and_then(Value::as_u64).unwrap_or(0);
1180    let items = receipt
1181        .get("items")
1182        .and_then(Value::as_array)
1183        .cloned()
1184        .context("run page items are not an array")?;
1185    let page_kind = page_kind(kind).context("run page kind is invalid")?;
1186    let cursor = receipt
1187        .get("cursor")
1188        .and_then(Value::as_u64)
1189        .context("run page has no cursor")?;
1190    let revision = receipt
1191        .get("revision")
1192        .and_then(Value::as_u64)
1193        .context("run page has no revision")?;
1194    if state.page_requests.get(&(run_id.to_string(), page_kind)) != Some(&cursor) {
1195        return Ok(());
1196    }
1197    let Some((_, view_revision, generation, view)) = state.raw_views.get_mut(run_id) else {
1198        return Ok(());
1199    };
1200    if *view_revision != revision {
1201        return Ok(());
1202    }
1203
1204    match kind {
1205        "steps" => {
1206            view["graphSteps"] = receipt
1207                .get("graphSteps")
1208                .cloned()
1209                .unwrap_or_else(|| Value::Array(items.clone()));
1210            view["stepStart"] = json!(start);
1211            view["stepTotal"] = json!(total);
1212            view["graphCursor"] = receipt.get("graphCursor").cloned().unwrap_or(json!(0));
1213            view["takenTransitions"] = receipt
1214                .get("takenTransitions")
1215                .cloned()
1216                .unwrap_or_else(|| json!([]));
1217            if let Some(workflow_state) = view.get_mut("state").and_then(Value::as_object_mut) {
1218                workflow_state.insert("steps".to_string(), Value::Array(items));
1219            }
1220        }
1221        "trace" | "trace_at_step" => {
1222            view["tracePage"] = json!({"start":start,"total":total,"items":items});
1223        }
1224        "session_entries" | "session_events" => {
1225            let session = view
1226                .get_mut("session")
1227                .and_then(Value::as_object_mut)
1228                .context("run view has no session object")?;
1229            session.insert(
1230                if kind == "session_entries" {
1231                    "entryPage".to_string()
1232                } else {
1233                    "eventPage".to_string()
1234                },
1235                json!({"start":start,"total":total,"items":items}),
1236            );
1237            if kind == "session_events" {
1238                session.insert(
1239                    "replayCheckpoint".to_string(),
1240                    receipt
1241                        .get("replayCheckpoint")
1242                        .cloned()
1243                        .unwrap_or(Value::Null),
1244                );
1245            }
1246        }
1247        "settings" => {
1248            view["settingsScopes"] = Value::Array(items);
1249            view["settingsStart"] = json!(start);
1250            view["settingsTotal"] = json!(total);
1251        }
1252        "follow_ups" => {
1253            if !view.get("followUpQueue").is_some_and(Value::is_object) {
1254                view["followUpQueue"] = json!({});
1255            }
1256            if let Some(queue) = view.get_mut("followUpQueue").and_then(Value::as_object_mut) {
1257                queue.insert("items".to_string(), Value::Array(items));
1258            }
1259            view["followUpStart"] = json!(start);
1260            view["followUpTotal"] = json!(total);
1261        }
1262        "updates" => {
1263            view["updates"] = Value::Array(items.clone());
1264            view["updateStart"] = json!(start);
1265            view["updateTotal"] = json!(total);
1266            if let Some(workflow_state) = view.get_mut("state").and_then(Value::as_object_mut) {
1267                workflow_state.insert("updates".to_string(), Value::Array(items));
1268            }
1269        }
1270        _ => anyhow::bail!("unsupported run page kind {kind}"),
1271    }
1272    *generation = generation.wrapping_add(1);
1273    Ok(())
1274}
1275
1276fn bump_view_generation(state: &mut Shared, run_id: &str) {
1277    if let Some((_, _, generation, _)) = state.raw_views.get_mut(run_id) {
1278        *generation = generation.wrapping_add(1);
1279    }
1280}
1281
1282fn store_snapshot(state: &mut Shared, run_id: String, event_revision: u64, value: Value) {
1283    state.next_view_generation = state.next_view_generation.wrapping_add(1);
1284    let generation = state.next_view_generation;
1285    let view_revision = value
1286        .get("revision")
1287        .and_then(Value::as_u64)
1288        .unwrap_or(event_revision);
1289    state
1290        .raw_views
1291        .insert(run_id, (event_revision, view_revision, generation, value));
1292}
1293
1294fn page_kind(value: &str) -> Option<PageKind> {
1295    match value {
1296        "steps" => Some(PageKind::Steps),
1297        "trace" => Some(PageKind::Trace),
1298        "trace_at_step" => Some(PageKind::TraceAtStep),
1299        "session_entries" => Some(PageKind::SessionEntries),
1300        "session_events" => Some(PageKind::SessionEvents),
1301        "settings" => Some(PageKind::Settings),
1302        "follow_ups" => Some(PageKind::FollowUps),
1303        "updates" => Some(PageKind::Updates),
1304        _ => None,
1305    }
1306}
1307
1308fn page_name(kind: PageKind) -> &'static str {
1309    match kind {
1310        PageKind::Steps => "steps",
1311        PageKind::Trace => "trace",
1312        PageKind::TraceAtStep => "trace_at_step",
1313        PageKind::SessionEntries => "session_entries",
1314        PageKind::SessionEvents => "session_events",
1315        PageKind::Settings => "settings",
1316        PageKind::FollowUps => "follow_ups",
1317        PageKind::Updates => "updates",
1318    }
1319}
1320
1321#[cfg(test)]
1322mod tests {
1323    use super::*;
1324    use crate::protocol::PatchOp;
1325    use crate::render::{render_graph, render_graph_lines, GraphNodeStyle, GraphView};
1326
1327    #[test]
1328    fn missing_run_watch_sets_a_visible_client_error() {
1329        let shared = Arc::new(Mutex::new(Shared::default()));
1330        let message = crate::protocol::canonical_json(&json!({
1331            "schema": PROTOCOL_ID,
1332            "type": "response",
1333            "requestId": "watch-missing",
1334            "outcome": "notFound",
1335            "error": "Workflow run not found"
1336        }))
1337        .unwrap();
1338        handle_server_message(&message, &shared).unwrap();
1339        assert_eq!(
1340            shared.lock().unwrap().error.as_deref(),
1341            Some("Workflow run not found")
1342        );
1343    }
1344
1345    #[test]
1346    fn decodes_the_server_owned_run_view_contract() {
1347        let source = json!({"kind":"file","path":"/tmp/smoke.workflow.ts","hash":"abc"});
1348        let raw = json!({
1349            "manifest": {
1350                "schema":"pi-workflows.run-manifest.v1",
1351                "runId":"run-1",
1352                "workflowName":"smoke",
1353                "workflowSource":source,
1354                "startedAt":"2026-01-01T00:00:00.000Z",
1355                "finishedAt":"2026-01-01T00:00:01.000Z",
1356                "status":"completed",
1357                "traceSchema":"pi-workflows.trace-event.v1",
1358                "paths":{"workflow":"host","state":"host","trace":"host"}
1359            },
1360            "state": {
1361                "schema":"pi-workflows.run-state.v1",
1362                "traceSeq":1,
1363                "runId":"run-1",
1364                "workflowName":"smoke",
1365                "workflowSource":source,
1366                "startedAt":"2026-01-01T00:00:00.000Z",
1367                "finishedAt":"2026-01-01T00:00:01.000Z",
1368                "updatedAt":"2026-01-01T00:00:01.000Z",
1369                "status":"completed",
1370                "input":{},
1371                "outputs":{},
1372                "results":{},
1373                "steps":[],
1374                "updates":[{"seq":299}]
1375            },
1376            "workflow": {
1377                "schema":"pi-workflows.definition-snapshot.v1",
1378                "name":"smoke",
1379                "startAt":"done",
1380                "nodes":{"done":{"nodeType":"compute"}},
1381                "edges":[]
1382            },
1383            "display": {
1384                "status":"completed",
1385                "activity":null,
1386                "controls":[],
1387                "reason":null
1388            },
1389            "graphSteps":[],
1390            "takenTransitions":[],
1391            "graphCursor":0,
1392            "stepStart":0,
1393            "stepTotal":0,
1394            "tracePage":{"start":0,"total":0,"items":[]},
1395            "session":{
1396                "binding":null,
1397                "entryPage":{"start":0,"total":0,"items":[]},
1398                "eventPage":{"start":0,"total":0,"items":[]},
1399                "capture":null,
1400                "integrity":null
1401            },
1402            "settingsScopes":[],
1403            "settingsStart":0,
1404            "settingsTotal":0,
1405            "followUpQueue":null,
1406            "followUpStart":0,
1407            "followUpTotal":0,
1408            "updateStart":299,
1409            "updateTotal":300,
1410            "live":false,
1411            "possiblyInterrupted":false
1412        });
1413
1414        let view = decode_view(4, 1, &raw, None, None, None).expect("server view should decode");
1415        assert_eq!(view.manifest.workflow_name, "smoke");
1416        assert_eq!(view.state.status, crate::state::types::RunStatus::Completed);
1417        assert_eq!(view.update_total, 300);
1418
1419        let mut missing_display = raw;
1420        missing_display.as_object_mut().unwrap().remove("display");
1421        assert!(decode_view(4, 1, &missing_display, None, None, None).is_none());
1422    }
1423
1424    #[test]
1425    fn uses_the_server_display_status_during_an_origin_turn() {
1426        let source = json!({"kind":"file","path":"/tmp/smoke.workflow.ts","hash":"abc"});
1427        let reason_content = "The origin-session model turn is active.";
1428        let raw = json!({
1429            "manifest": {
1430                "schema":"pi-workflows.run-manifest.v1",
1431                "runId":"run-1",
1432                "workflowName":"smoke",
1433                "workflowSource":source,
1434                "startedAt":"2026-01-01T00:00:00.000Z",
1435                "finishedAt":null,
1436                "status":"running",
1437                "traceSchema":"pi-workflows.trace-event.v1",
1438                "paths":{"workflow":"host","state":"host","trace":"host"}
1439            },
1440            "state": {
1441                "schema":"pi-workflows.run-state.v1",
1442                "traceSeq":1,
1443                "runId":"run-1",
1444                "workflowName":"smoke",
1445                "workflowSource":source,
1446                "startedAt":"2026-01-01T00:00:00.000Z",
1447                "finishedAt":"2026-01-01T00:00:01.000Z",
1448                "updatedAt":"2026-01-01T00:00:01.000Z",
1449                "status":"waiting",
1450                "input":{},
1451                "outputs":{},
1452                "results":{},
1453                "steps":[],
1454                "waitingOn":"work"
1455            },
1456            "display": {
1457                "status":"running",
1458                "activity":"origin_turn",
1459                "controls":["pause","cancel"],
1460                "reason":"The model is running.",
1461                "reasonContent": {
1462                    "$artifact": {
1463                        "path":"artifacts/sha256/display-reason.txt",
1464                        "mediaType":"text/plain",
1465                        "bytes":reason_content.len(),
1466                        "sha256":hex_sha256(reason_content.as_bytes())
1467                    }
1468                }
1469            },
1470            "workflow": {
1471                "schema":"pi-workflows.definition-snapshot.v1",
1472                "name":"smoke",
1473                "startAt":"work",
1474                "nodes":{"work":{"nodeType":"agent"}},
1475                "edges":[]
1476            }
1477        });
1478
1479        let view = decode_view(4, 1, &raw, None, None, Some(reason_content))
1480            .expect("server view should decode");
1481
1482        assert_eq!(view.state.status, crate::state::types::RunStatus::Waiting);
1483        assert_eq!(view.display.status, crate::state::types::RunStatus::Running);
1484        assert_eq!(
1485            view.display.reason_content,
1486            Some(Value::String(reason_content.to_string()))
1487        );
1488        assert_eq!(view.display.active_node(&view.state), Some("work"));
1489
1490        let graph = GraphView {
1491            state: &view.state,
1492            display: &view.display,
1493            snapshot: view.snapshot.as_ref(),
1494            graph_steps: Some(&view.graph_steps),
1495            taken_transitions: Some(&view.taken_transitions),
1496        };
1497        let rendered =
1498            render_graph_lines(&graph, -1, 1_767_225_660_000, GraphNodeStyle::Line).join("\n");
1499        assert!(rendered.contains("◐ work"), "{rendered}");
1500        assert!(!rendered.contains("⏸ work"), "{rendered}");
1501
1502        let replayed = render_graph(&graph, -1, false, 1_767_225_660_000, GraphNodeStyle::Line)
1503            .expect("graph should render")
1504            .canvas
1505            .render_plain()
1506            .join("\n");
1507        assert!(!replayed.contains("◐ work"), "{replayed}");
1508    }
1509
1510    #[test]
1511    fn display_only_patch_changes_the_live_status_without_changing_durable_state() {
1512        let mut raw = json!({
1513            "display": {
1514                "status":"waiting",
1515                "activity":null,
1516                "controls":["pause","cancel","answer"],
1517                "reason":"The workflow is waiting for origin-session input."
1518            },
1519            "state":{"status":"waiting"}
1520        });
1521
1522        apply_patch(
1523            &mut raw,
1524            &[
1525                PatchOp::Replace {
1526                    path: "/display/status".into(),
1527                    value: json!("running"),
1528                },
1529                PatchOp::Replace {
1530                    path: "/display/activity".into(),
1531                    value: json!("origin_turn"),
1532                },
1533            ],
1534        )
1535        .unwrap();
1536
1537        assert_eq!(raw["display"]["status"], "running");
1538        assert_eq!(raw["display"]["activity"], "origin_turn");
1539        assert_eq!(raw["state"]["status"], "waiting");
1540    }
1541
1542    #[test]
1543    fn large_workflow_definitions_load_before_layout() {
1544        let nodes = (0..300)
1545            .map(|index| (format!("node-{index}"), json!({"nodeType":"compute"})))
1546            .collect::<serde_json::Map<_, _>>();
1547        let full = json!({
1548            "schema":"pi-workflows.definition-snapshot.v1",
1549            "name":"large",
1550            "startAt":"node-0",
1551            "nodes":nodes,
1552            "edges":[]
1553        });
1554        let content = serde_json::to_string(&full).unwrap();
1555        let path = "artifacts/sha256/definition.json";
1556        let raw = json!({
1557            "workflow": {
1558                "schema":"pi-workflows.definition-snapshot.v1",
1559                "name":"large",
1560                "startAt":"node-0",
1561                "nodes":{"node-0":{"nodeType":"compute"}},
1562                "edges":[],
1563                "content": {
1564                    "$artifact": {
1565                        "path":path,
1566                        "mediaType":"application/json",
1567                        "bytes":content.len(),
1568                        "sha256":hex_sha256(content.as_bytes()),
1569                        "opaque":true
1570                    }
1571                }
1572            }
1573        });
1574        let mut state = Shared::default();
1575        let (missing, requested) = workflow_definition_content(&mut state, "run-1", &raw);
1576        assert!(missing.is_none());
1577        assert!(requested);
1578        assert_eq!(
1579            state
1580                .content_requests
1581                .get(&("run-1".to_string(), path.to_string())),
1582            Some(&0)
1583        );
1584        state.artifacts.insert(
1585            ("run-1".to_string(), path.to_string()),
1586            ArtifactEntry::Ready(content),
1587        );
1588        let (loaded, requested) = workflow_definition_content(&mut state, "run-1", &raw);
1589        assert!(!requested);
1590        let snapshot: DefinitionSnapshot =
1591            serde_json::from_value(workflow_definition_value(&raw, loaded.as_deref()).unwrap())
1592                .unwrap();
1593        assert_eq!(snapshot.nodes.len(), 300);
1594        assert_eq!(layout_graph(&snapshot).rank_of_node.len(), 300);
1595
1596        let mut invalid = raw;
1597        invalid["workflow"]["content"]["$artifact"]["sha256"] = json!("0".repeat(64));
1598        let (loaded, requested) = workflow_definition_content(&mut state, "run-1", &invalid);
1599        assert!(loaded.is_none());
1600        assert!(!requested);
1601        assert!(state
1602            .error
1603            .as_deref()
1604            .is_some_and(|error| error.contains("does not match")));
1605    }
1606
1607    #[test]
1608    fn complete_graph_history_uses_the_shared_content_loader() {
1609        let history = json!({
1610            "steps":[],
1611            "transitions":(0..300).map(|index| format!("node-{index}->node-{}", index + 1)).collect::<Vec<_>>()
1612        });
1613        let content = serde_json::to_string(&history).unwrap();
1614        let path = "artifacts/sha256/graph-history.json";
1615        let raw = json!({
1616            "graphSteps":[],
1617            "takenTransitions":["node-0->node-1"],
1618            "graphHistory": {
1619                "$artifact": {
1620                    "path":path,
1621                    "mediaType":"application/json",
1622                    "bytes":content.len(),
1623                    "sha256":hex_sha256(content.as_bytes()),
1624                    "opaque":true
1625                }
1626            }
1627        });
1628        let mut state = Shared::default();
1629        let (missing, requested) = graph_history_content(&mut state, "run-1", &raw);
1630        assert!(missing.is_none());
1631        assert!(requested);
1632        state.artifacts.insert(
1633            ("run-1".to_string(), path.to_string()),
1634            ArtifactEntry::Ready(content),
1635        );
1636        let (loaded, requested) = graph_history_content(&mut state, "run-1", &raw);
1637        assert!(!requested);
1638        let complete = graph_history_value(&raw, loaded.as_deref()).unwrap();
1639        assert_eq!(complete["transitions"].as_array().map(Vec::len), Some(300));
1640    }
1641
1642    #[test]
1643    fn complete_display_reason_uses_the_shared_content_loader() {
1644        let content = "complete workflow failure reason";
1645        let path = "artifacts/sha256/display-reason.txt";
1646        let raw = json!({
1647            "display": {
1648                "status":"failed",
1649                "activity":null,
1650                "controls":[],
1651                "reason":"Complete workflow failure details are available.",
1652                "reasonContent": {
1653                    "$artifact": {
1654                        "path":path,
1655                        "mediaType":"text/plain",
1656                        "bytes":content.len(),
1657                        "sha256":hex_sha256(content.as_bytes())
1658                    }
1659                }
1660            }
1661        });
1662        let mut state = Shared::default();
1663        let (missing, requested) = display_reason_content(&mut state, "run-1", &raw);
1664        assert!(missing.is_none());
1665        assert!(requested);
1666        state.artifacts.insert(
1667            ("run-1".to_string(), path.to_string()),
1668            ArtifactEntry::Ready(content.to_string()),
1669        );
1670        let (loaded, requested) = display_reason_content(&mut state, "run-1", &raw);
1671        assert!(!requested);
1672        assert_eq!(
1673            display_reason_value(&raw, loaded.as_deref()),
1674            Some(Value::String(content.to_string()))
1675        );
1676    }
1677
1678    #[test]
1679    fn a_run_page_updates_only_its_selected_window() {
1680        let mut state = Shared::default();
1681        state.raw_views.insert(
1682            "run-1".to_string(),
1683            (
1684                3,
1685                3,
1686                1,
1687                json!({
1688                    "state":{"steps":[],"updates":[]},
1689                    "session":{"entryPage":{"items":[]},"eventPage":{"items":[]}},
1690                    "settingsScopes":[{"change":1}],
1691                    "settingsStart":0,
1692                    "settingsTotal":1
1693                }),
1694            ),
1695        );
1696        state
1697            .page_requests
1698            .insert(("run-1".to_string(), PageKind::Settings), 44);
1699        state
1700            .page_requests
1701            .insert(("run-1".to_string(), PageKind::Steps), 0);
1702        state
1703            .page_requests
1704            .insert(("run-1".to_string(), PageKind::FollowUps), 2);
1705        state
1706            .page_requests
1707            .insert(("run-1".to_string(), PageKind::SessionEvents), 256);
1708        merge_page(
1709            &mut state,
1710            &json!({
1711                "schema":"pi-workflows.run-page.v1",
1712                "runId":"run-1",
1713                "revision":3,
1714                "kind":"settings",
1715                "cursor":44,
1716                "start":44,
1717                "total":300,
1718                "items":[{"change":299}]
1719            }),
1720        )
1721        .unwrap();
1722        merge_page(
1723            &mut state,
1724            &json!({
1725                "schema":"pi-workflows.run-page.v1",
1726                "runId":"run-1",
1727                "revision":3,
1728                "kind":"steps",
1729                "cursor":0,
1730                "start":0,
1731                "total":1,
1732                "items":[{"step":0}],
1733                "graphSteps":[{"node":"one"}],
1734                "graphCursor":0,
1735                "takenTransitions":[]
1736            }),
1737        )
1738        .unwrap();
1739        merge_page(
1740            &mut state,
1741            &json!({
1742                "schema":"pi-workflows.run-page.v1",
1743                "runId":"run-1",
1744                "revision":3,
1745                "kind":"follow_ups",
1746                "cursor":2,
1747                "start":2,
1748                "total":3,
1749                "items":[{"followUpId":"follow-3"}]
1750            }),
1751        )
1752        .unwrap();
1753        merge_page(
1754            &mut state,
1755            &json!({
1756                "schema":"pi-workflows.run-page.v1",
1757                "runId":"run-1",
1758                "revision":3,
1759                "kind":"session_events",
1760                "cursor":256,
1761                "start":256,
1762                "total":300,
1763                "items":[{"seq":257}],
1764                "replayCheckpoint":{"throughSeq":256}
1765            }),
1766        )
1767        .unwrap();
1768        let (_, _, generation, view) = state.raw_views.get("run-1").unwrap();
1769        assert_eq!(*generation, 5);
1770        assert_eq!(view["settingsStart"], 44);
1771        assert_eq!(view["settingsScopes"], json!([{"change":299}]));
1772        assert_eq!(view["graphSteps"], json!([{"node":"one"}]));
1773        assert_eq!(view["state"]["steps"], json!([{"step":0}]));
1774        assert_eq!(
1775            view["followUpQueue"]["items"],
1776            json!([{"followUpId":"follow-3"}])
1777        );
1778        assert_eq!(view["session"]["replayCheckpoint"]["throughSeq"], 256);
1779    }
1780
1781    #[test]
1782    fn stale_run_pages_cannot_replace_the_requested_window() {
1783        let mut state = Shared::default();
1784        state.raw_views.insert(
1785            "run-1".to_string(),
1786            (
1787                1,
1788                3,
1789                1,
1790                json!({"state":{"steps":[]},"graphSteps":[],"takenTransitions":[]}),
1791            ),
1792        );
1793        state
1794            .page_requests
1795            .insert(("run-1".to_string(), PageKind::Steps), 20);
1796        let receipt = |cursor: u64, revision: u64, item: u64| {
1797            json!({
1798                "schema":"pi-workflows.run-page.v1",
1799                "runId":"run-1",
1800                "revision":revision,
1801                "kind":"steps",
1802                "cursor":cursor,
1803                "start":cursor,
1804                "total":100,
1805                "items":[{"step":item}],
1806                "graphSteps":[],
1807                "graphCursor":cursor,
1808                "takenTransitions":[]
1809            })
1810        };
1811        merge_page(&mut state, &receipt(10, 3, 10)).unwrap();
1812        merge_page(&mut state, &receipt(20, 2, 2)).unwrap();
1813        assert_eq!(state.raw_views["run-1"].2, 1);
1814        merge_page(&mut state, &receipt(20, 3, 20)).unwrap();
1815        assert_eq!(state.raw_views["run-1"].2, 2);
1816        assert_eq!(
1817            state.raw_views["run-1"].3["state"]["steps"],
1818            json!([{"step":20}])
1819        );
1820    }
1821
1822    #[test]
1823    fn paged_run_lists_publish_only_complete_matching_revisions() {
1824        let mut state = Shared::default();
1825        start_run_list(
1826            &mut state,
1827            &json!({
1828                "schema":"pi-workflows.run-list-page.v1",
1829                "revision":"3:10:20",
1830                "start":0,
1831                "total":3,
1832                "items":[{"runId":"run-1"},{"runId":"run-2"}]
1833            }),
1834        )
1835        .unwrap();
1836        assert!(state.summaries.is_empty());
1837        assert_eq!(state.run_list_request, Some(("3:10:20".to_string(), 2)));
1838        merge_run_list(
1839            &mut state,
1840            &json!({
1841                "schema":"pi-workflows.run-list-page.v1",
1842                "revision":"stale",
1843                "start":2,
1844                "total":3,
1845                "items":[{"runId":"stale"}]
1846            }),
1847        )
1848        .unwrap();
1849        assert!(state.summaries.is_empty());
1850        merge_run_list(
1851            &mut state,
1852            &json!({
1853                "schema":"pi-workflows.run-list-page.v1",
1854                "revision":"3:10:20",
1855                "start":2,
1856                "total":3,
1857                "items":[{"runId":"run-3"}]
1858            }),
1859        )
1860        .unwrap();
1861        assert_eq!(state.summaries.len(), 3);
1862        assert!(state.run_list_request.is_none());
1863    }
1864
1865    #[test]
1866    fn switching_runs_discards_old_pages_content_and_artifacts() {
1867        let mut state = Shared::default();
1868        state
1869            .raw_views
1870            .insert("old".to_string(), (1, 1, 1, json!({})));
1871        state
1872            .page_requests
1873            .insert(("old".to_string(), PageKind::Steps), 10);
1874        state
1875            .content_requests
1876            .insert(("old".to_string(), "old.json".to_string()), 0);
1877        state.artifacts.insert(
1878            ("old".to_string(), "old.json".to_string()),
1879            ArtifactEntry::Ready("old".to_string()),
1880        );
1881        state
1882            .page_requests
1883            .insert(("current".to_string(), PageKind::Steps), 20);
1884
1885        discard_run_state(&mut state, "old");
1886
1887        assert!(!state.raw_views.contains_key("old"));
1888        assert!(state
1889            .page_requests
1890            .keys()
1891            .all(|(run_id, _)| run_id != "old"));
1892        assert!(state
1893            .content_requests
1894            .keys()
1895            .all(|(run_id, _)| run_id != "old"));
1896        assert!(state.artifacts.keys().all(|(run_id, _)| run_id != "old"));
1897        assert!(state
1898            .page_requests
1899            .contains_key(&("current".to_string(), PageKind::Steps)));
1900    }
1901
1902    #[test]
1903    fn page_requests_can_return_to_an_earlier_window() {
1904        let shared = Arc::new(Mutex::new(Shared::default()));
1905        shared
1906            .lock()
1907            .unwrap()
1908            .raw_views
1909            .insert("run-1".to_string(), (7, 7, 1, json!({})));
1910        let mut sent = HashSet::new();
1911        let mut counter = 0;
1912        let request_at = |cursor: u64,
1913                          shared: &Arc<Mutex<Shared>>,
1914                          sent: &mut HashSet<String>,
1915                          counter: &mut u64| {
1916            shared
1917                .lock()
1918                .unwrap()
1919                .page_requests
1920                .insert(("run-1".to_string(), PageKind::Steps), cursor);
1921            reconcile_requests(shared, sent, counter)
1922                .into_iter()
1923                .filter(|request| request.operation == "view.page")
1924                .count()
1925        };
1926        assert_eq!(request_at(10, &shared, &mut sent, &mut counter), 1);
1927        assert_eq!(request_at(20, &shared, &mut sent, &mut counter), 1);
1928        assert_eq!(request_at(10, &shared, &mut sent, &mut counter), 1);
1929    }
1930
1931    #[test]
1932    fn content_chunks_are_reassembled_and_verified() {
1933        let content = br#"{"complete":true}"#.to_vec();
1934        let path = "artifacts/sha256/content.json";
1935        let key = ("run-1".to_string(), path.to_string());
1936        let mut state = Shared::default();
1937        state
1938            .raw_views
1939            .insert("run-1".to_string(), (1, 1, 1, json!({"workflow":null})));
1940        state
1941            .artifacts
1942            .insert(key.clone(), ArtifactEntry::Loading(Vec::new()));
1943        state.content_requests.insert(key.clone(), 0);
1944        merge_content(
1945            &mut state,
1946            &json!({
1947                "schema":"pi-workflows.content-chunk.v1",
1948                "runId":"run-1",
1949                "path":path,
1950                "mediaType":"application/json",
1951                "bytes":content.len(),
1952                "sha256":hex_sha256(&content),
1953                "offset":0,
1954                "nextOffset":content.len(),
1955                "complete":true,
1956                "data":BASE64.encode(&content)
1957            }),
1958        )
1959        .unwrap();
1960        assert!(
1961            matches!(state.artifacts.get(&key), Some(ArtifactEntry::Ready(value)) if value == "{\"complete\":true}")
1962        );
1963        assert!(!state.content_requests.contains_key(&key));
1964        assert_eq!(state.raw_views["run-1"].2, 2);
1965    }
1966
1967    #[test]
1968    fn a_bad_patch_keeps_the_last_good_view() {
1969        let mut view = json!({"presentationRevision":1});
1970        let before = view.clone();
1971        let result = apply_target_patches(
1972            &mut view,
1973            &[TargetPatch {
1974                target_type: "graph".to_string(),
1975                target_key: String::new(),
1976                patch: vec![PatchOp::Replace {
1977                    path: "/missing/value".to_string(),
1978                    value: json!(2),
1979                }],
1980            }],
1981        );
1982        assert!(result.is_err());
1983        assert_eq!(view, before);
1984    }
1985}