Skip to main content

shuttle_rs/
context.rs

1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use crate::core::{Event, EventFilter, EventStore, EventType, Result, ShuttleError};
5use crate::task::{HandoffStatus, HandoffSummary, TaskStatus, TaskSummary};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct Context {
10    pub repo: String,
11    pub branch: String,
12    pub commit: String,
13    pub git_remote: Option<String>,
14    pub dirty: bool,
15    pub dirty_files: Vec<String>,
16    pub open_tasks: Vec<TaskSummary>,
17    pub claimed_tasks: Vec<TaskSummary>,
18    pub recent_decisions: Vec<Event>,
19    pub related_memories: Vec<Event>,
20    pub recent_messages: Vec<Event>,
21    pub pending_handoffs: Vec<HandoffSummary>,
22    pub recent_completed_handoffs: Vec<HandoffSummary>,
23    pub inbox: Vec<Event>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct RepoStatus {
28    pub repo_path: String,
29    pub git_remote: Option<String>,
30    pub branch: String,
31    pub commit: String,
32    pub dirty: bool,
33    pub dirty_files: Vec<String>,
34}
35
36pub fn repo_status(path: impl AsRef<Path>) -> Result<RepoStatus> {
37    let path = path.as_ref();
38    let repo_path = git(path, ["rev-parse", "--show-toplevel"])?;
39    let repo_path_buf = PathBuf::from(repo_path.trim());
40    let branch = git(&repo_path_buf, ["rev-parse", "--abbrev-ref", "HEAD"])?;
41    let commit = git(&repo_path_buf, ["rev-parse", "HEAD"])?;
42    let remote = git(&repo_path_buf, ["config", "--get", "remote.origin.url"]).ok();
43    let status = git(&repo_path_buf, ["status", "--porcelain"])?;
44    let dirty_files = parse_dirty_files(&status);
45
46    Ok(RepoStatus {
47        repo_path: repo_path.trim().to_owned(),
48        git_remote: remote
49            .map(|value| value.trim().to_owned())
50            .filter(|value| !value.is_empty()),
51        branch: branch.trim().to_owned(),
52        commit: commit.trim().to_owned(),
53        dirty: !dirty_files.is_empty(),
54        dirty_files,
55    })
56}
57
58pub async fn assemble_context(
59    store: &impl EventStore,
60    cwd: impl AsRef<Path>,
61    workspace_id: &str,
62    agent: &str,
63) -> Result<Context> {
64    let status = repo_status(cwd)?;
65    let task_summaries = crate::task::tasks(store, Some(workspace_id), None).await?;
66    let open_tasks = task_summaries
67        .iter()
68        .filter(|task| task.status == TaskStatus::Open)
69        .take(20)
70        .cloned()
71        .collect();
72    let claimed_tasks = task_summaries
73        .iter()
74        .filter(|task| task.status == TaskStatus::Claimed)
75        .take(20)
76        .cloned()
77        .collect();
78    let recent_decisions = store
79        .list(EventFilter {
80            event_type: Some(EventType::Decision),
81            workspace_id: Some(workspace_id.to_owned()),
82            limit: Some(20),
83            ..EventFilter::default()
84        })
85        .await?;
86    let related_memories = store
87        .list(EventFilter {
88            event_type: Some(EventType::Memory),
89            workspace_id: Some(workspace_id.to_owned()),
90            limit: Some(20),
91            ..EventFilter::default()
92        })
93        .await?;
94    let recent_messages = store
95        .list(EventFilter {
96            event_type: Some(EventType::Message),
97            workspace_id: Some(workspace_id.to_owned()),
98            limit: Some(20),
99            ..EventFilter::default()
100        })
101        .await?;
102    let handoff_summaries = crate::task::handoffs(store, Some(workspace_id), None).await?;
103    let pending_handoffs = handoff_summaries
104        .iter()
105        .filter(|handoff| handoff.status == HandoffStatus::Pending)
106        .take(20)
107        .cloned()
108        .collect();
109    let recent_completed_handoffs = handoff_summaries
110        .iter()
111        .filter(|handoff| handoff.status == HandoffStatus::Completed)
112        .take(20)
113        .cloned()
114        .collect();
115    let inbox = inbox_events(store, workspace_id, agent).await?;
116
117    Ok(Context {
118        repo: status.repo_path,
119        branch: status.branch,
120        commit: status.commit,
121        git_remote: status.git_remote,
122        dirty: status.dirty,
123        dirty_files: status.dirty_files,
124        open_tasks,
125        claimed_tasks,
126        recent_decisions,
127        related_memories,
128        recent_messages,
129        pending_handoffs,
130        recent_completed_handoffs,
131        inbox,
132    })
133}
134
135pub fn repo_id(status: &RepoStatus) -> String {
136    status
137        .git_remote
138        .clone()
139        .unwrap_or_else(|| status.repo_path.clone())
140}
141
142fn parse_dirty_files(status: &str) -> Vec<String> {
143    status
144        .lines()
145        .filter_map(|line| {
146            let path = line.get(3..)?.trim();
147            if path.is_empty() {
148                None
149            } else if let Some((_, destination)) = path.split_once(" -> ") {
150                Some(destination.to_owned())
151            } else {
152                Some(path.to_owned())
153            }
154        })
155        .collect()
156}
157
158async fn inbox_events(
159    store: &impl EventStore,
160    workspace_id: &str,
161    agent: &str,
162) -> Result<Vec<Event>> {
163    let mut events = store
164        .list(EventFilter {
165            event_type: Some(EventType::Message),
166            workspace_id: Some(workspace_id.to_owned()),
167            recipient: Some(agent.to_owned()),
168            limit: Some(20),
169            ..EventFilter::default()
170        })
171        .await?;
172    events.extend(
173        store
174            .list(EventFilter {
175                event_type: Some(EventType::Handoff),
176                workspace_id: Some(workspace_id.to_owned()),
177                recipient: Some(agent.to_owned()),
178                limit: Some(20),
179                ..EventFilter::default()
180            })
181            .await?,
182    );
183    events.sort_by(|left, right| right.created_at.cmp(&left.created_at));
184    events.truncate(20);
185    Ok(events)
186}
187
188fn git<const N: usize>(cwd: &Path, args: [&str; N]) -> Result<String> {
189    let output = Command::new("git")
190        .args(args)
191        .current_dir(cwd)
192        .output()
193        .map_err(|err| ShuttleError::Store(format!("failed to run git: {err}")))?;
194
195    if !output.status.success() {
196        let stderr = String::from_utf8_lossy(&output.stderr);
197        return Err(ShuttleError::Store(format!(
198            "git command failed: {}",
199            stderr.trim()
200        )));
201    }
202
203    Ok(String::from_utf8_lossy(&output.stdout).to_string())
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::core::EventStore;
210    use crate::store::SqliteEventStore;
211    use std::fs;
212
213    #[test]
214    fn repo_status_reports_dirty_files() {
215        let dir = tempfile::tempdir().unwrap();
216        Command::new("git")
217            .arg("init")
218            .current_dir(dir.path())
219            .output()
220            .unwrap();
221        fs::write(dir.path().join("README.md"), "repo").unwrap();
222        Command::new("git")
223            .args(["add", "README.md"])
224            .current_dir(dir.path())
225            .output()
226            .unwrap();
227        Command::new("git")
228            .args([
229                "-c",
230                "user.name=Shuttle Test",
231                "-c",
232                "user.email=shuttle@example.test",
233                "commit",
234                "-m",
235                "initial",
236            ])
237            .current_dir(dir.path())
238            .output()
239            .unwrap();
240        fs::write(dir.path().join("note.txt"), "dirty").unwrap();
241
242        let status = repo_status(dir.path()).unwrap();
243
244        assert!(status.dirty);
245        assert_eq!(status.dirty_files, vec!["note.txt"]);
246    }
247
248    #[test]
249    fn repo_id_prefers_remote_over_path() {
250        let status = RepoStatus {
251            repo_path: "/tmp/repo".into(),
252            git_remote: Some("https://example.test/repo.git".into()),
253            branch: "main".into(),
254            commit: "abc".into(),
255            dirty: false,
256            dirty_files: Vec::new(),
257        };
258
259        assert_eq!(repo_id(&status), "https://example.test/repo.git");
260    }
261
262    #[test]
263    fn dirty_file_parser_normalizes_rename_destinations() {
264        let files = parse_dirty_files("R  old-name.txt -> new-name.txt\nC  old.rs -> copy.rs\n");
265
266        assert_eq!(files, vec!["new-name.txt", "copy.rs"]);
267    }
268
269    #[test]
270    fn context_excludes_claimed_tasks_from_open_tasks() {
271        let repo = tempfile::tempdir().unwrap();
272        let data = tempfile::tempdir().unwrap();
273        init_git_repo(repo.path());
274        let store = SqliteEventStore::open(data.path().join("shuttle.db")).unwrap();
275        let task = crate::task::new_task(
276            "workspace".into(),
277            "codex".into(),
278            "session".into(),
279            "ship mvp".into(),
280        );
281        let claim = crate::task::new_claim(
282            "workspace".into(),
283            "claude".into(),
284            "session".into(),
285            task.id,
286        );
287        futures_executor::block_on(store.append(task)).unwrap();
288        futures_executor::block_on(store.append(claim)).unwrap();
289
290        let context =
291            futures_executor::block_on(assemble_context(&store, repo.path(), "workspace", "codex"))
292                .unwrap();
293
294        assert!(context.open_tasks.is_empty());
295    }
296
297    fn init_git_repo(path: &Path) {
298        Command::new("git")
299            .arg("init")
300            .current_dir(path)
301            .output()
302            .unwrap();
303        fs::write(path.join("README.md"), "repo").unwrap();
304        Command::new("git")
305            .args(["add", "README.md"])
306            .current_dir(path)
307            .output()
308            .unwrap();
309        Command::new("git")
310            .args([
311                "-c",
312                "user.name=Shuttle Test",
313                "-c",
314                "user.email=shuttle@example.test",
315                "commit",
316                "-m",
317                "initial",
318            ])
319            .current_dir(path)
320            .output()
321            .unwrap();
322    }
323}