Skip to main content

agent_trace/trace/
pipeline.rs

1use crate::git_store::CommitInfo;
2use crate::llm::trace_insights::Llm;
3use crate::permissions::{check_permission, PermissionResult};
4use crate::store::Store;
5use crate::trace::context::synthesize_context_content;
6use crate::trace::running_summary::{self, SummaryEvent};
7use crate::trace::{
8    agent_trace_md,
9    logs::{append_agent_log, summarize_change_no_llm, LogSynthEntry},
10};
11use crate::types::{Action, Actor, DocType};
12use chrono::Utc;
13use std::path::{Path, PathBuf};
14use thiserror::Error;
15
16#[derive(Debug, Error)]
17pub enum WriteDocumentError {
18    #[error("Permission denied: {path} — {reason}")]
19    PermissionDenied { path: PathBuf, reason: String },
20    #[error(transparent)]
21    Other(#[from] anyhow::Error),
22}
23
24fn detected_by_from_source(source: &str) -> &'static str {
25    match source {
26        "mcp_write" | "mcp" => "mcp",
27        "cli_write" | "cli" => "cli",
28        "poll" => "poll",
29        _ => "system",
30    }
31}
32
33fn source_from_prefix(summary_prefix: &str) -> &'static str {
34    if summary_prefix.starts_with("mcp") {
35        "mcp_write"
36    } else if summary_prefix.starts_with("agent") {
37        "cli_write"
38    } else {
39        "system"
40    }
41}
42
43pub fn write_document(
44    root: &Path,
45    file: &Path,
46    content: &str,
47    actor: &Actor,
48    summary_prefix: &str,
49    session_id: Option<&str>,
50) -> std::result::Result<PathBuf, WriteDocumentError> {
51    let rel = if file.is_absolute() {
52        file.strip_prefix(root).unwrap_or(file).to_path_buf()
53    } else {
54        file.to_path_buf()
55    };
56
57    let mut store = Store::open(root).map_err(WriteDocumentError::Other)?;
58
59    let (doc_type, was_tracked) = match store.manifest.find_by_path(&rel) {
60        Some(entry) => (entry.doc_type.clone(), true),
61        None => (DocType::Scratch, false),
62    };
63
64    match check_permission(&doc_type, actor, &store.overrides, Some(&rel)) {
65        PermissionResult::Denied { reason } => {
66            return Err(WriteDocumentError::PermissionDenied { path: rel, reason });
67        }
68        PermissionResult::Allowed | PermissionResult::RequiresConfirmation { .. } => {}
69    }
70
71    let full_path = root.join(&rel);
72    if let Some(parent) = full_path.parent() {
73        std::fs::create_dir_all(parent).map_err(|e| WriteDocumentError::Other(e.into()))?;
74    }
75
76    let action = if full_path.exists() {
77        Action::Modify
78    } else {
79        Action::Create
80    };
81    std::fs::write(&full_path, content).map_err(|e| WriteDocumentError::Other(e.into()))?;
82
83    let files_to_commit = vec![(rel.clone(), action, doc_type)];
84
85    if !was_tracked {
86        store
87            .manifest
88            .register(&rel, DocType::Scratch, actor.agent_name().unwrap_or(""))
89            .map_err(WriteDocumentError::Other)?;
90        store
91            .manifest
92            .save(root)
93            .map_err(WriteDocumentError::Other)?;
94    }
95
96    let info = CommitInfo {
97        action: files_to_commit[0].1.clone(),
98        files: files_to_commit,
99        actor: actor.clone(),
100        summary: format!("{}: {}", summary_prefix, rel.display()),
101        agent_name: actor.agent_name().map(String::from),
102        session_id: session_id.map(String::from),
103    };
104    store.commit(&info).map_err(WriteDocumentError::Other)?;
105
106    let source = source_from_prefix(summary_prefix);
107    apply_trace_hooks(
108        root,
109        &store.git,
110        &store.manifest,
111        actor,
112        session_id,
113        &info.files,
114        source,
115    )
116    .map_err(WriteDocumentError::Other)?;
117
118    Ok(rel)
119}
120
121pub fn apply_trace_hooks(
122    store_root: &Path,
123    git: &crate::git_store::GitStore,
124    manifest: &crate::manifest::Manifest,
125    actor: &Actor,
126    session_id: Option<&str>,
127    changed_files: &[(PathBuf, Action, DocType)],
128    source: &str,
129) -> anyhow::Result<()> {
130    if changed_files.is_empty() {
131        return Ok(());
132    }
133
134    // Pipeline synthesis gate: `from_store_root` fails (ModelUnavailable) when no
135    // reachable backend is configured and the escape hatch is unset, so the
136    // post-write pipeline never emits degraded artifacts — it bails here instead.
137    let trace_insights = Llm::from_store_root(store_root)?;
138
139    if actor.is_agent() {
140        if let (Some(agent_name), Some(sid)) = (actor.agent_name(), session_id) {
141            let entries: Vec<LogSynthEntry> = changed_files
142                .iter()
143                .map(|(path, _, doc_type)| {
144                    let stats = git.diff_stats(path, None, None).unwrap_or_default();
145                    let summary = {
146                        let diff = format!(
147                            "+{} lines\n-{} lines\n",
148                            stats.lines_added, stats.lines_removed
149                        );
150                        match trace_insights.summarize_change(path, doc_type, &diff) {
151                            Ok(s) => s,
152                            Err(e) => {
153                                tracing::warn!(
154                                    "LLM summarize_change failed for {}, using template: {}",
155                                    path.display(),
156                                    e
157                                );
158                                summarize_change_no_llm(path, doc_type, &stats, agent_name)
159                            }
160                        }
161                    };
162                    Ok(LogSynthEntry {
163                        timestamp: Utc::now(),
164                        path: path.clone(),
165                        summary,
166                    })
167                })
168                .collect::<anyhow::Result<Vec<_>>>()?;
169            append_agent_log(store_root, git, agent_name, sid, &entries)?;
170        }
171    }
172
173    for (path, action, doc_type) in changed_files {
174        let stats = git.diff_stats(path, None, None).unwrap_or_default();
175        let event_summary = {
176            let diff = format!(
177                "+{} lines\n-{} lines\n",
178                stats.lines_added, stats.lines_removed
179            );
180            match trace_insights.summarize_change(path, doc_type, &diff) {
181                Ok(s) => s,
182                Err(e) => {
183                    if trace_insights.is_degraded() {
184                        summarize_change_no_llm(
185                            path,
186                            doc_type,
187                            &stats,
188                            actor.agent_name().unwrap_or("system"),
189                        )
190                    } else {
191                        tracing::warn!(
192                            "LLM summarize_change failed for event {}, using template: {}",
193                            path.display(),
194                            e
195                        );
196                        summarize_change_no_llm(
197                            path,
198                            doc_type,
199                            &stats,
200                            actor.agent_name().unwrap_or("system"),
201                        )
202                    }
203                }
204            }
205        };
206        let event = SummaryEvent {
207            timestamp: Utc::now().to_rfc3339(),
208            session_id: session_id.map(String::from),
209            agent_name: actor.agent_name().map(String::from),
210            actor: actor.to_string(),
211            action: action.to_string(),
212            change_kind: action.to_string(),
213            path: path.display().to_string(),
214            doc_type: doc_type.to_string(),
215            summary: event_summary,
216            source: source.to_string(),
217            detected_by: detected_by_from_source(source).to_string(),
218            lines_added: stats.lines_added,
219            lines_removed: stats.lines_removed,
220        };
221        running_summary::append_event(store_root, event)?;
222    }
223
224    if let Err(e) = running_summary::refresh_template(store_root, git, manifest) {
225        tracing::warn!("running summary template refresh failed: {e}");
226    }
227    running_summary::schedule_synthesis_refresh(store_root.to_path_buf());
228
229    sync_agent_trace_md(store_root, git, manifest)?;
230
231    // Refresh context on any tracked file activity — not just curated doc types —
232    // so shell edits to source files (e.g. worker.py) are reflected in context.md.
233    let changed_paths: Vec<PathBuf> = changed_files
234        .iter()
235        .map(|(p, _, _)| p.clone())
236        .filter(|p| crate::git_store::should_track_activity(p))
237        .collect();
238    if !changed_paths.is_empty() {
239        sync_context_md(store_root, git, manifest, &trace_insights, &changed_paths)?;
240    }
241
242    Ok(())
243}
244
245fn sync_agent_trace_md(
246    store_root: &Path,
247    git: &crate::git_store::GitStore,
248    manifest: &crate::manifest::Manifest,
249) -> anyhow::Result<()> {
250    agent_trace_md::sync(store_root, manifest, git)
251}
252
253fn sync_context_md(
254    store_root: &Path,
255    git: &crate::git_store::GitStore,
256    manifest: &crate::manifest::Manifest,
257    trace_insights: &Llm,
258    changed_paths: &[PathBuf],
259) -> anyhow::Result<()> {
260    let (new_content, commit_label) =
261        synthesize_context_content(store_root, manifest, trace_insights, changed_paths)?;
262    let target = store_root.join("context.md");
263    let existing = std::fs::read_to_string(&target).unwrap_or_default();
264    if existing == new_content {
265        return Ok(());
266    }
267
268    crate::trace::context::write_context(store_root, &new_content)?;
269    let info = CommitInfo {
270        action: Action::Modify,
271        files: vec![(
272            PathBuf::from("context.md"),
273            Action::Modify,
274            DocType::Context,
275        )],
276        actor: Actor::System,
277        summary: format!("refresh synthesized context ({commit_label})"),
278        agent_name: None,
279        session_id: None,
280    };
281    git.commit(&info)?;
282    Ok(())
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::config::StoreInfo;
289    use crate::git_store::GitStore;
290    use crate::manifest::Manifest;
291    use tempfile::TempDir;
292
293    fn setup(tmp: &TempDir) -> (PathBuf, Manifest, GitStore) {
294        let root = tmp.path().to_path_buf();
295        std::fs::create_dir_all(root.join(".agent-trace")).unwrap();
296        let git = GitStore::init(&root).unwrap();
297        let info = StoreInfo::new("test".into());
298        let manifest = Manifest::create_empty(info.clone(), &root).unwrap();
299        let store_cfg = crate::config::StoreConfig {
300            store: info,
301            llm: None,
302            synthesis: Some(crate::config::SynthesisConfig::for_unit_tests_degraded()),
303            polling: crate::config::PollingConfig::default(),
304        };
305        store_cfg.save(&root).unwrap();
306        (root, manifest, git)
307    }
308
309    #[test]
310    fn scratch_write_triggers_context_refresh() {
311        let tmp = TempDir::new().unwrap();
312        let (root, mut manifest, git) = setup(&tmp);
313        let scratch_path = PathBuf::from("notes.md");
314        std::fs::write(
315            root.join(&scratch_path),
316            "scratch body: reconnect watermark test",
317        )
318        .unwrap();
319        manifest
320            .register(&scratch_path, DocType::Scratch, "")
321            .unwrap();
322        manifest.save(&root).unwrap();
323
324        let changed = vec![(scratch_path, Action::Modify, DocType::Scratch)];
325        apply_trace_hooks(
326            &root,
327            &git,
328            &manifest,
329            &Actor::User,
330            None,
331            &changed,
332            "cli_write",
333        )
334        .unwrap();
335
336        let ctx = std::fs::read_to_string(root.join("context.md")).expect("context.md created");
337        assert!(ctx.contains("reconnect watermark test"));
338        assert!(ctx.contains("[scratch] notes.md:"));
339    }
340
341    #[test]
342    fn build_trace_documents_includes_unmanifested_changed_paths() {
343        let tmp = TempDir::new().unwrap();
344        let (root, mut manifest, _git) = setup(&tmp);
345
346        // A managed plan document.
347        std::fs::write(root.join("plan.md"), "# Plan\n- [ ] step one\n").unwrap();
348        manifest
349            .register(&PathBuf::from("plan.md"), DocType::Plan, "")
350            .unwrap();
351
352        // An unmanaged source file touched by a shell edit.
353        std::fs::write(root.join("worker.py"), "print('worker activity')\n").unwrap();
354
355        let docs = crate::trace::context::build_trace_documents(
356            &root,
357            &manifest,
358            &[PathBuf::from("worker.py")],
359        );
360
361        assert!(
362            docs.iter().any(|d| d.path == "plan.md"),
363            "should include manifest plan document"
364        );
365        let worker = docs
366            .iter()
367            .find(|d| d.path == "worker.py")
368            .expect("should include unmanifested changed path");
369        assert_eq!(worker.doc_type, DocType::Scratch);
370        assert!(worker.content_snippet.contains("worker activity"));
371
372        // Unmanaged file must NOT be registered in the manifest.
373        assert!(!manifest.is_tracked(&PathBuf::from("worker.py")));
374    }
375
376    #[test]
377    fn build_trace_documents_does_not_duplicate_managed_paths() {
378        let tmp = TempDir::new().unwrap();
379        let (root, mut manifest, _git) = setup(&tmp);
380        std::fs::write(root.join("notes.md"), "scratch note\n").unwrap();
381        manifest
382            .register(&PathBuf::from("notes.md"), DocType::Scratch, "")
383            .unwrap();
384
385        let docs = crate::trace::context::build_trace_documents(
386            &root,
387            &manifest,
388            &[PathBuf::from("notes.md")],
389        );
390        let count = docs.iter().filter(|d| d.path == "notes.md").count();
391        assert_eq!(count, 1, "managed + changed path must not be duplicated");
392    }
393}