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}