Skip to main content

sac/store/
mod.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use anyhow::{anyhow, Context, Result};
5use rusqlite::{params, Connection, OptionalExtension, Transaction};
6
7mod render;
8mod schema;
9mod threads;
10mod time;
11mod worksets;
12
13pub use render::*;
14pub use schema::{default_store_path, initialize};
15pub use threads::*;
16pub use worksets::*;
17
18pub(crate) use schema::open_connection;
19use time::now_utc;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct EpisodeRecord {
23    pub id: i64,
24    pub thread_name: String,
25    pub session_id: String,
26    pub action: String,
27    pub content: String,
28    pub created_at: String,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ThreadRecord {
33    pub name: String,
34    pub session_id: String,
35    pub created_at: String,
36    pub updated_at: String,
37    pub episode_count: i64,
38    pub latest_action: Option<String>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct WorkerContext {
43    pub self_episodes: Vec<EpisodeRecord>,
44    pub source_episodes: Vec<EpisodeRecord>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct WorksetItemRecord {
49    pub position: i64,
50    pub title: String,
51    pub scope: String,
52    pub description: String,
53    pub role: String,
54    pub status: String,
55    pub depends_on: Vec<String>,
56    pub acceptance: String,
57    pub notes: Option<String>,
58    pub updated_at: String,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct WorksetRecord {
63    pub id: String,
64    pub session_id: String,
65    pub goal: String,
66    pub status: String,
67    pub summary: String,
68    pub verification_recipe: Option<String>,
69    pub created_at: String,
70    pub updated_at: String,
71    pub items: Vec<WorksetItemRecord>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct WorksetSummary {
76    pub id: String,
77    pub status: String,
78    pub summary: String,
79    pub item_count: i64,
80    pub updated_at: String,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct WorksetItemDefinition {
85    pub title: String,
86    pub scope: String,
87    pub description: String,
88    pub role: String,
89    pub depends_on: Vec<String>,
90    pub acceptance: String,
91    pub notes: Option<String>,
92    pub status: Option<String>,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct WorksetDefinition {
97    pub id: String,
98    pub goal: String,
99    pub status: String,
100    pub summary: String,
101    pub verification_recipe: Option<String>,
102    pub items: Vec<WorksetItemDefinition>,
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    fn temp_store_path(label: &str) -> PathBuf {
110        let unique = std::time::SystemTime::now()
111            .duration_since(std::time::UNIX_EPOCH)
112            .expect("time went backwards")
113            .as_nanos();
114        std::env::temp_dir()
115            .join(format!("sac_store_test_{}_{}", label, unique))
116            .join("store.db")
117    }
118
119    #[test]
120    fn append_list_and_read_thread_data() {
121        let store_path = temp_store_path("append");
122        initialize(&store_path).unwrap();
123
124        let session_id = "session-a";
125        append_episode(
126            &store_path,
127            session_id,
128            "auth",
129            "inspect",
130            "first auth episode",
131        )
132        .unwrap();
133        append_episode(
134            &store_path,
135            session_id,
136            "auth",
137            "refactor",
138            "second auth episode",
139        )
140        .unwrap();
141        append_episode(&store_path, session_id, "tests", "inspect", "test episode").unwrap();
142
143        let threads = list_threads(&store_path, session_id).unwrap();
144        assert_eq!(threads.len(), 2);
145        assert!(threads
146            .iter()
147            .any(|thread| thread.name == "auth" && thread.episode_count == 2));
148
149        let auth_episodes = thread_read(&store_path, session_id, "auth").unwrap();
150        assert_eq!(auth_episodes.len(), 2);
151        assert_eq!(auth_episodes[0].action, "inspect");
152        assert_eq!(auth_episodes[1].action, "refactor");
153
154        let rendered = render_thread_document("auth", &auth_episodes);
155        assert!(rendered.contains("first auth episode"));
156        assert!(rendered.contains("second auth episode"));
157
158        let _ = std::fs::remove_dir_all(store_path.parent().unwrap());
159    }
160
161    #[test]
162    fn worker_context_uses_latest_source_episode() {
163        let store_path = temp_store_path("context");
164        initialize(&store_path).unwrap();
165
166        let session_id = "session-b";
167        append_episode(&store_path, session_id, "auth", "inspect", "self history").unwrap();
168        append_episode(&store_path, session_id, "tests", "scan", "old source").unwrap();
169        append_episode(&store_path, session_id, "tests", "scan", "new source").unwrap();
170
171        let context =
172            load_worker_context(&store_path, session_id, "auth", &["tests".to_string()]).unwrap();
173
174        assert_eq!(context.self_episodes.len(), 1);
175        assert_eq!(context.source_episodes.len(), 1);
176        assert_eq!(context.source_episodes[0].content, "new source");
177
178        let _ = std::fs::remove_dir_all(store_path.parent().unwrap());
179    }
180
181    #[test]
182    fn delete_thread_removes_all_episodes() {
183        let store_path = temp_store_path("delete");
184        initialize(&store_path).unwrap();
185
186        let session_id = "session-c";
187        append_episode(&store_path, session_id, "impl", "step-1", "first episode").unwrap();
188        append_episode(&store_path, session_id, "impl", "step-2", "second episode").unwrap();
189
190        let deleted = delete_thread(&store_path, session_id, "impl").unwrap();
191        assert!(deleted);
192        assert!(thread_read(&store_path, session_id, "impl")
193            .unwrap()
194            .is_empty());
195
196        let _ = std::fs::remove_dir_all(store_path.parent().unwrap());
197    }
198
199    #[test]
200    fn define_read_and_list_worksets() {
201        let store_path = temp_store_path("worksets");
202        initialize(&store_path).unwrap();
203
204        let session_id = "session-workset";
205        let definition = WorksetDefinition {
206            id: "auth-refresh".to_string(),
207            goal: "refresh auth flow".to_string(),
208            status: "planned".to_string(),
209            summary: "Split auth refresh into scoped units.".to_string(),
210            verification_recipe: Some("cargo test -p sac".to_string()),
211            items: vec![
212                WorksetItemDefinition {
213                    title: "Inspect auth state handling".to_string(),
214                    scope: "crates/sac/src/agent.rs".to_string(),
215                    description: "Map auth state behavior and risks.".to_string(),
216                    role: "research".to_string(),
217                    depends_on: Vec::new(),
218                    acceptance: "Auth state behavior and risks are mapped.".to_string(),
219                    notes: None,
220                    status: None,
221                },
222                WorksetItemDefinition {
223                    title: "Implement auth state update".to_string(),
224                    scope: "crates/sac/src/tui.rs".to_string(),
225                    description: "Apply the focused code change.".to_string(),
226                    role: "implement".to_string(),
227                    depends_on: vec!["Inspect auth state handling".to_string()],
228                    acceptance: "Focused code change is applied.".to_string(),
229                    notes: Some("waiting on research".to_string()),
230                    status: None,
231                },
232            ],
233        };
234
235        define_workset(&store_path, session_id, &definition).unwrap();
236
237        let workset = read_workset(&store_path, session_id, "auth-refresh")
238            .unwrap()
239            .expect("expected workset");
240        assert_eq!(workset.goal, "refresh auth flow");
241        assert_eq!(workset.items.len(), 2);
242        assert_eq!(
243            workset.items[1].depends_on,
244            vec!["Inspect auth state handling"]
245        );
246        assert_eq!(
247            workset.items[1].acceptance,
248            "Focused code change is applied."
249        );
250
251        let listed = list_worksets(&store_path, session_id).unwrap();
252        assert_eq!(listed.len(), 1);
253        assert_eq!(listed[0].id, "auth-refresh");
254
255        let rendered = render_workset_document(&workset);
256        assert!(rendered.contains("Inspect auth state handling"));
257        assert!(rendered.contains("verification: cargo test -p sac"));
258        assert!(render_workset_list(&listed).contains("auth-refresh"));
259
260        let _ = std::fs::remove_dir_all(store_path.parent().unwrap());
261    }
262}