Skip to main content

zig_core/
resume.rs

1//! `zig continue` — re-open the most recent step's agent conversation
2//! from the latest `zig run`.
3//!
4//! Resolves a target zig session from the per-project session index
5//! (`~/.zig/projects/<id>/logs/index.json`), reads its JSONL log to find
6//! the last `StepStarted` event, and hands the recorded `zag_session_id`
7//! to [`zag_agent::builder::AgentBuilder::resume`] for an interactive
8//! resume of that step's conversation.
9//!
10//! This MVP intentionally does not replay workflow orchestration: skipping
11//! completed steps would lose their `saves` outputs, so any later step
12//! depending on them would have undefined variables. Real orchestration
13//! replay needs persisted variable state, which is a separate change.
14
15use std::path::PathBuf;
16
17use zag_agent::builder::AgentBuilder;
18
19use crate::error::ZigError;
20use crate::paths;
21use crate::session::{
22    SessionEventKind, SessionLogIndexEntry, load_project_index, read_session_events,
23};
24
25/// Options for `zig continue`.
26pub struct ContinueOptions {
27    /// Filter the most-recent lookup to a specific workflow name.
28    pub workflow: Option<String>,
29    /// Resume a specific zig session id (full UUID or unique prefix).
30    pub session: Option<String>,
31}
32
33/// Resolved target for resumption.
34#[derive(Debug)]
35pub struct ResumeTarget {
36    pub zig_session_id: String,
37    pub workflow_name: String,
38    pub zag_session_id: String,
39    pub log_path: PathBuf,
40}
41
42/// Resolve which zag session to resume based on `opts`.
43///
44/// Resolution order:
45/// 1. `opts.session` (exact id or unique prefix) → match in project index.
46/// 2. `opts.workflow` → most recent project entry for that workflow name.
47/// 3. Neither → most recent project entry.
48///
49/// In all cases the chosen entry's JSONL log is read and the **last**
50/// `StepStarted` event determines the zag session id to resume.
51pub fn resolve(opts: &ContinueOptions) -> Result<ResumeTarget, ZigError> {
52    let entry = if let Some(id) = &opts.session {
53        find_by_prefix(id)?
54    } else if let Some(name) = &opts.workflow {
55        find_latest_for_workflow(name)?
56    } else {
57        find_latest()?
58    };
59
60    let log_path = PathBuf::from(&entry.log_path);
61    resolve_from_log(&log_path, entry)
62}
63
64/// Internal helper exposed for tests: given an already-loaded index entry
65/// and its log path, pull out the last `StepStarted`'s zag session id.
66pub fn resolve_from_log(
67    log_path: &std::path::Path,
68    entry: SessionLogIndexEntry,
69) -> Result<ResumeTarget, ZigError> {
70    let events = read_session_events(log_path)?;
71
72    let zag_session_id = events
73        .iter()
74        .rev()
75        .find_map(|e| match &e.kind {
76            SessionEventKind::StepStarted { zag_session_id, .. } => Some(zag_session_id.clone()),
77            _ => None,
78        })
79        .ok_or_else(|| {
80            ZigError::Io(format!(
81                "session '{}' has no recorded step to resume",
82                entry.zig_session_id
83            ))
84        })?;
85
86    Ok(ResumeTarget {
87        zig_session_id: entry.zig_session_id,
88        workflow_name: entry.workflow_name,
89        zag_session_id,
90        log_path: log_path.to_path_buf(),
91    })
92}
93
94fn project_sessions() -> Result<Vec<SessionLogIndexEntry>, ZigError> {
95    let idx_path = paths::project_index_path(None)
96        .ok_or_else(|| ZigError::Io("HOME environment variable not set".into()))?;
97    if !idx_path.exists() {
98        return Err(ZigError::Io(
99            "no zig sessions yet — run `zig run` first.".into(),
100        ));
101    }
102    Ok(load_project_index(&idx_path).sessions)
103}
104
105fn find_by_prefix(id: &str) -> Result<SessionLogIndexEntry, ZigError> {
106    let sessions = project_sessions()?;
107    let matches: Vec<_> = sessions
108        .into_iter()
109        .filter(|e| e.zig_session_id.starts_with(id))
110        .collect();
111    match matches.len() {
112        0 => Err(ZigError::Io(format!("no session matches '{id}'"))),
113        1 => Ok(matches.into_iter().next().unwrap()),
114        n => Err(ZigError::Io(format!(
115            "ambiguous session prefix '{id}' matches {n} sessions"
116        ))),
117    }
118}
119
120fn find_latest() -> Result<SessionLogIndexEntry, ZigError> {
121    project_sessions()?
122        .into_iter()
123        .max_by(|a, b| a.started_at.cmp(&b.started_at))
124        .ok_or_else(|| ZigError::Io("no zig sessions yet — run `zig run` first.".into()))
125}
126
127fn find_latest_for_workflow(name: &str) -> Result<SessionLogIndexEntry, ZigError> {
128    project_sessions()?
129        .into_iter()
130        .filter(|e| e.workflow_name == name)
131        .max_by(|a, b| a.started_at.cmp(&b.started_at))
132        .ok_or_else(|| ZigError::Io(format!("no zig sessions found for workflow '{name}'.")))
133}
134
135/// Resume the most recent step's agent session interactively. The terminal
136/// attaches to the resumed conversation; type follow-ups directly there.
137pub async fn continue_run(opts: ContinueOptions) -> Result<(), ZigError> {
138    let target = resolve(&opts)?;
139    let short_id = &target.zig_session_id[..target.zig_session_id.len().min(8)];
140    eprintln!(
141        "resuming workflow '{}' (zig session {}, zag session {})",
142        target.workflow_name, short_id, target.zag_session_id,
143    );
144    AgentBuilder::new()
145        .resume(&target.zag_session_id)
146        .await
147        .map_err(|e| ZigError::Zag(format!("resume failed: {e}")))
148}
149
150#[cfg(test)]
151#[path = "resume_tests.rs"]
152mod tests;