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    /// Create a new workspace and persist metadata to the database.
151    #[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        // Persist to DB
167        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    /// Read a file through the overlay-first layer.
208    ///
209    /// 1. If the overlay has a `Modified` or `Added` entry, return that content.
210    /// 2. If the overlay has a `Deleted` entry, return a "not found" error.
211    /// 3. Otherwise, read from the Git tree at `base_commit`.
212    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        // Fall through to base tree.
229        // TODO(perf): The git tree entry already stores a content-addressable
230        // OID (blob hash). If GitRepository exposed the entry OID we could use
231        // it directly instead of recomputing SHA-256 on every base-tree read.
232        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    /// Write a file through the overlay.
243    ///
244    /// Determines whether the file is new (not in base tree) or modified.
245    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    /// Delete a file in the overlay.
256    pub async fn delete_file(&self, path: &str) -> Result<()> {
257        self.overlay.delete(path).await
258    }
259
260    /// List files visible in this workspace.
261    ///
262    /// If `only_modified` is true, return only overlay entries.
263    /// Otherwise, return the full base tree merged with overlay changes.
264    ///
265    /// When `prefix` is `Some`, only paths starting with the given prefix
266    /// are included. The filter is applied early in the pipeline so that
267    /// building the `HashSet` only contains relevant entries rather than
268    /// the entire tree (which can be 100k+ files in large repos).
269    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        // Start with base tree — filter by prefix before collecting into
293        // the HashSet to avoid allocating entries we will immediately discard.
294        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        // Apply overlay (only entries matching the prefix)
301        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    /// Touch the workspace to update last-active timestamp.
321    pub fn touch(&mut self) {
322        self.last_active = Instant::now();
323    }
324
325    /// Record that this session just read `path` at the current wall-clock
326    /// time. Called from `handle_file_read` to feed the STALE_OVERLAY
327    /// pre-write check: a session whose per-path timestamp predates a
328    /// competing submitted changeset touching the same path is forced to
329    /// re-read before the next `dk_file_write` is accepted.
330    pub fn mark_read(&self, path: &str) {
331        self.files_read.insert(path.to_string(), Utc::now());
332    }
333
334    /// Return the wall-clock timestamp of the most recent `dk_file_read`
335    /// for `path`, or `None` if this session has never read it.
336    pub fn last_read(&self, path: &str) -> Option<DateTime<Utc>> {
337        self.files_read.get(path).map(|e| *e.value())
338    }
339
340    /// Build the overlay vector for `commit_tree_overlay`.
341    ///
342    /// Returns `(path, Some(content))` for modified/added files and
343    /// `(path, None)` for deleted files.
344    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}