ralph-workflow 0.7.18

PROMPT-driven multi-agent orchestrator for git repos
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
//! Workspace filesystem abstraction for explicit path resolution.
//!
//! This module provides the [`Workspace`] trait and implementations that eliminate
//! CWD dependencies by making all path operations explicit relative to the repository root.
//!
//! # Problem
//!
//! The codebase previously relied on `std::env::set_current_dir()` to set the
//! process CWD to the repository root, then used relative paths (`.agent/`,
//! `PROMPT.md`, etc.) throughout. This caused:
//!
//! - Test flakiness when tests ran in parallel (CWD is process-global)
//! - Background thread bugs when CWD changed after thread started
//! - Poor testability without complex CWD manipulation
//!
//! # Solution
//!
//! The [`Workspace`] trait defines the interface for file operations, with two implementations:
//!
//! - [`WorkspaceFs`] - Production implementation using the real filesystem
//! - `MemoryWorkspace` - Test implementation with in-memory storage (available with `test-utils` feature)
//!
//! # Well-Known Paths
//!
//! This module defines constants for all Ralph artifact paths:
//!
//! - [`AGENT_DIR`] - `.agent/` directory
//! - [`PLAN_MD`] - `.agent/PLAN.md`
//! - [`ISSUES_MD`] - `.agent/ISSUES.md`
//! - [`PROMPT_MD`] - `PROMPT.md` (repository root)
//! - [`CHECKPOINT_JSON`] - `.agent/checkpoint.json`
//!
//! The [`Workspace`] trait provides convenience methods for these paths (e.g., [`Workspace::plan_md`]).
//!
//! # Production Example
//!
//! ```ignore
//! use ralph_workflow::workspace::WorkspaceFs;
//! use std::path::PathBuf;
//!
//! let ws = WorkspaceFs::new(PathBuf::from("/path/to/repo"));
//!
//! // Get paths to well-known files
//! let plan = ws.plan_md();  // /path/to/repo/.agent/PLAN.md
//! let prompt = ws.prompt_md();  // /path/to/repo/PROMPT.md
//!
//! // Perform file operations
//! ws.write(Path::new(".agent/test.txt"), "content")?;
//! let content = ws.read(Path::new(".agent/test.txt"))?;
//! ```
//!
//! # Testing with `MemoryWorkspace`
//!
//! The `test-utils` feature enables `MemoryWorkspace` for integration tests:
//!
//! ```ignore
//! use ralph_workflow::workspace::{MemoryWorkspace, Workspace};
//! use std::path::Path;
//!
//! // Create a test workspace with pre-populated files
//! let ws = MemoryWorkspace::new_test()
//!     .with_file("PROMPT.md", "# Task: Add logging")
//!     .with_file(".agent/PLAN.md", "1. Add log statements");
//!
//! // Verify file operations
//! assert!(ws.exists(Path::new("PROMPT.md")));
//! assert_eq!(ws.read(Path::new("PROMPT.md"))?, "# Task: Add logging");
//!
//! // Write and verify
//! ws.write(Path::new(".agent/output.txt"), "result")?;
//! assert!(ws.was_written(".agent/output.txt"));
//! ```
//!
//! # See Also
//!
//! - [`crate::executor::ProcessExecutor`] - Similar abstraction for process execution

use std::path::{Path, PathBuf};

// ============================================================================
// Well-known path constants
// ============================================================================

include!("workspace/paths.rs");

// ============================================================================
// DirEntry - abstraction for directory entries
// ============================================================================

include!("workspace/dir_entry.rs");

// ============================================================================
// Workspace Trait
// ============================================================================

/// Trait defining the workspace filesystem interface.
///
/// This trait abstracts file operations relative to a repository root, allowing
/// for both real filesystem access (production) and in-memory storage (testing).
pub trait Workspace: Send + Sync {
    /// Get the repository root path.
    fn root(&self) -> &Path;

    // =========================================================================
    // File operations
    // =========================================================================

    /// Read a file relative to the repository root.
    ///
    /// # Errors
    ///
    /// Returns error if the operation fails.
    fn read(&self, relative: &Path) -> std::io::Result<String>;

    /// Read a file as bytes relative to the repository root.
    ///
    /// # Errors
    ///
    /// Returns error if the operation fails.
    fn read_bytes(&self, relative: &Path) -> std::io::Result<Vec<u8>>;

    /// Write content to a file relative to the repository root.
    /// Creates parent directories if they don't exist.
    ///
    /// # Errors
    ///
    /// Returns error if the operation fails.
    fn write(&self, relative: &Path, content: &str) -> std::io::Result<()>;

    /// Write bytes to a file relative to the repository root.
    /// Creates parent directories if they don't exist.
    ///
    /// # Errors
    ///
    /// Returns error if the operation fails.
    fn write_bytes(&self, relative: &Path, content: &[u8]) -> std::io::Result<()>;

    /// Append bytes to a file relative to the repository root.
    /// Creates the file if it doesn't exist. Creates parent directories if needed.
    ///
    /// # Errors
    ///
    /// Returns error if the operation fails.
    fn append_bytes(&self, relative: &Path, content: &[u8]) -> std::io::Result<()>;

    /// Check if a path exists relative to the repository root.
    fn exists(&self, relative: &Path) -> bool;

    /// Check if a path is a file relative to the repository root.
    fn is_file(&self, relative: &Path) -> bool;

    /// Check if a path is a directory relative to the repository root.
    fn is_dir(&self, relative: &Path) -> bool;

    /// Remove a file relative to the repository root.
    ///
    /// # Errors
    ///
    /// Returns error if the operation fails.
    fn remove(&self, relative: &Path) -> std::io::Result<()>;

    /// Remove a file if it exists, silently succeeding if it doesn't.
    ///
    /// # Errors
    ///
    /// Returns error if the operation fails.
    fn remove_if_exists(&self, relative: &Path) -> std::io::Result<()>;

    /// Remove a directory and all its contents relative to the repository root.
    ///
    /// Similar to `std::fs::remove_dir_all`, this removes a directory and everything inside it.
    /// Returns an error if the directory doesn't exist.
    ///
    /// # Errors
    ///
    /// Returns error if the operation fails.
    fn remove_dir_all(&self, relative: &Path) -> std::io::Result<()>;

    /// Remove a directory and all its contents if it exists, silently succeeding if it doesn't.
    ///
    /// # Errors
    ///
    /// Returns error if the operation fails.
    fn remove_dir_all_if_exists(&self, relative: &Path) -> std::io::Result<()>;

    /// Create a directory and all parent directories relative to the repository root.
    ///
    /// # Errors
    ///
    /// Returns error if the operation fails.
    fn create_dir_all(&self, relative: &Path) -> std::io::Result<()>;

    /// List entries in a directory relative to the repository root.
    ///
    /// Returns a vector of `DirEntry`-like information for each entry.
    /// For production, this wraps `std::fs::read_dir`.
    /// For testing, this returns entries from the in-memory filesystem.
    ///
    /// # Errors
    ///
    /// Returns error if the operation fails.
    fn read_dir(&self, relative: &Path) -> std::io::Result<Vec<DirEntry>>;

    /// Rename/move a file from one path to another relative to the repository root.
    ///
    /// This is used for backup rotation where files are moved to new names.
    /// Returns an error if the source file doesn't exist.
    ///
    /// # Errors
    ///
    /// Returns error if the operation fails.
    fn rename(&self, from: &Path, to: &Path) -> std::io::Result<()>;

    /// Write content to a file atomically using temp file + rename pattern.
    ///
    /// This ensures the file is either fully written or not written at all,
    /// preventing partial writes or corruption from crashes/interruptions.
    ///
    /// # Implementation details
    ///
    /// - `WorkspaceFs`: Uses `tempfile::NamedTempFile` in the same directory,
    ///   writes content, syncs to disk, then atomically renames to target.
    ///   On Unix, temp file has mode 0600 for security.
    /// - `MemoryWorkspace`: Just calls `write()` since in-memory operations
    ///   are inherently atomic (no partial state possible).
    ///
    /// # When to use
    ///
    /// Use `write_atomic()` for critical files where corruption would be problematic:
    /// - XML outputs (issues.xml, plan.xml, `commit_message.xml`)
    /// - Agent artifacts (PLAN.md, commit-message.txt)
    /// - Any file that must not have partial content
    ///
    /// Use regular `write()` for:
    /// - Log files (append-only, partial is acceptable)
    /// - Temporary/debug files
    /// - Files where performance matters more than atomicity
    ///
    /// # Errors
    ///
    /// Returns error if the operation fails.
    fn write_atomic(&self, relative: &Path, content: &str) -> std::io::Result<()>;

    /// Set a file to read-only permissions.
    ///
    /// This is a best-effort operation for protecting files like PROMPT.md backups.
    /// On Unix, sets permissions to 0o444.
    /// On Windows, sets the readonly flag.
    /// In-memory implementations may no-op since permissions aren't relevant for testing.
    ///
    /// Returns Ok(()) on success or if the file doesn't exist (nothing to protect).
    /// Returns Err only if the file exists but permissions cannot be changed.
    ///
    /// # Errors
    ///
    /// Returns error if the operation fails.
    fn set_readonly(&self, relative: &Path) -> std::io::Result<()>;

    /// Set a file to writable permissions.
    ///
    /// Reverses the effect of `set_readonly`.
    /// On Unix, sets permissions to 0o644.
    /// On Windows, clears the readonly flag.
    /// In-memory implementations may no-op since permissions aren't relevant for testing.
    ///
    /// Returns Ok(()) on success or if the file doesn't exist.
    ///
    /// # Errors
    ///
    /// Returns error if the operation fails.
    fn set_writable(&self, relative: &Path) -> std::io::Result<()>;

    // =========================================================================
    // Path resolution (default implementations)
    // =========================================================================

    /// Resolve a relative path to an absolute path.
    fn absolute(&self, relative: &Path) -> PathBuf {
        self.root().join(relative)
    }

    /// Resolve a relative path to an absolute path as a string.
    fn absolute_str(&self, relative: &str) -> String {
        self.root().join(relative).display().to_string()
    }

    // =========================================================================
    // Well-known paths (default implementations)
    // =========================================================================

    /// Path to the `.agent` directory.
    fn agent_dir(&self) -> PathBuf {
        self.root().join(AGENT_DIR)
    }

    /// Path to the `.agent/tmp` directory.
    fn agent_tmp(&self) -> PathBuf {
        self.root().join(AGENT_TMP)
    }

    /// Path to `.agent/PLAN.md`.
    fn plan_md(&self) -> PathBuf {
        self.root().join(PLAN_MD)
    }

    /// Path to `.agent/ISSUES.md`.
    fn issues_md(&self) -> PathBuf {
        self.root().join(ISSUES_MD)
    }

    /// Path to `.agent/STATUS.md`.
    fn status_md(&self) -> PathBuf {
        self.root().join(STATUS_MD)
    }

    /// Path to `.agent/NOTES.md`.
    fn notes_md(&self) -> PathBuf {
        self.root().join(NOTES_MD)
    }

    /// Path to `.agent/commit-message.txt`.
    fn commit_message(&self) -> PathBuf {
        self.root().join(COMMIT_MESSAGE_TXT)
    }

    /// Path to `.agent/checkpoint.json`.
    fn checkpoint(&self) -> PathBuf {
        self.root().join(CHECKPOINT_JSON)
    }

    /// Path to `.agent/start_commit`.
    fn start_commit(&self) -> PathBuf {
        self.root().join(START_COMMIT)
    }

    /// Path to `.agent/review_baseline.txt`.
    fn review_baseline(&self) -> PathBuf {
        self.root().join(REVIEW_BASELINE_TXT)
    }

    /// Path to `PROMPT.md` in the repository root.
    fn prompt_md(&self) -> PathBuf {
        self.root().join(PROMPT_MD)
    }

    /// Path to `.agent/PROMPT.md.backup`.
    fn prompt_backup(&self) -> PathBuf {
        self.root().join(PROMPT_BACKUP)
    }

    /// Path to `.agent/config.toml`.
    fn agent_config(&self) -> PathBuf {
        self.root().join(AGENT_CONFIG_TOML)
    }

    /// Path to `.agent/agents.toml`.
    fn agents_toml(&self) -> PathBuf {
        self.root().join(AGENTS_TOML)
    }

    /// Path to an XSD schema file in `.agent/tmp/`.
    fn xsd_path(&self, name: &str) -> PathBuf {
        self.root().join(format!(".agent/tmp/{name}.xsd"))
    }

    /// Path to an XML file in `.agent/tmp/`.
    fn xml_path(&self, name: &str) -> PathBuf {
        self.root().join(format!(".agent/tmp/{name}.xml"))
    }

    /// Path to a log file in `.agent/logs/`.
    fn log_path(&self, name: &str) -> PathBuf {
        self.root().join(format!(".agent/logs/{name}"))
    }
}

// ============================================================================
// Production Implementation: WorkspaceFs
// ============================================================================

pub mod files;

// Re-export WorkspaceFs for backward compatibility
pub use files::WorkspaceFs;

// ============================================================================
// Test Implementation: MemoryWorkspace
// ============================================================================

#[cfg(any(test, feature = "test-utils"))]
pub mod memory_workspace;

#[cfg(any(test, feature = "test-utils"))]
pub use memory_workspace::MemoryWorkspace;

// ============================================================================
// Tests
// ============================================================================

#[cfg(test)]
mod tests {
    use super::*;

    include!("workspace/tests.rs");
}