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