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    /// Empty manager — used when a workflow declares no storage.
239    pub fn empty() -> Self {
240        Self { items: Vec::new() }
241    }
242
243    /// Returns true when no storage is declared.
244    pub fn is_empty(&self) -> bool {
245        self.items.is_empty()
246    }
247
248    /// Iterate over every declared item, regardless of scoping.
249    pub fn iter(&self) -> impl Iterator<Item = &StorageItem> {
250        self.items.iter()
251    }
252
253    /// Return the items a step is allowed to see, applying the step's
254    /// `storage` scoping field.
255    ///
256    /// - `scope = None` (field omitted) → every declared item.
257    /// - `scope = Some(&[])` → no items.
258    /// - `scope = Some(names)` → only items whose name appears in `names`.
259    pub fn items_for_step(&self, scope: Option<&[String]>) -> Vec<&StorageItem> {
260        match scope {
261            None => self.items.iter().collect(),
262            Some([]) => Vec::new(),
263            Some(names) => self
264                .items
265                .iter()
266                .filter(|item| names.iter().any(|n| n == &item.name))
267                .collect(),
268        }
269    }
270
271    /// Render the `<storage>` XML block for a single step. Returns `None`
272    /// when the step is scoped to zero items (or no storage is declared),
273    /// which tells the caller to omit the block entirely.
274    pub fn render_block(&self, scope: Option<&[String]>) -> Result<Option<String>, ZigError> {
275        let items = self.items_for_step(scope);
276        if items.is_empty() {
277            return Ok(None);
278        }
279        let mut out = String::from("<storage>\n");
280        for item in items {
281            let abs = item.backend.abs_path(&item.spec);
282            out.push_str(&format!(
283                "  <item name=\"{}\" type=\"{}\" path=\"{}\">\n",
284                escape_xml(&item.name),
285                item.spec.kind,
286                escape_xml(&abs.display().to_string()),
287            ));
288            if let Some(desc) = item.spec.description.as_deref() {
289                out.push_str(&format!(
290                    "    <description>{}</description>\n",
291                    escape_xml(desc)
292                ));
293            }
294            if let Some(hint) = item.spec.hint.as_deref() {
295                out.push_str(&format!("    <hint>{}</hint>\n", escape_xml(hint)));
296            }
297            if !item.spec.files.is_empty() {
298                out.push_str("    <expected>\n");
299                for file in &item.spec.files {
300                    match file.description.as_deref() {
301                        Some(d) => out.push_str(&format!(
302                            "      - {}: {}\n",
303                            escape_xml(&file.name),
304                            escape_xml(d)
305                        )),
306                        None => out.push_str(&format!("      - {}\n", escape_xml(&file.name))),
307                    }
308                }
309                out.push_str("    </expected>\n");
310            }
311            let listing = item.backend.listing(&item.spec)?;
312            if !listing.entries.is_empty() {
313                out.push_str("    <contents>\n");
314                for entry in listing.entries {
315                    out.push_str(&format!("      - {}\n", escape_xml(&entry.name)));
316                }
317                out.push_str("    </contents>\n");
318            }
319            out.push_str("  </item>\n");
320        }
321        out.push_str("</storage>");
322        Ok(Some(out))
323    }
324
325    /// Return absolute on-disk paths for every storage folder the step can
326    /// see. The caller feeds these into the step's `add_dirs` so the
327    /// agent sandbox can actually read/write them.
328    pub fn add_dirs_for_step(&self, scope: Option<&[String]>) -> Vec<PathBuf> {
329        self.items_for_step(scope)
330            .into_iter()
331            .map(|item| {
332                let mut path = item.backend.abs_path(&item.spec);
333                // For file-typed storage, widen scope to the parent dir so
334                // the agent can actually open the file.
335                if matches!(item.spec.kind, StorageKind::File) {
336                    if let Some(parent) = path.parent() {
337                        path = parent.to_path_buf();
338                    }
339                }
340                path
341            })
342            .collect()
343    }
344}
345
346fn escape_xml(input: &str) -> String {
347    let mut out = String::with_capacity(input.len());
348    for ch in input.chars() {
349        match ch {
350            '<' => out.push_str("&lt;"),
351            '>' => out.push_str("&gt;"),
352            '&' => out.push_str("&amp;"),
353            '"' => out.push_str("&quot;"),
354            _ => out.push(ch),
355        }
356    }
357    out
358}
359
360/// Resolve a raw storage path against `<cwd>/.zig/` for callers that
361/// don't have a full [`StorageManager`] — used by tests and by code that
362/// wants to know the absolute path without going through the backend.
363pub fn resolve_against(root: &Path, raw_path: &str) -> PathBuf {
364    let expanded = PathBuf::from(expand_path(raw_path));
365    if expanded.is_absolute() {
366        expanded
367    } else {
368        root.join(expanded)
369    }
370}
371
372#[cfg(test)]
373#[path = "storage_tests.rs"]
374mod tests;