intent_engine/
workspace.rs

1use crate::db::models::Task;
2use crate::error::{IntentError, Result};
3use serde::Serialize;
4use sqlx::SqlitePool;
5
6/// Default session ID for backward compatibility
7pub const DEFAULT_SESSION_ID: &str = "-1";
8
9/// Resolve session ID from various sources
10/// Priority: explicit param > IE_SESSION_ID env > default "-1"
11pub fn resolve_session_id(explicit: Option<&str>) -> String {
12    if let Some(s) = explicit {
13        if !s.is_empty() {
14            return s.to_string();
15        }
16    }
17
18    if let Ok(s) = std::env::var("IE_SESSION_ID") {
19        if !s.is_empty() {
20            return s;
21        }
22    }
23
24    DEFAULT_SESSION_ID.to_string()
25}
26
27#[derive(Debug, Serialize)]
28pub struct CurrentTaskResponse {
29    pub current_task_id: Option<i64>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub task: Option<Task>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub session_id: Option<String>,
34}
35
36pub struct WorkspaceManager<'a> {
37    pool: &'a SqlitePool,
38}
39
40impl<'a> WorkspaceManager<'a> {
41    pub fn new(pool: &'a SqlitePool) -> Self {
42        Self { pool }
43    }
44
45    /// Get the current task for a session
46    #[tracing::instrument(skip(self))]
47    pub async fn get_current_task(&self, session_id: Option<&str>) -> Result<CurrentTaskResponse> {
48        let session_id = resolve_session_id(session_id);
49
50        // Try to get from sessions table first
51        let current_task_id: Option<i64> = sqlx::query_scalar::<_, Option<i64>>(
52            "SELECT current_task_id FROM sessions WHERE session_id = ?",
53        )
54        .bind(&session_id)
55        .fetch_optional(self.pool)
56        .await?
57        .flatten();
58
59        // Update last_active_at if session exists
60        if current_task_id.is_some() {
61            sqlx::query(
62                "UPDATE sessions SET last_active_at = datetime('now') WHERE session_id = ?",
63            )
64            .bind(&session_id)
65            .execute(self.pool)
66            .await?;
67        }
68
69        let task = if let Some(id) = current_task_id {
70            sqlx::query_as::<_, Task>(
71                r#"
72                SELECT id, parent_id, name, spec, status, complexity, priority, first_todo_at, first_doing_at, first_done_at, active_form, owner
73                FROM tasks
74                WHERE id = ?
75                "#,
76            )
77            .bind(id)
78            .fetch_optional(self.pool)
79            .await?
80        } else {
81            None
82        };
83
84        Ok(CurrentTaskResponse {
85            current_task_id,
86            task,
87            session_id: Some(session_id),
88        })
89    }
90
91    /// Set the current task for a session
92    #[tracing::instrument(skip(self))]
93    pub async fn set_current_task(
94        &self,
95        task_id: i64,
96        session_id: Option<&str>,
97    ) -> Result<CurrentTaskResponse> {
98        let session_id = resolve_session_id(session_id);
99
100        // Check if task exists
101        let task_exists: bool =
102            sqlx::query_scalar::<_, bool>(crate::sql_constants::CHECK_TASK_EXISTS)
103                .bind(task_id)
104                .fetch_one(self.pool)
105                .await?;
106
107        if !task_exists {
108            return Err(IntentError::TaskNotFound(task_id));
109        }
110
111        // Upsert session with current task
112        sqlx::query(
113            r#"
114            INSERT INTO sessions (session_id, current_task_id, created_at, last_active_at)
115            VALUES (?, ?, datetime('now'), datetime('now'))
116            ON CONFLICT(session_id) DO UPDATE SET
117                current_task_id = excluded.current_task_id,
118                last_active_at = datetime('now')
119            "#,
120        )
121        .bind(&session_id)
122        .bind(task_id)
123        .execute(self.pool)
124        .await?;
125
126        self.get_current_task(Some(&session_id)).await
127    }
128
129    /// Clear the current task for a session
130    pub async fn clear_current_task(&self, session_id: Option<&str>) -> Result<()> {
131        let session_id = resolve_session_id(session_id);
132
133        sqlx::query(
134            "UPDATE sessions SET current_task_id = NULL, last_active_at = datetime('now') WHERE session_id = ?"
135        )
136        .bind(&session_id)
137        .execute(self.pool)
138        .await?;
139
140        Ok(())
141    }
142
143    /// Clean up expired sessions (older than given hours)
144    pub async fn cleanup_expired_sessions(&self, hours: u32) -> Result<u64> {
145        let result = sqlx::query(&format!(
146            "DELETE FROM sessions WHERE last_active_at < datetime('now', '-{} hours')",
147            hours
148        ))
149        .execute(self.pool)
150        .await?;
151
152        Ok(result.rows_affected())
153    }
154
155    /// Enforce session limit (keep most recent N sessions)
156    pub async fn enforce_session_limit(&self, max_sessions: u32) -> Result<u64> {
157        let result = sqlx::query(
158            r#"
159            DELETE FROM sessions
160            WHERE session_id IN (
161                SELECT session_id FROM sessions
162                ORDER BY last_active_at DESC
163                LIMIT -1 OFFSET ?
164            )
165            "#,
166        )
167        .bind(max_sessions)
168        .execute(self.pool)
169        .await?;
170
171        Ok(result.rows_affected())
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::tasks::TaskManager;
179    use crate::test_utils::test_helpers::TestContext;
180
181    #[tokio::test]
182    async fn test_get_current_task_none() {
183        let ctx = TestContext::new().await;
184        let workspace_mgr = WorkspaceManager::new(ctx.pool());
185
186        let response = workspace_mgr.get_current_task(None).await.unwrap();
187
188        assert!(response.current_task_id.is_none());
189        assert!(response.task.is_none());
190    }
191
192    #[tokio::test]
193    async fn test_set_current_task() {
194        let ctx = TestContext::new().await;
195        let task_mgr = TaskManager::new(ctx.pool());
196        let workspace_mgr = WorkspaceManager::new(ctx.pool());
197
198        let task = task_mgr
199            .add_task("Test task", None, None, None)
200            .await
201            .unwrap();
202
203        let response = workspace_mgr.set_current_task(task.id, None).await.unwrap();
204
205        assert_eq!(response.current_task_id, Some(task.id));
206        assert!(response.task.is_some());
207        assert_eq!(response.task.unwrap().id, task.id);
208    }
209
210    #[tokio::test]
211    async fn test_set_current_task_nonexistent() {
212        let ctx = TestContext::new().await;
213        let workspace_mgr = WorkspaceManager::new(ctx.pool());
214
215        let result = workspace_mgr.set_current_task(999, None).await;
216        assert!(matches!(result, Err(IntentError::TaskNotFound(999))));
217    }
218
219    #[tokio::test]
220    async fn test_update_current_task() {
221        let ctx = TestContext::new().await;
222        let task_mgr = TaskManager::new(ctx.pool());
223        let workspace_mgr = WorkspaceManager::new(ctx.pool());
224
225        let task1 = task_mgr.add_task("Task 1", None, None, None).await.unwrap();
226        let task2 = task_mgr.add_task("Task 2", None, None, None).await.unwrap();
227
228        // Set task1 as current
229        workspace_mgr
230            .set_current_task(task1.id, None)
231            .await
232            .unwrap();
233
234        // Update to task2
235        let response = workspace_mgr
236            .set_current_task(task2.id, None)
237            .await
238            .unwrap();
239
240        assert_eq!(response.current_task_id, Some(task2.id));
241        assert_eq!(response.task.unwrap().id, task2.id);
242    }
243
244    #[tokio::test]
245    async fn test_get_current_task_after_set() {
246        let ctx = TestContext::new().await;
247        let task_mgr = TaskManager::new(ctx.pool());
248        let workspace_mgr = WorkspaceManager::new(ctx.pool());
249
250        let task = task_mgr
251            .add_task("Test task", None, None, None)
252            .await
253            .unwrap();
254        workspace_mgr.set_current_task(task.id, None).await.unwrap();
255
256        let response = workspace_mgr.get_current_task(None).await.unwrap();
257
258        assert_eq!(response.current_task_id, Some(task.id));
259        assert!(response.task.is_some());
260    }
261
262    #[tokio::test]
263    async fn test_current_task_response_serialization() {
264        let ctx = TestContext::new().await;
265        let task_mgr = TaskManager::new(ctx.pool());
266        let workspace_mgr = WorkspaceManager::new(ctx.pool());
267
268        let task = task_mgr
269            .add_task("Test task", None, None, None)
270            .await
271            .unwrap();
272        let response = workspace_mgr.set_current_task(task.id, None).await.unwrap();
273
274        // Should serialize to JSON without errors
275        let json = serde_json::to_string(&response).unwrap();
276        assert!(json.contains("current_task_id"));
277        assert!(json.contains("task"));
278    }
279
280    #[tokio::test]
281    async fn test_current_task_response_none_serialization() {
282        let ctx = TestContext::new().await;
283        let workspace_mgr = WorkspaceManager::new(ctx.pool());
284
285        let response = workspace_mgr.get_current_task(None).await.unwrap();
286
287        // When no task, task field should be omitted (skip_serializing_if)
288        let json = serde_json::to_string(&response).unwrap();
289        assert!(json.contains("current_task_id"));
290        // task field should be omitted when None
291        assert!(!json.contains("\"task\""));
292    }
293
294    #[tokio::test]
295    async fn test_get_current_task_with_deleted_task() {
296        let ctx = TestContext::new().await;
297        let task_mgr = TaskManager::new(ctx.pool());
298        let workspace_mgr = WorkspaceManager::new(ctx.pool());
299
300        let task = task_mgr
301            .add_task("Test task", None, None, None)
302            .await
303            .unwrap();
304        workspace_mgr.set_current_task(task.id, None).await.unwrap();
305
306        // Delete the task - this triggers ON DELETE SET NULL in sessions table
307        task_mgr.delete_task(task.id).await.unwrap();
308
309        let response = workspace_mgr.get_current_task(None).await.unwrap();
310
311        // Due to ON DELETE SET NULL, current_task_id should be None
312        assert!(response.current_task_id.is_none());
313        assert!(response.task.is_none());
314    }
315
316    #[tokio::test]
317    async fn test_set_current_task_returns_complete_task() {
318        let ctx = TestContext::new().await;
319        let task_mgr = TaskManager::new(ctx.pool());
320        let workspace_mgr = WorkspaceManager::new(ctx.pool());
321
322        let task = task_mgr
323            .add_task("Test task", Some("Task spec"), None, None)
324            .await
325            .unwrap();
326
327        let response = workspace_mgr.set_current_task(task.id, None).await.unwrap();
328
329        // Verify task object is complete
330        let returned_task = response.task.unwrap();
331        assert_eq!(returned_task.id, task.id);
332        assert_eq!(returned_task.name, "Test task");
333        assert_eq!(returned_task.spec, Some("Task spec".to_string()));
334        assert_eq!(returned_task.status, "todo");
335    }
336
337    #[tokio::test]
338    async fn test_set_same_task_multiple_times() {
339        let ctx = TestContext::new().await;
340        let task_mgr = TaskManager::new(ctx.pool());
341        let workspace_mgr = WorkspaceManager::new(ctx.pool());
342
343        let task = task_mgr
344            .add_task("Test task", None, None, None)
345            .await
346            .unwrap();
347
348        // Set the same task multiple times (idempotent)
349        workspace_mgr.set_current_task(task.id, None).await.unwrap();
350        workspace_mgr.set_current_task(task.id, None).await.unwrap();
351        let response = workspace_mgr.set_current_task(task.id, None).await.unwrap();
352
353        assert_eq!(response.current_task_id, Some(task.id));
354    }
355
356    #[tokio::test]
357    async fn test_session_isolation() {
358        let ctx = TestContext::new().await;
359        let task_mgr = TaskManager::new(ctx.pool());
360        let workspace_mgr = WorkspaceManager::new(ctx.pool());
361
362        let task1 = task_mgr.add_task("Task 1", None, None, None).await.unwrap();
363        let task2 = task_mgr.add_task("Task 2", None, None, None).await.unwrap();
364
365        // Set different tasks for different sessions
366        workspace_mgr
367            .set_current_task(task1.id, Some("session-a"))
368            .await
369            .unwrap();
370        workspace_mgr
371            .set_current_task(task2.id, Some("session-b"))
372            .await
373            .unwrap();
374
375        // Each session should see its own task
376        let response_a = workspace_mgr
377            .get_current_task(Some("session-a"))
378            .await
379            .unwrap();
380        let response_b = workspace_mgr
381            .get_current_task(Some("session-b"))
382            .await
383            .unwrap();
384
385        assert_eq!(response_a.current_task_id, Some(task1.id));
386        assert_eq!(response_b.current_task_id, Some(task2.id));
387        assert_eq!(response_a.session_id, Some("session-a".to_string()));
388        assert_eq!(response_b.session_id, Some("session-b".to_string()));
389    }
390
391    #[tokio::test]
392    async fn test_session_upsert() {
393        let ctx = TestContext::new().await;
394        let task_mgr = TaskManager::new(ctx.pool());
395        let workspace_mgr = WorkspaceManager::new(ctx.pool());
396
397        let task1 = task_mgr.add_task("Task 1", None, None, None).await.unwrap();
398        let task2 = task_mgr.add_task("Task 2", None, None, None).await.unwrap();
399
400        // Update same session's task
401        workspace_mgr
402            .set_current_task(task1.id, Some("session-x"))
403            .await
404            .unwrap();
405        workspace_mgr
406            .set_current_task(task2.id, Some("session-x"))
407            .await
408            .unwrap();
409
410        // Should only have one session entry with the latest task
411        let response = workspace_mgr
412            .get_current_task(Some("session-x"))
413            .await
414            .unwrap();
415        assert_eq!(response.current_task_id, Some(task2.id));
416
417        // Check only one session row exists
418        let count: i64 =
419            sqlx::query_scalar("SELECT COUNT(*) FROM sessions WHERE session_id = 'session-x'")
420                .fetch_one(ctx.pool())
421                .await
422                .unwrap();
423        assert_eq!(count, 1);
424    }
425
426    #[tokio::test]
427    async fn test_get_current_task_with_changed_status() {
428        let ctx = TestContext::new().await;
429        let task_mgr = TaskManager::new(ctx.pool());
430        let workspace_mgr = WorkspaceManager::new(ctx.pool());
431
432        let task = task_mgr
433            .add_task("Test task", None, None, None)
434            .await
435            .unwrap();
436        workspace_mgr.set_current_task(task.id, None).await.unwrap();
437
438        // Change task status
439        task_mgr.start_task(task.id, false).await.unwrap();
440
441        let response = workspace_mgr.get_current_task(None).await.unwrap();
442
443        // Should reflect updated status
444        assert_eq!(response.task.unwrap().status, "doing");
445    }
446
447    #[tokio::test]
448    async fn test_clear_current_task() {
449        let ctx = TestContext::new().await;
450        let task_mgr = TaskManager::new(ctx.pool());
451        let workspace_mgr = WorkspaceManager::new(ctx.pool());
452
453        let task = task_mgr.add_task("Task", None, None, None).await.unwrap();
454        workspace_mgr
455            .set_current_task(task.id, Some("test-session"))
456            .await
457            .unwrap();
458
459        // Clear the current task
460        workspace_mgr
461            .clear_current_task(Some("test-session"))
462            .await
463            .unwrap();
464
465        let response = workspace_mgr
466            .get_current_task(Some("test-session"))
467            .await
468            .unwrap();
469        assert!(response.current_task_id.is_none());
470    }
471
472    #[tokio::test]
473    async fn test_cleanup_expired_sessions() {
474        let ctx = TestContext::new().await;
475        let task_mgr = TaskManager::new(ctx.pool());
476        let workspace_mgr = WorkspaceManager::new(ctx.pool());
477
478        let task = task_mgr.add_task("Task", None, None, None).await.unwrap();
479
480        // Create a session
481        workspace_mgr
482            .set_current_task(task.id, Some("old-session"))
483            .await
484            .unwrap();
485
486        // Manually set last_active_at to 25 hours ago
487        sqlx::query(
488            "UPDATE sessions SET last_active_at = datetime('now', '-25 hours') WHERE session_id = 'old-session'"
489        )
490        .execute(ctx.pool())
491        .await
492        .unwrap();
493
494        // Create a recent session
495        workspace_mgr
496            .set_current_task(task.id, Some("new-session"))
497            .await
498            .unwrap();
499
500        // Cleanup sessions older than 24 hours
501        let deleted = workspace_mgr.cleanup_expired_sessions(24).await.unwrap();
502        assert_eq!(deleted, 1);
503
504        // Old session should be gone
505        let response = workspace_mgr
506            .get_current_task(Some("old-session"))
507            .await
508            .unwrap();
509        assert!(response.current_task_id.is_none());
510
511        // New session should still exist
512        let response = workspace_mgr
513            .get_current_task(Some("new-session"))
514            .await
515            .unwrap();
516        assert_eq!(response.current_task_id, Some(task.id));
517    }
518
519    #[tokio::test]
520    async fn test_resolve_session_id_priority() {
521        // Test explicit param takes priority
522        assert_eq!(resolve_session_id(Some("explicit")), "explicit");
523
524        // Test empty explicit falls through to env var or default
525        let empty_result = resolve_session_id(Some(""));
526        // When IE_SESSION_ID is set, it uses that; otherwise uses DEFAULT_SESSION_ID
527        if let Ok(env_session) = std::env::var("IE_SESSION_ID") {
528            if !env_session.is_empty() {
529                assert_eq!(empty_result, env_session);
530            } else {
531                assert_eq!(empty_result, DEFAULT_SESSION_ID);
532            }
533        } else {
534            assert_eq!(empty_result, DEFAULT_SESSION_ID);
535        }
536
537        // Test None falls through to default (env var may or may not be set)
538        let result = resolve_session_id(None);
539        // Either uses env var or default
540        assert!(!result.is_empty());
541    }
542}