Skip to main content

zig_core/
update.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use serde::Serialize;
6
7use crate::create::{get_zag_help, get_zag_orch_reference};
8use crate::error::ZigError;
9use crate::pack;
10use crate::prompt;
11use crate::run;
12use crate::workflow::parser;
13
14/// File kind on disk — either a plain `.zwf` TOML file or a zipped `.zwfz`
15/// archive. Determines how the binary stages the workflow for editing and how
16/// it writes it back when the agent session ends.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
18pub enum WorkflowKind {
19    Plain,
20    Zipped,
21}
22
23impl WorkflowKind {
24    fn from_path(path: &Path) -> Self {
25        match path.extension().and_then(|s| s.to_str()) {
26            Some("zwfz") => WorkflowKind::Zipped,
27            _ => WorkflowKind::Plain,
28        }
29    }
30}
31
32/// Prepared parameters for workflow update — the system prompt, initial user
33/// prompt, and the staging path that the agent should edit in place.
34#[derive(Debug, Serialize)]
35pub struct UpdateParams {
36    pub system_prompt: String,
37    pub initial_prompt: String,
38    pub original_path: PathBuf,
39    pub staging_path: PathBuf,
40    pub kind: WorkflowKind,
41    pub session_name: String,
42    pub session_tag: String,
43    /// Owned tempdir that holds the staging copy. Kept alive until the
44    /// update completes so the scratch files are available to the agent;
45    /// dropped to clean them up afterwards.
46    #[serde(skip)]
47    pub _staging_dir: tempfile::TempDir,
48}
49
50fn build_system_prompt(zag_help: &str, zag_orch: &str, examples_reference: &str) -> String {
51    let vars = HashMap::from([
52        ("zwf_format_spec", prompt::templates::config_sidecar()),
53        ("zag_help", zag_help),
54        ("zag_orch", zag_orch),
55        ("examples_reference", examples_reference),
56    ]);
57    prompt::render(prompt::templates::update(), &vars)
58}
59
60/// Prepare an update session without launching zag. Resolves the workflow,
61/// copies or unzips it to a tempdir, validates that it parses, and builds
62/// the system + initial prompts.
63pub fn prepare_update(workflow: &str) -> Result<UpdateParams, ZigError> {
64    // Make sure the agent can read the canonical examples.
65    if let Err(e) = prompt::write_examples_to_global_dir() {
66        eprintln!("Warning: could not write example files: {e}");
67    }
68
69    let original_path = run::resolve_workflow_path(workflow)?;
70    let kind = WorkflowKind::from_path(&original_path);
71
72    let staging_dir = tempfile::TempDir::new()
73        .map_err(|e| ZigError::Io(format!("failed to create staging directory: {e}")))?;
74
75    let staging_path = match kind {
76        WorkflowKind::Plain => {
77            let file_name = original_path
78                .file_name()
79                .ok_or_else(|| ZigError::Io("workflow path has no file name".into()))?;
80            let dest = staging_dir.path().join(file_name);
81            std::fs::copy(&original_path, &dest).map_err(|e| {
82                ZigError::Io(format!(
83                    "failed to copy {} to staging: {e}",
84                    original_path.display()
85                ))
86            })?;
87            dest
88        }
89        WorkflowKind::Zipped => {
90            parser::extract_zip(&original_path, staging_dir.path())?;
91            let toml_files = parser::find_workflow_files(staging_dir.path())?;
92            match toml_files.len() {
93                0 => {
94                    return Err(ZigError::Parse(
95                        "archive contains no .toml or .zwf workflow file".into(),
96                    ));
97                }
98                1 => toml_files.into_iter().next().unwrap(),
99                n => {
100                    return Err(ZigError::Parse(format!(
101                        "archive contains {n} workflow files (expected exactly one)"
102                    )));
103                }
104            }
105        }
106    };
107
108    // Parse up-front to fail fast on a broken workflow before paying for a zag session.
109    parser::parse_file(&staging_path)?;
110
111    let zag_help = get_zag_help();
112    let zag_orch = get_zag_orch_reference();
113    let examples_reference = prompt::examples_reference_block();
114    let system_prompt = build_system_prompt(&zag_help, &zag_orch, &examples_reference);
115
116    let initial_prompt = format!(
117        "I want to update the workflow file at `{}`. Please read it first, \
118         then help me make the changes I describe. Edit the file in place at \
119         that exact path — do not rename, move, or copy it.",
120        staging_path.display()
121    );
122
123    Ok(UpdateParams {
124        system_prompt,
125        initial_prompt,
126        original_path,
127        staging_path,
128        kind,
129        session_name: "zig-update".to_string(),
130        session_tag: "zig-workflow-update".to_string(),
131        _staging_dir: staging_dir,
132    })
133}
134
135/// Launch an interactive zag session for workflow revision.
136///
137/// Flow:
138/// 1. Resolve the workflow by name or path.
139/// 2. Copy (plain `.zwf`) or unzip (`.zwfz`) it into a temp staging directory.
140/// 3. Spawn `zag run` with an update system prompt and the staging path.
141/// 4. On success, move (plain) or re-zip (zipped) the staging contents back
142///    over the original path.
143pub fn run_update(workflow: &str) -> Result<(), ZigError> {
144    run::check_zag()?;
145
146    let params = prepare_update(workflow)?;
147
148    let status = Command::new("zag")
149        .args(["run", &params.initial_prompt])
150        .args(["--system-prompt", &params.system_prompt])
151        .args(["--name", &params.session_name])
152        .args(["--tag", &params.session_tag])
153        .status()
154        .map_err(|e| ZigError::Zag(format!("failed to launch zag: {e}")))?;
155
156    if !status.success() {
157        return Err(ZigError::Zag(format!("zag exited with status {status}")));
158    }
159
160    // Re-validate the edited workflow; warn (but do not abort) on issues so
161    // the user still ends up with the file the agent produced.
162    if params.staging_path.exists() {
163        match parser::parse_file(&params.staging_path) {
164            Ok(workflow) => {
165                if let Err(errors) = crate::workflow::validate::validate(&workflow) {
166                    eprintln!("Warning: updated workflow has validation issues:");
167                    for e in &errors {
168                        eprintln!("  - {e}");
169                    }
170                }
171            }
172            Err(e) => {
173                eprintln!("Warning: could not parse updated file: {e}");
174            }
175        }
176    } else {
177        return Err(ZigError::Io(format!(
178            "expected updated workflow at {} but the file is missing — \
179             did the agent move or rename it?",
180            params.staging_path.display()
181        )));
182    }
183
184    commit_update(&params)?;
185
186    println!("updated {}", params.original_path.display());
187    Ok(())
188}
189
190/// Move (plain) or re-zip (zipped) the staging copy back over the original
191/// workflow path. Writes through a sibling temp path + rename so a failure
192/// mid-write can't leave the original file truncated.
193fn commit_update(params: &UpdateParams) -> Result<(), ZigError> {
194    match params.kind {
195        WorkflowKind::Plain => {
196            let tmp = sibling_temp_path(&params.original_path)?;
197            std::fs::copy(&params.staging_path, &tmp).map_err(|e| {
198                ZigError::Io(format!(
199                    "failed to write updated workflow to {}: {e}",
200                    tmp.display()
201                ))
202            })?;
203            std::fs::rename(&tmp, &params.original_path).map_err(|e| {
204                ZigError::Io(format!(
205                    "failed to replace {}: {e}",
206                    params.original_path.display()
207                ))
208            })?;
209        }
210        WorkflowKind::Zipped => {
211            let tmp = sibling_temp_path(&params.original_path)?;
212            pack::zip_directory(params._staging_dir.path(), &tmp)?;
213            std::fs::rename(&tmp, &params.original_path).map_err(|e| {
214                ZigError::Io(format!(
215                    "failed to replace {}: {e}",
216                    params.original_path.display()
217                ))
218            })?;
219        }
220    }
221    Ok(())
222}
223
224/// Build a sibling path next to `target` suitable for an atomic-rename
225/// write-then-rename sequence.
226fn sibling_temp_path(target: &Path) -> Result<PathBuf, ZigError> {
227    let parent = target
228        .parent()
229        .ok_or_else(|| ZigError::Io("workflow path has no parent directory".into()))?;
230    let file_name = target
231        .file_name()
232        .ok_or_else(|| ZigError::Io("workflow path has no file name".into()))?
233        .to_string_lossy();
234    let pid = std::process::id();
235    Ok(parent.join(format!(".{file_name}.update.{pid}.tmp")))
236}
237
238#[cfg(test)]
239#[path = "update_tests.rs"]
240mod tests;