Skip to main content

dk_engine/workspace/
session_workspace.rs

1//! SessionWorkspace — the isolated workspace for a single agent session.
2//!
3//! Each workspace owns a [`FileOverlay`] and a [`SessionGraph`], pinned to a
4//! `base_commit` in the repository. Reads go through the overlay first, then
5//! fall back to the Git tree at the base commit.
6
7use 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
21// ── Type aliases ─────────────────────────────────────────────────────
22
23pub type WorkspaceId = Uuid;
24pub type SessionId = Uuid;
25
26// ── Workspace mode ───────────────────────────────────────────────────
27
28/// Controls the lifetime semantics of a workspace.
29#[derive(Debug, Clone)]
30pub enum WorkspaceMode {
31    /// Destroyed when the session disconnects.
32    Ephemeral,
33    /// Survives disconnection; optionally expires at a deadline.
34    Persistent { expires_at: Option<Instant> },
35}
36
37impl WorkspaceMode {
38    /// SQL label for the DB column.
39    pub fn as_str(&self) -> &'static str {
40        match self {
41            Self::Ephemeral => "ephemeral",
42            Self::Persistent { .. } => "persistent",
43        }
44    }
45}
46
47// ── Workspace state machine ──────────────────────────────────────────
48
49/// Lifecycle state of a workspace.
50#[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// ── File read result ─────────────────────────────────────────────────
72
73/// Result of reading a file through the workspace layer.
74#[derive(Debug, Clone)]
75pub struct FileReadResult {
76    pub content: Vec<u8>,
77    pub hash: String,
78    pub modified_in_session: bool,
79}
80
81// ── SessionWorkspace ─────────────────────────────────────────────────
82
83/// An isolated workspace for a single agent session.
84///
85/// Reads resolve overlay-first, then fall through to the Git tree at
86/// `base_commit`. Writes go exclusively to the overlay.
87pub 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    /// Per-path wall-clock timestamp of the most recent `dk_file_read` in
103    /// this session. Consumed by the STALE_OVERLAY pre-write check so a
104    /// session whose local view predates a competing submitted changeset is
105    /// forced to re-read before writing. Interior-mutable so it can be
106    /// updated through `&SessionWorkspace` (mirrors how `overlay` manages
107    /// its own locking).
108    pub files_read: Arc<DashMap<String, DateTime<Utc>>>,
109}
110
111impl SessionWorkspace {
112    /// Create a workspace without any database interaction (test-only).
113    ///
114    /// Uses [`FileOverlay::new_inmemory`] so writes go only to the
115    /// in-memory DashMap. Suitable for unit / integration tests that
116    /// verify isolation semantics without requiring PostgreSQL.
117    #[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    /// Rehydrate a workspace from existing database state without inserting a new row.
151    ///
152    /// Used by [`WorkspaceManager::resume`] to reconstruct an in-memory
153    /// `SessionWorkspace` after the DB row has already been updated (session_id
154    /// rotated, stranded_at cleared). Unlike [`SessionWorkspace::new`], this
155    /// constructor does **not** insert a new `session_workspaces` row — it only
156    /// wires up the in-memory structures pointing at the existing `workspace_id`.
157    #[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    /// Create a new workspace and persist metadata to the database.
194    #[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        // Persist to DB
210        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    /// Read a file through the overlay-first layer.
252    ///
253    /// 1. If the overlay has a `Modified` or `Added` entry, return that content.
254    /// 2. If the overlay has a `Deleted` entry, return a "not found" error.
255    /// 3. Otherwise, read from the Git tree at `base_commit`.
256    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        // Fall through to base tree.
273        // TODO(perf): The git tree entry already stores a content-addressable
274        // OID (blob hash). If GitRepository exposed the entry OID we could use
275        // it directly instead of recomputing SHA-256 on every base-tree read.
276        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    /// Write a file through the overlay.
287    ///
288    /// Determines whether the file is new (not in base tree) or modified.
289    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    /// Delete a file in the overlay.
300    pub async fn delete_file(&self, path: &str) -> Result<()> {
301        self.overlay.delete(path).await
302    }
303
304    /// List files visible in this workspace.
305    ///
306    /// If `only_modified` is true, return only overlay entries.
307    /// Otherwise, return the full base tree merged with overlay changes.
308    ///
309    /// When `prefix` is `Some`, only paths starting with the given prefix
310    /// are included. The filter is applied early in the pipeline so that
311    /// building the `HashSet` only contains relevant entries rather than
312    /// the entire tree (which can be 100k+ files in large repos).
313    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        // Start with base tree — filter by prefix before collecting into
337        // the HashSet to avoid allocating entries we will immediately discard.
338        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        // Apply overlay (only entries matching the prefix)
345        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    /// Touch the workspace to update last-active timestamp.
365    pub fn touch(&mut self) {
366        self.last_active = Instant::now();
367    }
368
369    /// Record that this session just read `path` at the current wall-clock
370    /// time. Called from `handle_file_read` to feed the STALE_OVERLAY
371    /// pre-write check: a session whose per-path timestamp predates a
372    /// competing submitted changeset touching the same path is forced to
373    /// re-read before the next `dk_file_write` is accepted.
374    pub fn mark_read(&self, path: &str) {
375        self.files_read.insert(path.to_string(), Utc::now());
376    }
377
378    /// Return the wall-clock timestamp of the most recent `dk_file_read`
379    /// for `path`, or `None` if this session has never read it.
380    pub fn last_read(&self, path: &str) -> Option<DateTime<Utc>> {
381        self.files_read.get(path).map(|e| *e.value())
382    }
383
384    /// Re-parse overlay file contents and rebuild the semantic graph.
385    ///
386    /// Called by [`WorkspaceManager::resume`] after the overlay is restored from
387    /// the database. Walks every entry in the overlay, parses supported files,
388    /// and updates the session graph delta accordingly:
389    ///
390    /// - **Deleted** entries: all session-owned symbols for that file are
391    ///   removed from the delta by iterating `added_symbols` and
392    ///   `modified_symbols` and dropping matches.
393    /// - **Added / Modified** entries: the content is re-parsed by the
394    ///   [`ParserRegistry`]. The parse result is fed into
395    ///   [`SessionGraph::update_from_parse`] with an empty base (all symbols
396    ///   in overlay files are session additions — there is no base-symbol set
397    ///   available at this point). Unsupported file extensions are silently
398    ///   skipped.
399    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                    // Remove any session-owned symbols for this file.
412                    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                    // All overlay symbols are session additions — pass an empty
434                    // base so update_from_parse classifies all new symbols as
435                    // added and removes none.
436                    self.graph
437                        .update_from_parse(&path_str, analysis.symbols, &[]);
438                }
439            }
440        }
441
442        Ok(())
443    }
444
445    /// Build the overlay vector for `commit_tree_overlay`.
446    ///
447    /// Returns `(path, Some(content))` for modified/added files and
448    /// `(path, None)` for deleted files.
449    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        // Write a Rust file with a single function into the overlay.
485        ws.overlay
486            .write_local("x.rs", b"pub fn hello() {}".to_vec(), true);
487
488        ws.reindex_from_overlay().await.unwrap();
489
490        // The graph should now contain "hello" (added symbol).
491        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        // Should not error — unsupported extension is silently skipped.
505        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        // Pre-populate the graph with a symbol for the file.
516        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        // Mark file as deleted in the overlay.
533        ws.overlay.delete_local("gone.rs");
534
535        ws.reindex_from_overlay().await.unwrap();
536
537        // Symbol should have been removed.
538        let symbols = ws.graph.changed_symbols_for_file("gone.rs");
539        assert!(symbols.is_empty(), "deleted file should have no graph symbols");
540    }
541}