Skip to main content

kiss_workflow/
progress.rs

1//! Live run state and the snapshots a user interface renders.
2//!
3//! State changes bump a version counter published on a `watch` channel. A
4//! viewer subscribes and redraws only when the version moves, so nothing polls.
5//! Snapshots are built at most once per change and shared behind an `Arc`.
6
7use crate::runner::AgentId;
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::{Arc, Mutex};
10use std::time::{Duration, Instant};
11use tokio::sync::{Notify, watch};
12use tokio_util::sync::CancellationToken;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum RunStatus {
16    Running,
17    Paused,
18    Completed,
19    Failed,
20    Stopped,
21}
22
23impl RunStatus {
24    pub fn is_finished(self) -> bool {
25        matches!(
26            self,
27            RunStatus::Completed | RunStatus::Failed | RunStatus::Stopped
28        )
29    }
30
31    pub fn label(self) -> &'static str {
32        match self {
33            RunStatus::Running => "running",
34            RunStatus::Paused => "paused",
35            RunStatus::Completed => "completed",
36            RunStatus::Failed => "failed",
37            RunStatus::Stopped => "stopped",
38        }
39    }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum AgentStatus {
44    Queued,
45    Running,
46    Completed,
47    Failed,
48    Stopped,
49    /// Replayed from a previous run rather than started again.
50    Reused,
51}
52
53impl AgentStatus {
54    pub fn is_finished(self) -> bool {
55        !matches!(self, AgentStatus::Queued | AgentStatus::Running)
56    }
57
58    pub fn label(self) -> &'static str {
59        match self {
60            AgentStatus::Queued => "queued",
61            AgentStatus::Running => "running",
62            AgentStatus::Completed => "completed",
63            AgentStatus::Failed => "failed",
64            AgentStatus::Stopped => "stopped",
65            AgentStatus::Reused => "reused",
66        }
67    }
68}
69
70/// One agent as shown in the progress view.
71#[derive(Debug, Clone)]
72pub struct AgentSnapshot {
73    pub id: AgentId,
74    pub label: String,
75    pub status: AgentStatus,
76    pub tokens: u64,
77    pub elapsed: Duration,
78    pub prompt: String,
79    pub result: Option<String>,
80    pub error: Option<String>,
81}
82
83/// One phase, with its agents.
84#[derive(Debug, Clone)]
85pub struct PhaseSnapshot {
86    pub title: String,
87    pub agents: Vec<AgentSnapshot>,
88    pub tokens: u64,
89}
90
91impl PhaseSnapshot {
92    pub fn finished_agents(&self) -> usize {
93        self.agents
94            .iter()
95            .filter(|agent| agent.status.is_finished())
96            .count()
97    }
98}
99
100/// The whole run as shown in the progress view.
101#[derive(Debug, Clone)]
102pub struct RunSnapshot {
103    pub status: RunStatus,
104    pub elapsed: Duration,
105    pub log: Vec<String>,
106    pub phases: Vec<PhaseSnapshot>,
107    pub tokens: u64,
108    pub error: Option<String>,
109}
110
111impl RunSnapshot {
112    pub fn total_agents(&self) -> usize {
113        self.phases.iter().map(|phase| phase.agents.len()).sum()
114    }
115
116    pub fn finished_agents(&self) -> usize {
117        self.phases.iter().map(PhaseSnapshot::finished_agents).sum()
118    }
119
120    /// The phase with work in flight, or the last one that has any agents.
121    pub fn active_phase(&self) -> Option<&PhaseSnapshot> {
122        self.phases
123            .iter()
124            .find(|phase| phase.agents.iter().any(|agent| !agent.status.is_finished()))
125            .or_else(|| {
126                self.phases
127                    .iter()
128                    .rev()
129                    .find(|phase| !phase.agents.is_empty())
130            })
131    }
132}
133
134struct AgentRecord {
135    id: AgentId,
136    label: String,
137    status: AgentStatus,
138    tokens: u64,
139    started: Option<Instant>,
140    elapsed: Duration,
141    prompt: String,
142    result: Option<String>,
143    error: Option<String>,
144    cancel: CancellationToken,
145    /// Set when the user asks the active call to start again. Cancelling alone
146    /// means that the call returns null. This flag tells the interpreter to
147    /// replace the cancellation token and issue the same request again.
148    restart: bool,
149}
150
151struct PhaseRecord {
152    title: String,
153    agents: Vec<AgentRecord>,
154}
155
156struct Inner {
157    status: RunStatus,
158    started: Instant,
159    finished: Option<Instant>,
160    log: Vec<String>,
161    phases: Vec<PhaseRecord>,
162    current_phase: usize,
163    error: Option<String>,
164}
165
166/// The live state of one run.
167pub(crate) struct RunState {
168    inner: Mutex<Inner>,
169    version: watch::Sender<u64>,
170    /// The snapshot for the current version, built on first request.
171    cached: Mutex<Option<(u64, Arc<RunSnapshot>)>>,
172    paused: AtomicBool,
173    resumed: Notify,
174    stop: CancellationToken,
175}
176
177/// The phase used before a script calls `phase()`.
178pub(crate) const DEFAULT_PHASE: &str = "Workflow";
179
180impl RunState {
181    pub(crate) fn new(declared_phases: &[String]) -> Arc<RunState> {
182        let mut phases: Vec<PhaseRecord> = declared_phases
183            .iter()
184            .map(|title| PhaseRecord {
185                title: title.clone(),
186                agents: Vec::new(),
187            })
188            .collect();
189        if phases.is_empty() {
190            phases.push(PhaseRecord {
191                title: DEFAULT_PHASE.to_string(),
192                agents: Vec::new(),
193            });
194        }
195        let (version, _) = watch::channel(0);
196        Arc::new(RunState {
197            inner: Mutex::new(Inner {
198                status: RunStatus::Running,
199                started: Instant::now(),
200                finished: None,
201                log: Vec::new(),
202                phases,
203                current_phase: 0,
204                error: None,
205            }),
206            version,
207            cached: Mutex::new(None),
208            paused: AtomicBool::new(false),
209            resumed: Notify::new(),
210            stop: CancellationToken::new(),
211        })
212    }
213
214    pub(crate) fn stop_token(&self) -> CancellationToken {
215        self.stop.clone()
216    }
217
218    pub fn subscribe(&self) -> watch::Receiver<u64> {
219        self.version.subscribe()
220    }
221
222    fn changed(&self) {
223        if let Ok(mut cached) = self.cached.lock() {
224            *cached = None;
225        }
226        self.version
227            .send_modify(|version| *version = version.wrapping_add(1));
228    }
229
230    /// Select the phase later agents belong to, adding it when it is new.
231    pub(crate) fn set_phase(&self, title: &str) {
232        let Ok(mut inner) = self.inner.lock() else {
233            return;
234        };
235        match inner.phases.iter().position(|phase| phase.title == title) {
236            Some(index) => inner.current_phase = index,
237            None => {
238                inner.phases.push(PhaseRecord {
239                    title: title.to_string(),
240                    agents: Vec::new(),
241                });
242                inner.current_phase = inner.phases.len() - 1;
243            }
244        }
245        drop(inner);
246        self.changed();
247    }
248
249    pub(crate) fn current_phase_title(&self) -> String {
250        let Ok(inner) = self.inner.lock() else {
251            return DEFAULT_PHASE.to_string();
252        };
253        inner
254            .phases
255            .get(inner.current_phase)
256            .map(|phase| phase.title.clone())
257            .unwrap_or_else(|| DEFAULT_PHASE.to_string())
258    }
259
260    pub(crate) fn log(&self, message: String) {
261        if let Ok(mut inner) = self.inner.lock() {
262            inner.log.push(message);
263        }
264        self.changed();
265    }
266
267    /// Register an agent before it starts, returning the token that stops it.
268    pub(crate) fn register_agent(
269        &self,
270        id: AgentId,
271        label: String,
272        prompt: String,
273    ) -> CancellationToken {
274        let cancel = self.stop.child_token();
275        if let Ok(mut inner) = self.inner.lock() {
276            let phase = inner.current_phase;
277            if let Some(phase) = inner.phases.get_mut(phase) {
278                phase.agents.push(AgentRecord {
279                    id,
280                    label,
281                    status: AgentStatus::Queued,
282                    tokens: 0,
283                    started: None,
284                    elapsed: Duration::ZERO,
285                    prompt,
286                    result: None,
287                    error: None,
288                    cancel: cancel.clone(),
289                    restart: false,
290                });
291            }
292        }
293        self.changed();
294        cancel
295    }
296
297    pub(crate) fn agent_started(&self, id: AgentId) {
298        self.with_agent(id, |agent| {
299            agent.status = AgentStatus::Running;
300            agent.started = Some(Instant::now());
301        });
302    }
303
304    pub(crate) fn agent_finished(
305        &self,
306        id: AgentId,
307        status: AgentStatus,
308        result: Option<String>,
309        error: Option<String>,
310        tokens: u64,
311    ) {
312        self.with_agent(id, |agent| {
313            agent.status = status;
314            agent.result = result;
315            agent.error = error;
316            agent.tokens = tokens;
317            if let Some(started) = agent.started {
318                agent.elapsed = started.elapsed();
319            }
320        });
321    }
322
323    fn with_agent(&self, id: AgentId, apply: impl FnOnce(&mut AgentRecord)) {
324        if let Ok(mut inner) = self.inner.lock()
325            && let Some(agent) = inner
326                .phases
327                .iter_mut()
328                .flat_map(|phase| phase.agents.iter_mut())
329                .find(|agent| agent.id == id)
330        {
331            apply(agent);
332        }
333        self.changed();
334    }
335
336    pub(crate) fn finish(&self, status: RunStatus, error: Option<String>) {
337        if let Ok(mut inner) = self.inner.lock() {
338            inner.status = status;
339            inner.error = error;
340            inner.finished = Some(Instant::now());
341        }
342        self.changed();
343    }
344
345    // ----- user controls ----------------------------------------------------
346
347    pub fn pause(&self) {
348        if self.paused.swap(true, Ordering::SeqCst) {
349            return;
350        }
351        if let Ok(mut inner) = self.inner.lock()
352            && inner.status == RunStatus::Running
353        {
354            inner.status = RunStatus::Paused;
355        }
356        self.changed();
357    }
358
359    pub fn resume(&self) {
360        if !self.paused.swap(false, Ordering::SeqCst) {
361            return;
362        }
363        if let Ok(mut inner) = self.inner.lock()
364            && inner.status == RunStatus::Paused
365        {
366            inner.status = RunStatus::Running;
367        }
368        self.resumed.notify_waiters();
369        self.changed();
370    }
371
372    pub fn is_paused(&self) -> bool {
373        self.paused.load(Ordering::SeqCst)
374    }
375
376    /// Wait here while the run is paused. Returns false when the run was
377    /// stopped while waiting.
378    pub(crate) async fn wait_while_paused(&self) -> bool {
379        while self.paused.load(Ordering::SeqCst) {
380            if self.stop.is_cancelled() {
381                return false;
382            }
383            let resumed = self.resumed.notified();
384            // Re-check after arming the wait, so a resume that lands in between
385            // is not missed.
386            if !self.paused.load(Ordering::SeqCst) {
387                break;
388            }
389            tokio::select! {
390                _ = resumed => {}
391                _ = self.stop.cancelled() => return false,
392            }
393        }
394        !self.stop.is_cancelled()
395    }
396
397    pub fn stop(&self) {
398        self.stop.cancel();
399        // A paused run must wake so that it can observe the stop.
400        self.resume();
401        self.changed();
402    }
403
404    pub fn stop_agent(&self, id: AgentId) {
405        if let Ok(inner) = self.inner.lock()
406            && let Some(agent) = inner
407                .phases
408                .iter()
409                .flat_map(|phase| phase.agents.iter())
410                .find(|agent| agent.id == id)
411        {
412            agent.cancel.cancel();
413        }
414        self.changed();
415    }
416
417    /// Ask an active agent to stop its current child session and start again.
418    ///
419    /// Finished agents cannot be changed because their value can already have
420    /// moved through later script stages. Relaunching a finished result is a
421    /// new workflow run, not an in-place control action.
422    pub fn restart_agent(&self, id: AgentId) {
423        if let Ok(mut inner) = self.inner.lock()
424            && let Some(agent) = inner
425                .phases
426                .iter_mut()
427                .flat_map(|phase| phase.agents.iter_mut())
428                .find(|agent| agent.id == id)
429            && matches!(agent.status, AgentStatus::Queued | AgentStatus::Running)
430        {
431            agent.restart = true;
432            agent.cancel.cancel();
433        }
434        self.changed();
435    }
436
437    /// Replace the cancelled token after a restart request.
438    pub(crate) fn take_restart(&self, id: AgentId) -> Option<CancellationToken> {
439        if self.stop.is_cancelled() {
440            return None;
441        }
442        let mut inner = self.inner.lock().ok()?;
443        let agent = inner
444            .phases
445            .iter_mut()
446            .flat_map(|phase| phase.agents.iter_mut())
447            .find(|agent| agent.id == id && agent.restart)?;
448        agent.restart = false;
449        agent.status = AgentStatus::Queued;
450        agent.started = None;
451        agent.elapsed = Duration::ZERO;
452        agent.result = None;
453        agent.error = None;
454        agent.cancel = self.stop.child_token();
455        let cancel = agent.cancel.clone();
456        drop(inner);
457        self.changed();
458        Some(cancel)
459    }
460
461    // ----- snapshots --------------------------------------------------------
462
463    pub fn snapshot(&self) -> Arc<RunSnapshot> {
464        let version = *self.version.borrow();
465        if let Ok(cached) = self.cached.lock()
466            && let Some((cached_version, snapshot)) = cached.as_ref()
467            && *cached_version == version
468        {
469            return snapshot.clone();
470        }
471        let snapshot = Arc::new(self.build_snapshot());
472        if let Ok(mut cached) = self.cached.lock() {
473            *cached = Some((version, snapshot.clone()));
474        }
475        snapshot
476    }
477
478    fn build_snapshot(&self) -> RunSnapshot {
479        let Ok(inner) = self.inner.lock() else {
480            return RunSnapshot {
481                status: RunStatus::Failed,
482                elapsed: Duration::ZERO,
483                log: Vec::new(),
484                phases: Vec::new(),
485                tokens: 0,
486                error: Some("the run state was poisoned".into()),
487            };
488        };
489        let mut total_tokens = 0;
490        let phases = inner
491            .phases
492            .iter()
493            .map(|phase| {
494                let mut phase_tokens = 0;
495                let agents = phase
496                    .agents
497                    .iter()
498                    .map(|agent| {
499                        phase_tokens += agent.tokens;
500                        AgentSnapshot {
501                            id: agent.id,
502                            label: agent.label.clone(),
503                            status: agent.status,
504                            tokens: agent.tokens,
505                            elapsed: match (agent.status.is_finished(), agent.started) {
506                                (false, Some(started)) => started.elapsed(),
507                                _ => agent.elapsed,
508                            },
509                            prompt: agent.prompt.clone(),
510                            result: agent.result.clone(),
511                            error: agent.error.clone(),
512                        }
513                    })
514                    .collect();
515                total_tokens += phase_tokens;
516                PhaseSnapshot {
517                    title: phase.title.clone(),
518                    agents,
519                    tokens: phase_tokens,
520                }
521            })
522            .collect();
523        RunSnapshot {
524            status: inner.status,
525            elapsed: inner
526                .finished
527                .unwrap_or_else(Instant::now)
528                .saturating_duration_since(inner.started),
529            log: inner.log.clone(),
530            phases,
531            tokens: total_tokens,
532            error: inner.error.clone(),
533        }
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    #[test]
542    fn declared_phases_appear_before_any_agent_runs() {
543        let state = RunState::new(&["Discover".into(), "Audit".into()]);
544        let snapshot = state.snapshot();
545        assert_eq!(snapshot.phases.len(), 2);
546        assert_eq!(snapshot.phases[0].title, "Discover");
547        assert_eq!(snapshot.total_agents(), 0);
548    }
549
550    #[test]
551    fn a_script_without_phases_gets_one_default_phase() {
552        let state = RunState::new(&[]);
553        assert_eq!(state.snapshot().phases[0].title, DEFAULT_PHASE);
554    }
555
556    #[test]
557    fn an_undeclared_phase_is_added_when_it_is_first_used() {
558        let state = RunState::new(&["Discover".into()]);
559        state.set_phase("Verify");
560        state.register_agent(0, "check".into(), "prompt".into());
561        let snapshot = state.snapshot();
562        assert_eq!(snapshot.phases.len(), 2);
563        assert_eq!(snapshot.phases[1].title, "Verify");
564        assert_eq!(snapshot.phases[1].agents.len(), 1);
565    }
566
567    #[test]
568    fn snapshots_are_shared_until_the_state_changes() {
569        let state = RunState::new(&[]);
570        let first = state.snapshot();
571        let second = state.snapshot();
572        assert!(Arc::ptr_eq(&first, &second));
573
574        state.register_agent(0, "one".into(), "prompt".into());
575        let third = state.snapshot();
576        assert!(!Arc::ptr_eq(&first, &third));
577    }
578
579    #[test]
580    fn the_version_moves_on_every_change_so_viewers_never_poll() {
581        let state = RunState::new(&[]);
582        let mut versions = state.subscribe();
583        assert_eq!(*versions.borrow_and_update(), 0);
584        state.log("started".into());
585        assert!(versions.has_changed().unwrap_or(false));
586    }
587
588    #[test]
589    fn finished_counts_ignore_agents_still_working() {
590        let state = RunState::new(&[]);
591        state.register_agent(0, "one".into(), "p".into());
592        state.register_agent(1, "two".into(), "p".into());
593        state.agent_started(0);
594        state.agent_finished(0, AgentStatus::Completed, Some("ok".into()), None, 12);
595        let snapshot = state.snapshot();
596        assert_eq!(snapshot.total_agents(), 2);
597        assert_eq!(snapshot.finished_agents(), 1);
598        assert_eq!(snapshot.tokens, 12);
599    }
600
601    #[tokio::test]
602    async fn a_paused_run_continues_after_resume() {
603        let state = RunState::new(&[]);
604        state.pause();
605        assert!(state.is_paused());
606        assert_eq!(state.snapshot().status, RunStatus::Paused);
607
608        let waiter = state.clone();
609        let handle = tokio::spawn(async move { waiter.wait_while_paused().await });
610        tokio::task::yield_now().await;
611        state.resume();
612        assert!(handle.await.unwrap());
613        assert_eq!(state.snapshot().status, RunStatus::Running);
614    }
615
616    #[tokio::test]
617    async fn stopping_a_paused_run_releases_it() {
618        let state = RunState::new(&[]);
619        state.pause();
620        let waiter = state.clone();
621        let handle = tokio::spawn(async move { waiter.wait_while_paused().await });
622        tokio::task::yield_now().await;
623        state.stop();
624        // False means "do not carry on", which is what a stopped run needs.
625        assert!(!handle.await.unwrap());
626    }
627}