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)]
158 pub fn rehydrate(
159 workspace_id: WorkspaceId,
160 session_id: SessionId,
161 repo_id: RepoId,
162 agent_id: AgentId,
163 changeset_id: Uuid,
164 intent: String,
165 base_commit: String,
166 mode: WorkspaceMode,
167 agent_name: String,
168 db: PgPool,
169 ) -> Self {
170 let now = Instant::now();
171 let overlay = FileOverlay::new(workspace_id, db);
172 let graph = SessionGraph::empty();
173
174 Self {
175 id: workspace_id,
176 session_id,
177 repo_id,
178 agent_id,
179 agent_name,
180 changeset_id,
181 intent,
182 base_commit,
183 overlay,
184 graph,
185 mode,
186 state: WorkspaceState::Active,
187 created_at: now,
188 last_active: now,
189 files_read: Arc::new(DashMap::new()),
190 }
191 }
192
193 #[allow(clippy::too_many_arguments)]
195 pub async fn new(
196 session_id: SessionId,
197 repo_id: RepoId,
198 agent_id: AgentId,
199 changeset_id: Uuid,
200 intent: String,
201 base_commit: String,
202 mode: WorkspaceMode,
203 agent_name: String,
204 db: PgPool,
205 ) -> Result<Self> {
206 let id = Uuid::new_v4();
207 let now = Instant::now();
208
209 sqlx::query(
211 r#"
212 INSERT INTO session_workspaces
213 (id, session_id, repo_id, base_commit_hash, state, mode, agent_id, intent, agent_name, changeset_id)
214 VALUES ($1, $2, $3, $4, 'active', $5, $6, $7, $8, $9)
215 "#,
216 )
217 .bind(id)
218 .bind(session_id)
219 .bind(repo_id)
220 .bind(&base_commit)
221 .bind(mode.as_str())
222 .bind(&agent_id)
223 .bind(&intent)
224 .bind(&agent_name)
225 .bind(changeset_id)
226 .execute(&db)
227 .await?;
228
229 let overlay = FileOverlay::new(id, db);
230 let graph = SessionGraph::empty();
231
232 Ok(Self {
233 id,
234 session_id,
235 repo_id,
236 agent_id,
237 agent_name,
238 changeset_id,
239 intent,
240 base_commit,
241 overlay,
242 graph,
243 mode,
244 state: WorkspaceState::Active,
245 created_at: now,
246 last_active: now,
247 files_read: Arc::new(DashMap::new()),
248 })
249 }
250
251 pub fn read_file(&self, path: &str, git_repo: &GitRepository) -> Result<FileReadResult> {
257 if let Some(entry) = self.overlay.get(path) {
258 return match entry.value() {
259 OverlayEntry::Modified { content, hash } | OverlayEntry::Added { content, hash } => {
260 Ok(FileReadResult {
261 content: content.clone(),
262 hash: hash.clone(),
263 modified_in_session: true,
264 })
265 }
266 OverlayEntry::Deleted => Err(dk_core::Error::Git(format!(
267 "file '{path}' has been deleted in this session"
268 ))),
269 };
270 }
271
272 let content = git_repo.read_tree_entry(&self.base_commit, path)?;
277 let hash = format!("{:x}", Sha256::digest(&content));
278
279 Ok(FileReadResult {
280 content,
281 hash,
282 modified_in_session: false,
283 })
284 }
285
286 pub async fn write_file(
290 &self,
291 path: &str,
292 content: Vec<u8>,
293 git_repo: &GitRepository,
294 ) -> Result<String> {
295 let is_new = git_repo.read_tree_entry(&self.base_commit, path).is_err();
296 self.overlay.write(path, content, is_new).await
297 }
298
299 pub async fn delete_file(&self, path: &str) -> Result<()> {
301 self.overlay.delete(path).await
302 }
303
304 pub fn list_files(
314 &self,
315 git_repo: &GitRepository,
316 only_modified: bool,
317 prefix: Option<&str>,
318 ) -> Result<Vec<String>> {
319 let matches_prefix = |p: &str| -> bool {
320 match prefix {
321 Some(pfx) => p.starts_with(pfx),
322 None => true,
323 }
324 };
325
326 if only_modified {
327 return Ok(self
328 .overlay
329 .list_changes()
330 .into_iter()
331 .filter(|(path, _)| matches_prefix(path))
332 .map(|(path, _)| path)
333 .collect());
334 }
335
336 let base_files = git_repo.list_tree_files(&self.base_commit)?;
339 let mut result: HashSet<String> = base_files
340 .into_iter()
341 .filter(|p| matches_prefix(p))
342 .collect();
343
344 for (path, entry) in self.overlay.list_changes() {
346 if !matches_prefix(&path) {
347 continue;
348 }
349 match entry {
350 OverlayEntry::Added { .. } | OverlayEntry::Modified { .. } => {
351 result.insert(path);
352 }
353 OverlayEntry::Deleted => {
354 result.remove(&path);
355 }
356 }
357 }
358
359 let mut files: Vec<String> = result.into_iter().collect();
360 files.sort();
361 Ok(files)
362 }
363
364 pub fn touch(&mut self) {
366 self.last_active = Instant::now();
367 }
368
369 pub fn mark_read(&self, path: &str) {
375 self.files_read.insert(path.to_string(), Utc::now());
376 }
377
378 pub fn last_read(&self, path: &str) -> Option<DateTime<Utc>> {
381 self.files_read.get(path).map(|e| *e.value())
382 }
383
384 pub async fn reindex_from_overlay(&mut self) -> dk_core::Result<()> {
400 use crate::parser::ParserRegistry;
401 use crate::workspace::overlay::OverlayEntry;
402 use std::path::Path;
403
404 let registry = ParserRegistry::new();
405 let changes = self.overlay.list_changes();
406
407 for (path_str, entry) in changes {
408 let file_path = Path::new(&path_str);
409 match entry {
410 OverlayEntry::Deleted => {
411 self.graph.remove_session_symbols_for_file(&path_str);
413 }
414 OverlayEntry::Added { content, .. } | OverlayEntry::Modified { content, .. } => {
415 if !registry.supports_file(file_path) {
416 continue;
417 }
418 let text = std::str::from_utf8(&content).map_err(|e| {
419 dk_core::Error::Internal(format!(
420 "reindex_from_overlay: non-utf8 in {path_str}: {e}"
421 ))
422 })?;
423 let analysis = match registry.parse_file(file_path, text.as_bytes()) {
424 Ok(a) => a,
425 Err(e) => {
426 tracing::warn!(
427 path = %path_str,
428 "reindex_from_overlay: parse failed, skipping: {e}"
429 );
430 continue;
431 }
432 };
433 self.graph
437 .update_from_parse(&path_str, analysis.symbols, &[]);
438 }
439 }
440 }
441
442 Ok(())
443 }
444
445 pub fn overlay_for_tree(&self) -> Vec<(String, Option<Vec<u8>>)> {
450 self.overlay
451 .list_changes()
452 .into_iter()
453 .map(|(path, entry)| {
454 let data = match entry {
455 OverlayEntry::Modified { content, .. }
456 | OverlayEntry::Added { content, .. } => Some(content),
457 OverlayEntry::Deleted => None,
458 };
459 (path, data)
460 })
461 .collect()
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468 use uuid::Uuid;
469
470 fn make_test_workspace() -> SessionWorkspace {
471 SessionWorkspace::new_test(
472 Uuid::new_v4(),
473 Uuid::new_v4(),
474 "test-agent".to_string(),
475 "test intent".to_string(),
476 "abc123".to_string(),
477 WorkspaceMode::Ephemeral,
478 )
479 }
480
481 #[tokio::test]
482 async fn reindex_from_overlay_adds_symbols_for_rust_file() {
483 let mut ws = make_test_workspace();
484 ws.overlay
486 .write_local("x.rs", b"pub fn hello() {}".to_vec(), true);
487
488 ws.reindex_from_overlay().await.unwrap();
489
490 let symbols = ws.graph.changed_symbols_for_file("x.rs");
492 assert!(
493 symbols.iter().any(|s| s == "hello"),
494 "expected 'hello' in graph symbols, got: {symbols:?}"
495 );
496 }
497
498 #[tokio::test]
499 async fn reindex_from_overlay_skips_unsupported_extensions() {
500 let mut ws = make_test_workspace();
501 ws.overlay
502 .write_local("readme.txt", b"just text".to_vec(), true);
503
504 ws.reindex_from_overlay().await.unwrap();
506 assert_eq!(ws.graph.change_count(), 0);
507 }
508
509 #[tokio::test]
510 async fn reindex_from_overlay_deleted_entry_clears_symbols() {
511 use dk_core::{Span, Symbol, SymbolKind, Visibility};
512 use std::path::PathBuf;
513
514 let mut ws = make_test_workspace();
515 ws.graph.add_symbol(Symbol {
517 id: Uuid::new_v4(),
518 name: "old_fn".to_string(),
519 qualified_name: "old_fn".to_string(),
520 kind: SymbolKind::Function,
521 visibility: Visibility::Public,
522 file_path: PathBuf::from("gone.rs"),
523 span: Span { start_byte: 0, end_byte: 10 },
524 signature: None,
525 doc_comment: None,
526 parent: None,
527 last_modified_by: None,
528 last_modified_intent: None,
529 });
530 assert_eq!(ws.graph.change_count(), 1);
531
532 ws.overlay.delete_local("gone.rs");
534
535 ws.reindex_from_overlay().await.unwrap();
536
537 let symbols = ws.graph.changed_symbols_for_file("gone.rs");
539 assert!(symbols.is_empty(), "deleted file should have no graph symbols");
540 }
541}