Skip to main content

cortexa_gcc/
workspace.rs

1//! GCC Workspace — core file-system operations (arXiv:2508.00031v2).
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use chrono::Utc;
7use fs2::FileExt;
8use uuid::Uuid;
9
10use crate::error::{GCCError, Result};
11use crate::models::{
12    desanitize, split_blocks, BranchMetadata, CommitRecord, ContextResult, OTARecord,
13};
14
15const MAIN_BRANCH: &str = "main";
16const GCC_DIR: &str = ".GCC";
17
18fn now() -> String {
19    Utc::now().to_rfc3339()
20}
21
22fn short_id() -> String {
23    Uuid::new_v4().to_string()[..8].to_string()
24}
25
26/// Return `Err(Validation)` if `value` is empty or whitespace-only.
27fn validate_not_empty(value: &str, field: &str) -> Result<()> {
28    if value.trim().is_empty() {
29        return Err(GCCError::Validation(format!("{field} must not be empty")));
30    }
31    Ok(())
32}
33
34/// Return `Err(Validation)` if `name` is not a valid branch identifier.
35fn validate_branch_name(name: &str) -> Result<()> {
36    validate_not_empty(name, "Branch name")?;
37    if name.contains('/') || name.contains('\\') {
38        return Err(GCCError::Validation(format!(
39            "Branch name must not contain path separators: {name:?}"
40        )));
41    }
42    if name == "." || name == ".." {
43        return Err(GCCError::Validation(format!(
44            "Branch name must not be '.' or '..': {name:?}"
45        )));
46    }
47    Ok(())
48}
49
50/// Manages the `.GCC/` directory structure for one agent project.
51///
52/// Implements the four GCC commands from arXiv:2508.00031v2:
53///   - COMMIT  (§3.2): milestone checkpointing
54///   - BRANCH  (§3.3): isolated reasoning workspace
55///   - MERGE   (§3.4): synthesise divergent paths
56///   - CONTEXT (§3.5): hierarchical memory retrieval
57pub struct GCCWorkspace {
58    gcc_dir: PathBuf,
59    current_branch: String,
60}
61
62/// RAII file lock guard. Acquires exclusive lock on creation, releases on drop.
63struct FileLock {
64    _file: fs::File,
65}
66
67impl FileLock {
68    fn acquire(gcc_dir: &Path) -> Result<Self> {
69        let lock_path = gcc_dir.join(".lock");
70        if let Some(parent) = lock_path.parent() {
71            fs::create_dir_all(parent)?;
72        }
73        let file = fs::OpenOptions::new()
74            .create(true)
75            .read(true)
76            .write(true)
77            .open(&lock_path)?;
78        file.lock_exclusive()
79            .map_err(|e| GCCError::Lock(e.to_string()))?;
80        Ok(Self { _file: file })
81    }
82}
83
84impl Drop for FileLock {
85    fn drop(&mut self) {
86        let _ = self._file.unlock();
87    }
88}
89
90impl GCCWorkspace {
91    pub fn new(project_root: impl AsRef<Path>) -> Self {
92        let gcc_dir = project_root.as_ref().join(GCC_DIR);
93        Self {
94            gcc_dir,
95            current_branch: MAIN_BRANCH.to_string(),
96        }
97    }
98
99    // ------------------------------------------------------------------ //
100    // Paths                                                                //
101    // ------------------------------------------------------------------ //
102
103    fn branch_dir(&self, branch: &str) -> PathBuf {
104        self.gcc_dir.join("branches").join(branch)
105    }
106
107    fn log_path(&self, branch: &str) -> PathBuf {
108        self.branch_dir(branch).join("log.md")
109    }
110
111    fn commit_path(&self, branch: &str) -> PathBuf {
112        self.branch_dir(branch).join("commit.md")
113    }
114
115    fn meta_path(&self, branch: &str) -> PathBuf {
116        self.branch_dir(branch).join("metadata.yaml")
117    }
118
119    fn main_md(&self) -> PathBuf {
120        self.gcc_dir.join("main.md")
121    }
122
123    // ------------------------------------------------------------------ //
124    // I/O helpers                                                          //
125    // ------------------------------------------------------------------ //
126
127    fn read(&self, path: &Path) -> String {
128        fs::read_to_string(path).unwrap_or_default()
129    }
130
131    fn write(&self, path: &Path, content: &str) -> Result<()> {
132        if let Some(parent) = path.parent() {
133            fs::create_dir_all(parent)?;
134        }
135        fs::write(path, content)?;
136        Ok(())
137    }
138
139    fn append(&self, path: &Path, content: &str) -> Result<()> {
140        use std::io::Write;
141        let mut file = fs::OpenOptions::new()
142            .append(true)
143            .create(true)
144            .open(path)?;
145        file.write_all(content.as_bytes())?;
146        Ok(())
147    }
148
149    fn read_commits(&self, branch: &str) -> Vec<CommitRecord> {
150        let text = self.read(&self.commit_path(branch));
151        let mut records = Vec::new();
152        for block in split_blocks(&text) {
153            let block = block.trim();
154            if block.is_empty() {
155                continue;
156            }
157            let mut commit_id = String::new();
158            let mut ts = String::new();
159            let mut current_field: Option<&str> = None;
160            let mut fields: std::collections::HashMap<&str, String> =
161                std::collections::HashMap::new();
162
163            for line in block.lines() {
164                if line.starts_with("## Commit `") {
165                    commit_id = line
166                        .trim_start_matches("## Commit `")
167                        .trim_end_matches('`')
168                        .to_string();
169                    current_field = None;
170                } else if line.starts_with("**Timestamp:**") {
171                    ts = line.replace("**Timestamp:**", "").trim().to_string();
172                    current_field = None;
173                } else if line.starts_with("**Branch Purpose:**") {
174                    let val = line.replace("**Branch Purpose:**", "").trim().to_string();
175                    fields.insert("branch_purpose", val);
176                    current_field = Some("branch_purpose");
177                } else if line.starts_with("**Previous Progress Summary:**") {
178                    let val = line
179                        .replace("**Previous Progress Summary:**", "")
180                        .trim()
181                        .to_string();
182                    fields.insert("prev_summary", val);
183                    current_field = Some("prev_summary");
184                } else if line.starts_with("**This Commit's Contribution:**") {
185                    let val = line
186                        .replace("**This Commit's Contribution:**", "")
187                        .trim()
188                        .to_string();
189                    fields.insert("contribution", val);
190                    current_field = Some("contribution");
191                } else if let Some(field) = current_field {
192                    let trimmed = line.trim();
193                    if !trimmed.is_empty() {
194                        if let Some(existing) = fields.get_mut(field) {
195                            existing.push('\n');
196                            existing.push_str(trimmed);
197                        }
198                    }
199                }
200            }
201            if !commit_id.is_empty() {
202                let get = |key: &str| -> String {
203                    desanitize(fields.get(key).map(|s| s.trim()).unwrap_or(""))
204                };
205                records.push(CommitRecord {
206                    commit_id,
207                    branch_name: branch.to_string(),
208                    branch_purpose: get("branch_purpose"),
209                    previous_progress_summary: get("prev_summary"),
210                    this_commit_contribution: get("contribution"),
211                    timestamp: ts,
212                });
213            }
214        }
215        records
216    }
217
218    fn read_ota(&self, branch: &str) -> Vec<OTARecord> {
219        let text = self.read(&self.log_path(branch));
220        let mut records = Vec::new();
221        for block in split_blocks(&text) {
222            let block = block.trim();
223            if block.is_empty() {
224                continue;
225            }
226            let mut step = 0usize;
227            let mut ts = String::new();
228            let mut current_field: Option<&str> = None;
229            let mut fields: std::collections::HashMap<&str, String> =
230                std::collections::HashMap::new();
231
232            for line in block.lines() {
233                if line.starts_with("### Step ") {
234                    let parts: Vec<&str> = line.splitn(2, '—').collect();
235                    step = parts[0]
236                        .replace("### Step ", "")
237                        .trim()
238                        .parse()
239                        .unwrap_or(0);
240                    ts = parts.get(1).unwrap_or(&"").trim().to_string();
241                    current_field = None;
242                } else if line.starts_with("**Observation:**") {
243                    let val = line.replace("**Observation:**", "").trim().to_string();
244                    fields.insert("obs", val);
245                    current_field = Some("obs");
246                } else if line.starts_with("**Thought:**") {
247                    let val = line.replace("**Thought:**", "").trim().to_string();
248                    fields.insert("thought", val);
249                    current_field = Some("thought");
250                } else if line.starts_with("**Action:**") {
251                    let val = line.replace("**Action:**", "").trim().to_string();
252                    fields.insert("action", val);
253                    current_field = Some("action");
254                } else if let Some(field) = current_field {
255                    let trimmed = line.trim();
256                    if !trimmed.is_empty() {
257                        if let Some(existing) = fields.get_mut(field) {
258                            existing.push('\n');
259                            existing.push_str(trimmed);
260                        }
261                    }
262                }
263            }
264            let get = |key: &str| -> String {
265                desanitize(fields.get(key).map(|s| s.trim()).unwrap_or(""))
266            };
267            let obs = get("obs");
268            let thought = get("thought");
269            if !obs.is_empty() || !thought.is_empty() {
270                records.push(OTARecord {
271                    step,
272                    timestamp: ts,
273                    observation: obs,
274                    thought,
275                    action: get("action"),
276                });
277            }
278        }
279        records
280    }
281
282    fn read_meta(&self, branch: &str) -> Option<BranchMetadata> {
283        let text = self.read(&self.meta_path(branch));
284        if text.is_empty() {
285            return None;
286        }
287        serde_yaml::from_str(&text).ok()
288    }
289
290    // ------------------------------------------------------------------ //
291    // Initialisation                                                       //
292    // ------------------------------------------------------------------ //
293
294    /// Initialise a new GCC workspace.
295    pub fn init(&mut self, project_roadmap: &str) -> Result<()> {
296        if self.gcc_dir.exists() {
297            return Err(GCCError::AlreadyExists {
298                path: self.gcc_dir.display().to_string(),
299            });
300        }
301
302        let main_dir = self.branch_dir(MAIN_BRANCH);
303        fs::create_dir_all(&main_dir)?;
304
305        let _lock = FileLock::acquire(&self.gcc_dir)?;
306
307        // main.md — global roadmap
308        let roadmap = format!(
309            "# Project Roadmap\n\n**Initialized:** {}\n\n{}\n",
310            now(),
311            project_roadmap
312        );
313        self.write(&self.main_md(), &roadmap)?;
314
315        // log.md and commit.md for main branch
316        self.write(
317            &self.log_path(MAIN_BRANCH),
318            &format!("# OTA Log — branch `{MAIN_BRANCH}`\n\n"),
319        )?;
320        self.write(
321            &self.commit_path(MAIN_BRANCH),
322            &format!("# Commit History — branch `{MAIN_BRANCH}`\n\n"),
323        )?;
324
325        // metadata.yaml
326        let meta = BranchMetadata {
327            name: MAIN_BRANCH.to_string(),
328            purpose: "Primary reasoning trajectory".to_string(),
329            created_from: String::new(),
330            created_at: now(),
331            status: "active".to_string(),
332            merged_into: None,
333            merged_at: None,
334        };
335        self.write(&self.meta_path(MAIN_BRANCH), &serde_yaml::to_string(&meta)?)?;
336
337        self.current_branch = MAIN_BRANCH.to_string();
338        Ok(())
339    }
340
341    /// Load an existing GCC workspace.
342    pub fn load(&mut self) -> Result<()> {
343        if !self.gcc_dir.exists() {
344            return Err(GCCError::NotFound {
345                path: self.gcc_dir.display().to_string(),
346            });
347        }
348        self.current_branch = MAIN_BRANCH.to_string();
349        Ok(())
350    }
351
352    // ------------------------------------------------------------------ //
353    // GCC Commands                                                         //
354    // ------------------------------------------------------------------ //
355
356    /// Append an OTA step to the current branch's `log.md`.
357    /// The paper logs continuous Observation–Thought–Action cycles.
358    pub fn log_ota(&self, observation: &str, thought: &str, action: &str) -> Result<OTARecord> {
359        if observation.trim().is_empty() && thought.trim().is_empty() && action.trim().is_empty() {
360            return Err(GCCError::Validation(
361                "At least one of observation, thought, or action must be non-empty".to_string(),
362            ));
363        }
364        let _lock = FileLock::acquire(&self.gcc_dir)?;
365        let existing = self.read_ota(&self.current_branch);
366        let step = existing.len() + 1;
367        let record = OTARecord {
368            step,
369            timestamp: now(),
370            observation: observation.to_string(),
371            thought: thought.to_string(),
372            action: action.to_string(),
373        };
374        self.append(&self.log_path(&self.current_branch), &record.to_markdown())?;
375        Ok(record)
376    }
377
378    /// COMMIT command (paper §3.2).
379    ///
380    /// Persists a milestone checkpoint to `commit.md` with fields:
381    /// Branch Purpose, Previous Progress Summary, This Commit's Contribution.
382    pub fn commit(
383        &self,
384        contribution: &str,
385        previous_summary: Option<&str>,
386        update_roadmap: Option<&str>,
387    ) -> Result<CommitRecord> {
388        validate_not_empty(contribution, "Contribution")?;
389        let _lock = FileLock::acquire(&self.gcc_dir)?;
390        self.commit_inner(contribution, previous_summary, update_roadmap)
391    }
392
393    /// Internal commit logic, called with lock already held.
394    fn commit_inner(
395        &self,
396        contribution: &str,
397        previous_summary: Option<&str>,
398        update_roadmap: Option<&str>,
399    ) -> Result<CommitRecord> {
400        let meta = self.read_meta(&self.current_branch);
401        let branch_purpose = meta.as_ref().map(|m| m.purpose.clone()).unwrap_or_default();
402
403        let prev = match previous_summary {
404            Some(s) => s.to_string(),
405            None => {
406                let commits = self.read_commits(&self.current_branch);
407                commits
408                    .last()
409                    .map(|c| c.this_commit_contribution.clone())
410                    .unwrap_or_else(|| "Initial state — no prior commits.".to_string())
411            }
412        };
413
414        let record = CommitRecord {
415            commit_id: short_id(),
416            branch_name: self.current_branch.clone(),
417            branch_purpose,
418            previous_progress_summary: prev,
419            this_commit_contribution: contribution.to_string(),
420            timestamp: now(),
421        };
422
423        self.append(
424            &self.commit_path(&self.current_branch),
425            &record.to_markdown(),
426        )?;
427
428        if let Some(roadmap_update) = update_roadmap {
429            let update = format!("\n## Update ({})\n{}\n", record.timestamp, roadmap_update);
430            self.append(&self.main_md(), &update)?;
431        }
432
433        Ok(record)
434    }
435
436    /// BRANCH command (paper §3.3).
437    ///
438    /// Creates isolated workspace: B_t^(name) = BRANCH(M_{t-1}).
439    /// Initialises empty OTA trace and commit.md; metadata records intent.
440    pub fn branch(&mut self, name: &str, purpose: &str) -> Result<()> {
441        validate_branch_name(name)?;
442        validate_not_empty(purpose, "Branch purpose")?;
443        let branch_dir = self.branch_dir(name);
444        if branch_dir.exists() {
445            return Err(GCCError::BranchExists {
446                name: name.to_string(),
447            });
448        }
449
450        fs::create_dir_all(&branch_dir)?;
451
452        let _lock = FileLock::acquire(&self.gcc_dir)?;
453        self.write(
454            &self.log_path(name),
455            &format!("# OTA Log — branch `{name}`\n\n"),
456        )?;
457        self.write(
458            &self.commit_path(name),
459            &format!("# Commit History — branch `{name}`\n\n"),
460        )?;
461
462        let meta = BranchMetadata {
463            name: name.to_string(),
464            purpose: purpose.to_string(),
465            created_from: self.current_branch.clone(),
466            created_at: now(),
467            status: "active".to_string(),
468            merged_into: None,
469            merged_at: None,
470        };
471        self.write(&self.meta_path(name), &serde_yaml::to_string(&meta)?)?;
472
473        self.current_branch = name.to_string();
474        Ok(())
475    }
476
477    /// MERGE command (paper §3.4).
478    ///
479    /// Integrates a completed branch back into `target` (default: main),
480    /// merging summaries and execution traces into a unified state.
481    pub fn merge(
482        &mut self,
483        branch_name: &str,
484        summary: Option<&str>,
485        target: &str,
486    ) -> Result<CommitRecord> {
487        validate_branch_name(branch_name)?;
488        validate_branch_name(target)?;
489        if !self.branch_dir(branch_name).exists() {
490            return Err(GCCError::BranchNotFound {
491                name: branch_name.to_string(),
492            });
493        }
494
495        let _lock = FileLock::acquire(&self.gcc_dir)?;
496
497        let branch_commits = self.read_commits(branch_name);
498        let branch_ota = self.read_ota(branch_name);
499        let meta = self.read_meta(branch_name);
500
501        let merge_summary = match summary {
502            Some(s) => s.to_string(),
503            None => {
504                let contribs: Vec<_> = branch_commits
505                    .iter()
506                    .map(|c| c.this_commit_contribution.as_str())
507                    .collect();
508                format!(
509                    "Merged branch `{}` ({} commits). Contributions: {}",
510                    branch_name,
511                    branch_commits.len(),
512                    contribs.join(" | ")
513                )
514            }
515        };
516
517        // Append branch OTA to target log
518        if !branch_ota.is_empty() {
519            let header = format!("\n## Merged from `{}` ({})\n\n", branch_name, now());
520            self.append(&self.log_path(target), &header)?;
521            for rec in &branch_ota {
522                self.append(&self.log_path(target), &rec.to_markdown())?;
523            }
524        }
525
526        // Create merge commit on target
527        self.current_branch = target.to_string();
528        let prev = format!(
529            "Merging branch `{}` with purpose: {}",
530            branch_name,
531            meta.as_ref().map(|m| m.purpose.as_str()).unwrap_or("")
532        );
533        let merge_commit = self.commit_inner(&merge_summary, Some(&prev), Some(&merge_summary))?;
534
535        // Mark branch as merged
536        if let Some(mut m) = meta {
537            m.status = "merged".to_string();
538            m.merged_into = Some(target.to_string());
539            m.merged_at = Some(now());
540            self.write(&self.meta_path(branch_name), &serde_yaml::to_string(&m)?)?;
541        }
542
543        Ok(merge_commit)
544    }
545
546    /// CONTEXT command (paper §3.5).
547    ///
548    /// Retrieves historical context at K-commit resolution.
549    /// Paper experiments use K=1 (only most recent commit revealed).
550    pub fn context(&self, branch: Option<&str>, k: usize) -> Result<ContextResult> {
551        if k < 1 {
552            return Err(GCCError::Validation(format!("k must be >= 1, got {k}")));
553        }
554        let _lock = FileLock::acquire(&self.gcc_dir)?;
555        let target = branch.unwrap_or(&self.current_branch);
556        if !self.branch_dir(target).exists() {
557            return Err(GCCError::BranchNotFound {
558                name: target.to_string(),
559            });
560        }
561
562        let all_commits = self.read_commits(target);
563        let skip = if all_commits.len() > k {
564            all_commits.len() - k
565        } else {
566            0
567        };
568        let commits = all_commits[skip..].to_vec();
569        let ota_records = self.read_ota(target);
570        let main_roadmap = self.read(&self.main_md());
571        let metadata = self.read_meta(target);
572
573        Ok(ContextResult {
574            branch_name: target.to_string(),
575            k,
576            commits,
577            ota_records,
578            main_roadmap,
579            metadata,
580        })
581    }
582
583    // ------------------------------------------------------------------ //
584    // Helpers                                                              //
585    // ------------------------------------------------------------------ //
586
587    pub fn current_branch(&self) -> &str {
588        &self.current_branch
589    }
590
591    pub fn switch_branch(&mut self, name: &str) -> Result<()> {
592        validate_branch_name(name)?;
593        if !self.branch_dir(name).exists() {
594            return Err(GCCError::BranchNotFound {
595                name: name.to_string(),
596            });
597        }
598        self.current_branch = name.to_string();
599        Ok(())
600    }
601
602    pub fn list_branches(&self) -> Vec<String> {
603        let branches_root = self.gcc_dir.join("branches");
604        fs::read_dir(&branches_root)
605            .map(|entries| {
606                entries
607                    .filter_map(|e| e.ok())
608                    .filter(|e| e.path().is_dir())
609                    .map(|e| e.file_name().to_string_lossy().to_string())
610                    .collect()
611            })
612            .unwrap_or_default()
613    }
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619    use tempfile::TempDir;
620
621    fn workspace() -> (TempDir, GCCWorkspace) {
622        let dir = TempDir::new().unwrap();
623        let mut ws = GCCWorkspace::new(dir.path());
624        ws.init("Test project roadmap").unwrap();
625        (dir, ws)
626    }
627
628    #[test]
629    fn test_init_creates_gcc_structure() {
630        let (dir, _) = workspace();
631        assert!(dir.path().join(".GCC/main.md").exists());
632        assert!(dir.path().join(".GCC/branches/main/log.md").exists());
633        assert!(dir.path().join(".GCC/branches/main/commit.md").exists());
634        assert!(dir.path().join(".GCC/branches/main/metadata.yaml").exists());
635    }
636
637    #[test]
638    fn test_log_ota_increments_step() {
639        let (_dir, ws) = workspace();
640        let r1 = ws.log_ota("obs1", "thought1", "action1").unwrap();
641        let r2 = ws.log_ota("obs2", "thought2", "action2").unwrap();
642        assert_eq!(r1.step, 1);
643        assert_eq!(r2.step, 2);
644    }
645
646    #[test]
647    fn test_commit_writes_checkpoint() {
648        let (_dir, ws) = workspace();
649        let c = ws.commit("Initial scaffold done", None, None).unwrap();
650        assert_eq!(c.this_commit_contribution, "Initial scaffold done");
651        assert_eq!(c.branch_name, "main");
652        assert_eq!(c.commit_id.len(), 8);
653    }
654
655    #[test]
656    fn test_branch_creates_isolated_workspace() {
657        let (_dir, mut ws) = workspace();
658        ws.branch("experiment-a", "Try alternative algorithm")
659            .unwrap();
660        assert_eq!(ws.current_branch(), "experiment-a");
661        let ota = ws.read_ota("experiment-a");
662        assert!(ota.is_empty());
663    }
664
665    #[test]
666    fn test_merge_integrates_branch() {
667        let (_dir, mut ws) = workspace();
668        ws.commit("Main first commit", None, None).unwrap();
669        ws.branch("feature", "Add feature X").unwrap();
670        ws.log_ota("feature obs", "feature thought", "feature action")
671            .unwrap();
672        ws.commit("Feature X done", None, None).unwrap();
673        let merge_commit = ws.merge("feature", None, "main").unwrap();
674        assert!(merge_commit.this_commit_contribution.contains("feature"));
675        assert_eq!(ws.current_branch(), "main");
676    }
677
678    #[test]
679    fn test_context_k1_returns_last_commit() {
680        let (_dir, ws) = workspace();
681        ws.commit("C1", None, None).unwrap();
682        ws.commit("C2", None, None).unwrap();
683        ws.commit("C3", None, None).unwrap();
684        let ctx = ws.context(None, 1).unwrap();
685        assert_eq!(ctx.commits.len(), 1);
686        assert_eq!(ctx.commits[0].this_commit_contribution, "C3");
687    }
688
689    #[test]
690    fn test_branch_metadata_records_purpose() {
691        let (_dir, mut ws) = workspace();
692        ws.branch("jwt-branch", "JWT auth experiment").unwrap();
693        let meta = ws.read_meta("jwt-branch").unwrap();
694        assert_eq!(meta.purpose, "JWT auth experiment");
695        assert_eq!(meta.created_from, "main");
696        assert_eq!(meta.status, "active");
697    }
698
699    #[test]
700    fn test_merge_marks_branch_merged() {
701        let (_dir, mut ws) = workspace();
702        ws.branch("to-merge", "Will be merged").unwrap();
703        ws.commit("Branch work", None, None).unwrap();
704        ws.merge("to-merge", None, "main").unwrap();
705        let meta = ws.read_meta("to-merge").unwrap();
706        assert_eq!(meta.status, "merged");
707        assert_eq!(meta.merged_into.unwrap(), "main");
708    }
709}