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("<"),
376 '>' => out.push_str(">"),
377 '&' => out.push_str("&"),
378 '"' => out.push_str("""),
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;