1use chrono::{DateTime, Utc};
8use dashmap::DashMap;
9use dk_core::{AgentId, RepoId, Result};
10use sha2::{Digest, Sha256};
11use sqlx::PgPool;
12use std::collections::HashSet;
13use std::sync::Arc;
14use tokio::time::Instant;
15use uuid::Uuid;
16
17use crate::git::GitRepository;
18use crate::workspace::overlay::{FileOverlay, OverlayEntry};
19use crate::workspace::session_graph::SessionGraph;
20
21pub type WorkspaceId = Uuid;
24pub type SessionId = Uuid;
25
26#[derive(Debug, Clone)]
30pub enum WorkspaceMode {
31 Ephemeral,
33 Persistent { expires_at: Option<Instant> },
35}
36
37impl WorkspaceMode {
38 pub fn as_str(&self) -> &'static str {
40 match self {
41 Self::Ephemeral => "ephemeral",
42 Self::Persistent { .. } => "persistent",
43 }
44 }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum WorkspaceState {
52 Active,
53 Submitted,
54 Merged,
55 Expired,
56 Abandoned,
57}
58
59impl WorkspaceState {
60 pub fn as_str(&self) -> &'static str {
61 match self {
62 Self::Active => "active",
63 Self::Submitted => "submitted",
64 Self::Merged => "merged",
65 Self::Expired => "expired",
66 Self::Abandoned => "abandoned",
67 }
68 }
69}
70
71#[derive(Debug, Clone)]
75pub struct FileReadResult {
76 pub content: Vec<u8>,
77 pub hash: String,
78 pub modified_in_session: bool,
79}
80
81pub struct SessionWorkspace {
88 pub id: WorkspaceId,
89 pub session_id: SessionId,
90 pub repo_id: RepoId,
91 pub agent_id: AgentId,
92 pub agent_name: String,
93 pub changeset_id: uuid::Uuid,
94 pub intent: String,
95 pub base_commit: String,
96 pub overlay: FileOverlay,
97 pub graph: SessionGraph,
98 pub mode: WorkspaceMode,
99 pub state: WorkspaceState,
100 pub created_at: Instant,
101 pub last_active: Instant,
102 pub files_read: Arc<DashMap<String, DateTime<Utc>>>,
109}
110
111impl SessionWorkspace {
112 #[doc(hidden)]
118 pub fn new_test(
119 session_id: SessionId,
120 repo_id: RepoId,
121 agent_id: AgentId,
122 intent: String,
123 base_commit: String,
124 mode: WorkspaceMode,
125 ) -> Self {
126 let id = Uuid::new_v4();
127 let now = Instant::now();
128 let overlay = FileOverlay::new_inmemory(id);
129 let graph = SessionGraph::empty();
130
131 Self {
132 id,
133 session_id,
134 repo_id,
135 agent_id,
136 agent_name: String::new(),
137 changeset_id: Uuid::new_v4(),
138 intent,
139 base_commit,
140 overlay,
141 graph,
142 mode,
143 state: WorkspaceState::Active,
144 created_at: now,
145 last_active: now,
146 files_read: Arc::new(DashMap::new()),
147 }
148 }
149
150 #[allow(clippy::too_many_arguments)]
152 pub async fn new(
153 session_id: SessionId,
154 repo_id: RepoId,
155 agent_id: AgentId,
156 changeset_id: Uuid,
157 intent: String,
158 base_commit: String,
159 mode: WorkspaceMode,
160 agent_name: String,
161 db: PgPool,
162 ) -> Result<Self> {
163 let id = Uuid::new_v4();
164 let now = Instant::now();
165
166 sqlx::query(
168 r#"
169 INSERT INTO session_workspaces
170 (id, session_id, repo_id, base_commit_hash, state, mode, agent_id, intent, agent_name)
171 VALUES ($1, $2, $3, $4, 'active', $5, $6, $7, $8)
172 "#,
173 )
174 .bind(id)
175 .bind(session_id)
176 .bind(repo_id)
177 .bind(&base_commit)
178 .bind(mode.as_str())
179 .bind(&agent_id)
180 .bind(&intent)
181 .bind(&agent_name)
182 .execute(&db)
183 .await?;
184
185 let overlay = FileOverlay::new(id, db);
186 let graph = SessionGraph::empty();
187
188 Ok(Self {
189 id,
190 session_id,
191 repo_id,
192 agent_id,
193 agent_name,
194 changeset_id,
195 intent,
196 base_commit,
197 overlay,
198 graph,
199 mode,
200 state: WorkspaceState::Active,
201 created_at: now,
202 last_active: now,
203 files_read: Arc::new(DashMap::new()),
204 })
205 }
206
207 pub fn read_file(&self, path: &str, git_repo: &GitRepository) -> Result<FileReadResult> {
213 if let Some(entry) = self.overlay.get(path) {
214 return match entry.value() {
215 OverlayEntry::Modified { content, hash } | OverlayEntry::Added { content, hash } => {
216 Ok(FileReadResult {
217 content: content.clone(),
218 hash: hash.clone(),
219 modified_in_session: true,
220 })
221 }
222 OverlayEntry::Deleted => Err(dk_core::Error::Git(format!(
223 "file '{path}' has been deleted in this session"
224 ))),
225 };
226 }
227
228 let content = git_repo.read_tree_entry(&self.base_commit, path)?;
233 let hash = format!("{:x}", Sha256::digest(&content));
234
235 Ok(FileReadResult {
236 content,
237 hash,
238 modified_in_session: false,
239 })
240 }
241
242 pub async fn write_file(
246 &self,
247 path: &str,
248 content: Vec<u8>,
249 git_repo: &GitRepository,
250 ) -> Result<String> {
251 let is_new = git_repo.read_tree_entry(&self.base_commit, path).is_err();
252 self.overlay.write(path, content, is_new).await
253 }
254
255 pub async fn delete_file(&self, path: &str) -> Result<()> {
257 self.overlay.delete(path).await
258 }
259
260 pub fn list_files(
270 &self,
271 git_repo: &GitRepository,
272 only_modified: bool,
273 prefix: Option<&str>,
274 ) -> Result<Vec<String>> {
275 let matches_prefix = |p: &str| -> bool {
276 match prefix {
277 Some(pfx) => p.starts_with(pfx),
278 None => true,
279 }
280 };
281
282 if only_modified {
283 return Ok(self
284 .overlay
285 .list_changes()
286 .into_iter()
287 .filter(|(path, _)| matches_prefix(path))
288 .map(|(path, _)| path)
289 .collect());
290 }
291
292 let base_files = git_repo.list_tree_files(&self.base_commit)?;
295 let mut result: HashSet<String> = base_files
296 .into_iter()
297 .filter(|p| matches_prefix(p))
298 .collect();
299
300 for (path, entry) in self.overlay.list_changes() {
302 if !matches_prefix(&path) {
303 continue;
304 }
305 match entry {
306 OverlayEntry::Added { .. } | OverlayEntry::Modified { .. } => {
307 result.insert(path);
308 }
309 OverlayEntry::Deleted => {
310 result.remove(&path);
311 }
312 }
313 }
314
315 let mut files: Vec<String> = result.into_iter().collect();
316 files.sort();
317 Ok(files)
318 }
319
320 pub fn touch(&mut self) {
322 self.last_active = Instant::now();
323 }
324
325 pub fn mark_read(&self, path: &str) {
331 self.files_read.insert(path.to_string(), Utc::now());
332 }
333
334 pub fn last_read(&self, path: &str) -> Option<DateTime<Utc>> {
337 self.files_read.get(path).map(|e| *e.value())
338 }
339
340 pub fn overlay_for_tree(&self) -> Vec<(String, Option<Vec<u8>>)> {
345 self.overlay
346 .list_changes()
347 .into_iter()
348 .map(|(path, entry)| {
349 let data = match entry {
350 OverlayEntry::Modified { content, .. }
351 | OverlayEntry::Added { content, .. } => Some(content),
352 OverlayEntry::Deleted => None,
353 };
354 (path, data)
355 })
356 .collect()
357 }
358}