Skip to main content

forge_agent/userdata/catalog/
mutations.rs

1//! Filesystem-backed session mutations:
2//!
3//! - [`tag_session`] - appends a `tag` JSONL entry (pass `None` to clear).
4
5use std::fs;
6use std::io::Write;
7use std::path::{Path, PathBuf};
8
9use serde_json::json;
10
11use forge_sdk::{Error, projects_dir_for};
12
13use crate::userdata::catalog::scan::is_valid_uuid;
14
15/// Tag a session. Pass `None` to clear the tag (appends an empty-string
16/// entry which `list_sessions` interprets as cleared). `project` is
17/// required: a tag row that names no project cannot be told from another
18/// project's.
19///
20/// The append is not fsynced, so a crash mid-write can leave a half-written
21/// final line. The scanner still reads that line and reports the tag it
22/// carries, and keeps it out of the cached state, so the next scan reads it
23/// again.
24///
25/// # Errors
26///
27/// - [`Error::MessageParse`] when `session_id` is not a valid UUID, a
28///   non-None `tag` is empty after trimming, or `project` is empty.
29/// - [`Error::Io`] when the session file can't be found or the append
30///   fails.
31pub fn tag_session(
32    config_dir: &Path,
33    session_id: &str,
34    tag: Option<&str>,
35    project: &str,
36    directory: Option<&str>,
37) -> Result<(), Error> {
38    validate_uuid(session_id)?;
39    let stored: String = match tag {
40        None => String::new(),
41        Some(raw) => {
42            let stripped = raw.trim();
43            if stripped.is_empty() {
44                return Err(Error::message_parse("tag must be non-empty (use None to clear)"));
45            }
46            stripped.to_string()
47        }
48    };
49    if project.trim().is_empty() {
50        return Err(Error::message_parse(
51            "project must be non-empty: an unscoped tag row cannot be told from another \
52             project's",
53        ));
54    }
55    let payload = json!({
56        "type": "tag",
57        "tag": stored,
58        // Scopes the tag to the forge project that wrote it, so a resume
59        // in another project composing the same worktree directory cannot
60        // claim this session. Required: a tag write that cannot name its
61        // project refuses rather than writing an unscoped row.
62        "project": project.trim(),
63        "sessionId": session_id,
64    });
65    append_to_session(config_dir, session_id, directory, &payload)
66}
67
68/// The CLI names transcripts by session UUID, so a stem that is not one is
69/// refused rather than matched against some other file.
70fn validate_uuid(s: &str) -> Result<(), Error> {
71    if is_valid_uuid(s) {
72        Ok(())
73    } else {
74        Err(Error::message_parse(format!("Invalid session_id: {s}")))
75    }
76}
77
78fn find_session_file(
79    config_dir: &Path,
80    session_id: &str,
81    directory: Option<&str>,
82) -> Option<PathBuf> {
83    let file_name = format!("{session_id}.jsonl");
84    if let Some(dir) = directory {
85        let canonical = match fs::canonicalize(dir) {
86            Ok(p) => p.to_string_lossy().into_owned(),
87            Err(_) => dir.to_string(),
88        };
89        let project_dir = projects_dir_for(config_dir)
90            .join(crate::userdata::catalog::scan::sanitize_path_public(&canonical));
91        let candidate = project_dir.join(&file_name);
92        return candidate.is_file().then_some(candidate);
93    }
94    fs::read_dir(projects_dir_for(config_dir))
95        .ok()
96        .and_then(|iter| iter.flatten().map(|e| e.path().join(&file_name)).find(|p| p.is_file()))
97}
98
99fn append_to_session(
100    config_dir: &Path,
101    session_id: &str,
102    directory: Option<&str>,
103    payload: &serde_json::Value,
104) -> Result<(), Error> {
105    let path = find_session_file(config_dir, session_id, directory).ok_or_else(|| {
106        Error::Io(std::io::Error::new(
107            std::io::ErrorKind::NotFound,
108            format!("session {session_id} not found"),
109        ))
110    })?;
111    let mut line =
112        serde_json::to_string(payload).map_err(|e| Error::encode("mutation payload", e))?;
113    line.push('\n');
114    append_line(&path, line.as_bytes())
115}
116
117fn append_line(path: &Path, data: &[u8]) -> Result<(), Error> {
118    let mut file = fs::OpenOptions::new().append(true).open(path)?;
119    file.write_all(data)?;
120    Ok(())
121}
122
123#[cfg(test)]
124mod tests {
125
126    use super::*;
127
128    fn fake_config_dir() -> PathBuf {
129        PathBuf::from("/tmp/forge_mutations_test_cfg")
130    }
131
132    #[test]
133    fn empty_tag_is_rejected() {
134        let session_id = "550e8400-e29b-41d4-a716-446655440001";
135        let r = tag_session(&fake_config_dir(), session_id, Some("   "), "Default/forge", None);
136        assert!(matches!(r, Err(Error::MessageParse { .. })));
137    }
138}