1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use serde::Serialize;
5use zag_agent::builder::AgentBuilder;
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, validate};
13
14#[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#[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 #[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
60pub fn prepare_update(workflow: &str) -> Result<UpdateParams, ZigError> {
64 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 let parsed = parser::parse_file(&staging_path)?;
110
111 let validation_report = match validate::validate(&parsed) {
115 Ok(()) => "Validation: no issues found.".to_string(),
116 Err(errors) => {
117 let mut report = format!(
118 "Validation: {} issue{} found:",
119 errors.len(),
120 if errors.len() == 1 { "" } else { "s" },
121 );
122 for e in &errors {
123 report.push_str("\n - ");
124 report.push_str(&e.to_string());
125 }
126 report
127 }
128 };
129
130 let zag_help = get_zag_help();
131 let zag_orch = get_zag_orch_reference();
132 let examples_reference = prompt::examples_reference_block();
133 let system_prompt = build_system_prompt(&zag_help, &zag_orch, &examples_reference);
134
135 let initial_prompt = format!(
136 "I want to update the workflow file at `{}`. Please read it first, \
137 then help me make the changes I describe. Edit the file in place at \
138 that exact path — do not rename, move, or copy it.\n\n\
139 Here is the current validation report for that workflow:\n\n{}\n\n\
140 Use this report as a starting point: mention any issues while you \
141 summarize the workflow, but do not start fixing anything yet. Wait \
142 for me to explicitly tell you what to change before editing the file.",
143 staging_path.display(),
144 validation_report,
145 );
146
147 Ok(UpdateParams {
148 system_prompt,
149 initial_prompt,
150 original_path,
151 staging_path,
152 kind,
153 session_name: "zig-update".to_string(),
154 session_tag: "zig-workflow-update".to_string(),
155 _staging_dir: staging_dir,
156 })
157}
158
159pub async fn run_update(workflow: &str) -> Result<(), ZigError> {
169 let params = prepare_update(workflow)?;
170
171 AgentBuilder::new()
172 .system_prompt(¶ms.system_prompt)
173 .name(¶ms.session_name)
174 .tag(¶ms.session_tag)
175 .run(Some(¶ms.initial_prompt))
176 .await
177 .map_err(|e| ZigError::Zag(format!("failed to run agent: {e}")))?;
178
179 if params.staging_path.exists() {
182 match parser::parse_file(¶ms.staging_path) {
183 Ok(workflow) => {
184 if let Err(errors) = crate::workflow::validate::validate(&workflow) {
185 eprintln!("Warning: updated workflow has validation issues:");
186 for e in &errors {
187 eprintln!(" - {e}");
188 }
189 }
190 }
191 Err(e) => {
192 eprintln!("Warning: could not parse updated file: {e}");
193 }
194 }
195 } else {
196 return Err(ZigError::Io(format!(
197 "expected updated workflow at {} but the file is missing — \
198 did the agent move or rename it?",
199 params.staging_path.display()
200 )));
201 }
202
203 commit_update(¶ms)?;
204
205 println!("updated {}", params.original_path.display());
206 Ok(())
207}
208
209fn commit_update(params: &UpdateParams) -> Result<(), ZigError> {
213 match params.kind {
214 WorkflowKind::Plain => {
215 let tmp = sibling_temp_path(¶ms.original_path)?;
216 std::fs::copy(¶ms.staging_path, &tmp).map_err(|e| {
217 ZigError::Io(format!(
218 "failed to write updated workflow to {}: {e}",
219 tmp.display()
220 ))
221 })?;
222 std::fs::rename(&tmp, ¶ms.original_path).map_err(|e| {
223 ZigError::Io(format!(
224 "failed to replace {}: {e}",
225 params.original_path.display()
226 ))
227 })?;
228 }
229 WorkflowKind::Zipped => {
230 let tmp = sibling_temp_path(¶ms.original_path)?;
231 pack::zip_directory(params._staging_dir.path(), &tmp)?;
232 std::fs::rename(&tmp, ¶ms.original_path).map_err(|e| {
233 ZigError::Io(format!(
234 "failed to replace {}: {e}",
235 params.original_path.display()
236 ))
237 })?;
238 }
239 }
240 Ok(())
241}
242
243fn sibling_temp_path(target: &Path) -> Result<PathBuf, ZigError> {
246 let parent = target
247 .parent()
248 .ok_or_else(|| ZigError::Io("workflow path has no parent directory".into()))?;
249 let file_name = target
250 .file_name()
251 .ok_or_else(|| ZigError::Io("workflow path has no file name".into()))?
252 .to_string_lossy();
253 let pid = std::process::id();
254 Ok(parent.join(format!(".{file_name}.update.{pid}.tmp")))
255}
256
257#[cfg(test)]
258#[path = "update_tests.rs"]
259mod tests;