Skip to main content

zig_core/
storage.rs

1//! Workflow storage — structured, writable working data for a run.
2//!
3//! Storage is a first-class workflow concept alongside `vars` (scalar state)
4//! and `resources` (read-only reference files). It gives a workflow a place to
5//! accumulate files that its steps produce and consume — the canonical example
6//! is a book-writing workflow that maintains character sheets, world-building
7//! notes, and a consistency bible across many steps.
8//!
9//! ## Path resolution
10//!
11//! Storage paths resolve relative to `<cwd>/.zig/` — where `<cwd>` is the
12//! directory the user invoked `zig run` from. Absolute paths pass through
13//! unchanged. This differs from resources, which resolve relative to the
14//! `.zwf` file: resources ship with the workflow, storage belongs to the
15//! run.
16//!
17//! ## Backends
18//!
19//! The [`StorageBackend`] trait abstracts over the underlying store. The only
20//! implementation today is [`FilesystemBackend`]; future sqlite/remote
21//! backends slot in behind the same trait without workflow-format changes.
22
23use std::path::{Path, PathBuf};
24
25use crate::error::ZigError;
26use crate::paths::expand_path;
27use crate::workflow::model::{StorageKind, StorageSpec};
28
29/// A single entry inside a folder-typed storage item, surfaced to the agent
30/// so it can see what previous steps wrote.
31#[derive(Debug, Clone)]
32pub struct StorageEntry {
33    /// File name relative to the storage folder (no path prefix).
34    pub name: String,
35    /// File size in bytes, when available.
36    pub size: Option<u64>,
37}
38
39/// The current contents of a storage item at the moment a step starts.
40#[derive(Debug, Clone, Default)]
41pub struct StorageListing {
42    /// Files currently present. For folder-typed storage this enumerates
43    /// the folder contents; for file-typed storage it contains a single
44    /// entry (or is empty when the file hasn't been created yet).
45    pub entries: Vec<StorageEntry>,
46}
47
48/// Abstraction over a storage backend. Implementations decide how to
49/// materialise a [`StorageSpec`] and how to enumerate its contents.
50pub trait StorageBackend: std::fmt::Debug {
51    /// Ensure the storage item exists and is ready for reads/writes.
52    /// Called once per run before any step executes. Must be idempotent.
53    fn ensure(&self, spec: &StorageSpec) -> Result<(), ZigError>;
54
55    /// Enumerate the current contents of the storage item. Called each
56    /// time a step's system prompt is rendered so the listing reflects
57    /// files written by previous steps in the same run.
58    fn listing(&self, spec: &StorageSpec) -> Result<StorageListing, ZigError>;
59
60    /// Absolute on-disk path for the storage item. This is what gets
61    /// embedded in the agent's system prompt so it can read/write with
62    /// its normal file tools.
63    fn abs_path(&self, spec: &StorageSpec) -> PathBuf;
64}
65
66/// Filesystem-backed storage rooted at `<cwd>/.zig/`.
67#[derive(Debug, Clone)]
68pub struct FilesystemBackend {
69    /// Root directory — typically `<cwd>/.zig/`. Relative paths in a
70    /// [`StorageSpec`] are joined onto this; absolute paths bypass it.
71    zig_root: PathBuf,
72}
73
74impl FilesystemBackend {
75    /// Create a backend rooted at the given directory. The directory is
76    /// created on demand during [`StorageBackend::ensure`]; it does not
77    /// need to exist yet.
78    pub fn new(zig_root: PathBuf) -> Self {
79        Self { zig_root }
80    }
81
82    /// Build a backend rooted at `<cwd>/.zig/` using the process's current
83    /// working directory. Use [`FilesystemBackend::new`] in tests so you
84    /// can pin the root to a tempdir.
85    pub fn from_cwd() -> Result<Self, ZigError> {
86        let cwd = std::env::current_dir()
87            .map_err(|e| ZigError::Io(format!("failed to resolve cwd for storage: {e}")))?;
88        Ok(Self::new(cwd.join(".zig")))
89    }
90
91    fn resolve(&self, raw_path: &str) -> PathBuf {
92        let expanded = PathBuf::from(expand_path(raw_path));
93        if expanded.is_absolute() {
94            expanded
95        } else {
96            self.zig_root.join(expanded)
97        }
98    }
99}
100
101impl StorageBackend for FilesystemBackend {
102    fn ensure(&self, spec: &StorageSpec) -> Result<(), ZigError> {
103        let target = self.resolve(&spec.path);
104        match spec.kind {
105            StorageKind::Folder => {
106                std::fs::create_dir_all(&target).map_err(|e| {
107                    ZigError::Io(format!(
108                        "failed to create storage folder {}: {e}",
109                        target.display()
110                    ))
111                })?;
112            }
113            StorageKind::File => {
114                if let Some(parent) = target.parent() {
115                    std::fs::create_dir_all(parent).map_err(|e| {
116                        ZigError::Io(format!(
117                            "failed to create parent for storage file {}: {e}",
118                            target.display()
119                        ))
120                    })?;
121                }
122                if !target.exists() {
123                    std::fs::OpenOptions::new()
124                        .create(true)
125                        .write(true)
126                        .truncate(false)
127                        .open(&target)
128                        .map_err(|e| {
129                            ZigError::Io(format!(
130                                "failed to create storage file {}: {e}",
131                                target.display()
132                            ))
133                        })?;
134                }
135            }
136        }
137        Ok(())
138    }
139
140    fn listing(&self, spec: &StorageSpec) -> Result<StorageListing, ZigError> {
141        let target = self.resolve(&spec.path);
142        let mut entries = Vec::new();
143        match spec.kind {
144            StorageKind::Folder => {
145                let read_dir = match std::fs::read_dir(&target) {
146                    Ok(r) => r,
147                    // Folder hasn't been created yet (race) or was removed —
148                    // return an empty listing rather than failing the step.
149                    Err(_) => return Ok(StorageListing::default()),
150                };
151                for entry in read_dir.flatten() {
152                    let path = entry.path();
153                    let meta = match std::fs::metadata(&path) {
154                        Ok(m) => m,
155                        Err(_) => continue,
156                    };
157                    if !meta.is_file() {
158                        continue;
159                    }
160                    let name = match path.file_name() {
161                        Some(n) => n.to_string_lossy().into_owned(),
162                        None => continue,
163                    };
164                    entries.push(StorageEntry {
165                        name,
166                        size: Some(meta.len()),
167                    });
168                }
169                entries.sort_by(|a, b| a.name.cmp(&b.name));
170            }
171            StorageKind::File => {
172                if let Ok(meta) = std::fs::metadata(&target) {
173                    if meta.is_file() {
174                        let name = target
175                            .file_name()
176                            .map(|n| n.to_string_lossy().into_owned())
177                            .unwrap_or_else(|| target.display().to_string());
178                        entries.push(StorageEntry {
179                            name,
180                            size: Some(meta.len()),
181                        });
182                    }
183                }
184            }
185        }
186        Ok(StorageListing { entries })
187    }
188
189    fn abs_path(&self, spec: &StorageSpec) -> PathBuf {
190        self.resolve(&spec.path)
191    }
192}
193
194/// Owns the set of declared storage items for a single workflow run and
195/// routes operations to the appropriate backend. Built once in
196/// [`crate::run::execute`] and threaded through to every step.
197#[derive(Debug)]
198pub struct StorageManager {
199    items: Vec<StorageItem>,
200}
201
202/// A single declared storage item bound to the backend that services it.
203#[derive(Debug)]
204pub struct StorageItem {
205    /// The name the workflow author gave this storage entry in `[storage.*]`.
206    pub name: String,
207    /// The declaration from the workflow file.
208    pub spec: StorageSpec,
209    /// Backend that materialises this entry. Always `FilesystemBackend`
210    /// today; the trait leaves room for future sqlite/remote backends.
211    pub backend: Box<dyn StorageBackend + Send + Sync>,
212}
213
214impl StorageManager {
215    /// Build a manager from the workflow's `storage` table, wiring every
216    /// entry to a shared [`FilesystemBackend`]. Calls `ensure` on each
217    /// item so downstream steps can trust that the path is live.
218    pub fn build(
219        storage: &std::collections::HashMap<String, StorageSpec>,
220        backend: FilesystemBackend,
221    ) -> Result<Self, ZigError> {
222        let mut items = Vec::with_capacity(storage.len());
223        // Sort names so the listing order in prompts is deterministic.
224        let mut names: Vec<&String> = storage.keys().collect();
225        names.sort();
226        for name in names {
227            let spec = storage[name].clone();
228            backend.ensure(&spec)?;
229            items.push(StorageItem {
230                name: name.clone(),
231                spec,
232                backend: Box::new(backend.clone()),
233            });
234        }
235        Ok(Self { items })
236    }
237
238    /// Build a manager without calling `ensure` on its backends.
239    ///
240    /// Used by `--dry-run`: the `<storage>` block still renders with the
241    /// correct absolute paths, but no directories or files are created on
242    /// disk. The live `<contents>` listing will reflect whatever happens to
243    /// be on disk already (or be empty) rather than the state a real run
244    /// would produce — an acceptable trade-off for a preview.
245    pub fn build_dry(
246        storage: &std::collections::HashMap<String, StorageSpec>,
247        backend: FilesystemBackend,
248    ) -> Self {
249        let mut items = Vec::with_capacity(storage.len());
250        let mut names: Vec<&String> = storage.keys().collect();
251        names.sort();
252        for name in names {
253            let spec = storage[name].clone();
254            items.push(StorageItem {
255                name: name.clone(),
256                spec,
257                backend: Box::new(backend.clone()),
258            });
259        }
260        Self { items }
261    }
262
263    /// Empty manager — used when a workflow declares no storage.
264    pub fn empty() -> Self {
265        Self { items: Vec::new() }
266    }
267
268    /// Returns true when no storage is declared.
269    pub fn is_empty(&self) -> bool {
270        self.items.is_empty()
271    }
272
273    /// Iterate over every declared item, regardless of scoping.
274    pub fn iter(&self) -> impl Iterator<Item = &StorageItem> {
275        self.items.iter()
276    }
277
278    /// Return the items a step is allowed to see, applying the step's
279    /// `storage` scoping field.
280    ///
281    /// - `scope = None` (field omitted) → every declared item.
282    /// - `scope = Some(&[])` → no items.
283    /// - `scope = Some(names)` → only items whose name appears in `names`.
284    pub fn items_for_step(&self, scope: Option<&[String]>) -> Vec<&StorageItem> {
285        match scope {
286            None => self.items.iter().collect(),
287            Some([]) => Vec::new(),
288            Some(names) => self
289                .items
290                .iter()
291                .filter(|item| names.iter().any(|n| n == &item.name))
292                .collect(),
293        }
294    }
295
296    /// Render the `<storage>` XML block for a single step. Returns `None`
297    /// when the step is scoped to zero items (or no storage is declared),
298    /// which tells the caller to omit the block entirely.
299    pub fn render_block(&self, scope: Option<&[String]>) -> Result<Option<String>, ZigError> {
300        let items = self.items_for_step(scope);
301        if items.is_empty() {
302            return Ok(None);
303        }
304        let mut out = String::from("<storage>\n");
305        for item in items {
306            let abs = item.backend.abs_path(&item.spec);
307            out.push_str(&format!(
308                "  <item name=\"{}\" type=\"{}\" path=\"{}\">\n",
309                escape_xml(&item.name),
310                item.spec.kind,
311                escape_xml(&abs.display().to_string()),
312            ));
313            if let Some(desc) = item.spec.description.as_deref() {
314                out.push_str(&format!(
315                    "    <description>{}</description>\n",
316                    escape_xml(desc)
317                ));
318            }
319            if let Some(hint) = item.spec.hint.as_deref() {
320                out.push_str(&format!("    <hint>{}</hint>\n", escape_xml(hint)));
321            }
322            if !item.spec.files.is_empty() {
323                out.push_str("    <expected>\n");
324                for file in &item.spec.files {
325                    match file.description.as_deref() {
326                        Some(d) => out.push_str(&format!(
327                            "      - {}: {}\n",
328                            escape_xml(&file.name),
329                            escape_xml(d)
330                        )),
331                        None => out.push_str(&format!("      - {}\n", escape_xml(&file.name))),
332                    }
333                }
334                out.push_str("    </expected>\n");
335            }
336            let listing = item.backend.listing(&item.spec)?;
337            if !listing.entries.is_empty() {
338                out.push_str("    <contents>\n");
339                for entry in listing.entries {
340                    out.push_str(&format!("      - {}\n", escape_xml(&entry.name)));
341                }
342                out.push_str("    </contents>\n");
343            }
344            out.push_str("  </item>\n");
345        }
346        out.push_str("</storage>");
347        Ok(Some(out))
348    }
349
350    /// Return absolute on-disk paths for every storage folder the step can
351    /// see. The caller feeds these into the step's `add_dirs` so the
352    /// agent sandbox can actually read/write them.
353    pub fn add_dirs_for_step(&self, scope: Option<&[String]>) -> Vec<PathBuf> {
354        self.items_for_step(scope)
355            .into_iter()
356            .map(|item| {
357                let mut path = item.backend.abs_path(&item.spec);
358                // For file-typed storage, widen scope to the parent dir so
359                // the agent can actually open the file.
360                if matches!(item.spec.kind, StorageKind::File) {
361                    if let Some(parent) = path.parent() {
362                        path = parent.to_path_buf();
363                    }
364                }
365                path
366            })
367            .collect()
368    }
369}
370
371fn escape_xml(input: &str) -> String {
372    let mut out = String::with_capacity(input.len());
373    for ch in input.chars() {
374        match ch {
375            '<' => out.push_str("&lt;"),
376            '>' => out.push_str("&gt;"),
377            '&' => out.push_str("&amp;"),
378            '"' => out.push_str("&quot;"),
379            _ => out.push(ch),
380        }
381    }
382    out
383}
384
385/// Resolve a raw storage path against `<cwd>/.zig/` for callers that
386/// don't have a full [`StorageManager`] — used by tests and by code that
387/// wants to know the absolute path without going through the backend.
388pub fn resolve_against(root: &Path, raw_path: &str) -> PathBuf {
389    let expanded = PathBuf::from(expand_path(raw_path));
390    if expanded.is_absolute() {
391        expanded
392    } else {
393        root.join(expanded)
394    }
395}
396
397#[cfg(test)]
398#[path = "storage_tests.rs"]
399mod tests;