1use 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 host {}", 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 host {}", path.display()))?;
727 run_local_connection(socket, shared, wake).await
728 }
729 #[cfg(not(any(unix, windows)))]
730 anyhow::bail!("local workflow host 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: host {}, 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_host_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("host 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_host_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("host 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}