Skip to main content

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, metadata
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.as_str())).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
175impl crate::backend::WorkspaceBackend for WorkspaceManager<'_> {
176    fn get_current_task(
177        &self,
178        session_id: Option<&str>,
179    ) -> impl std::future::Future<Output = Result<CurrentTaskResponse>> + Send {
180        self.get_current_task(session_id)
181    }
182
183    fn set_current_task(
184        &self,
185        task_id: i64,
186        session_id: Option<&str>,
187    ) -> impl std::future::Future<Output = Result<CurrentTaskResponse>> + Send {
188        self.set_current_task(task_id, session_id)
189    }
190
191    fn clear_current_task(
192        &self,
193        session_id: Option<&str>,
194    ) -> impl std::future::Future<Output = Result<()>> + Send {
195        self.clear_current_task(session_id)
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use crate::tasks::TaskManager;
203    use crate::test_utils::test_helpers::TestContext;
204
205    #[tokio::test]
206    async fn test_get_current_task_none() {
207        let ctx = TestContext::new().await;
208        let workspace_mgr = WorkspaceManager::new(ctx.pool());
209
210        let response = workspace_mgr.get_current_task(None).await.unwrap();
211
212        assert!(response.current_task_id.is_none());
213        assert!(response.task.is_none());
214    }
215
216    #[tokio::test]
217    async fn test_set_current_task() {
218        let ctx = TestContext::new().await;
219        let task_mgr = TaskManager::new(ctx.pool());
220        let workspace_mgr = WorkspaceManager::new(ctx.pool());
221
222        let task = task_mgr
223            .add_task("Test task".to_string(), None, None, None, None, None)
224            .await
225            .unwrap();
226
227        let response = workspace_mgr.set_current_task(task.id, None).await.unwrap();
228
229        assert_eq!(response.current_task_id, Some(task.id));
230        assert!(response.task.is_some());
231        assert_eq!(response.task.unwrap().id, task.id);
232    }
233
234    #[tokio::test]
235    async fn test_set_current_task_nonexistent() {
236        let ctx = TestContext::new().await;
237        let workspace_mgr = WorkspaceManager::new(ctx.pool());
238
239        let result = workspace_mgr.set_current_task(999, None).await;
240        assert!(matches!(result, Err(IntentError::TaskNotFound(999))));
241    }
242
243    #[tokio::test]
244    async fn test_update_current_task() {
245        let ctx = TestContext::new().await;
246        let task_mgr = TaskManager::new(ctx.pool());
247        let workspace_mgr = WorkspaceManager::new(ctx.pool());
248
249        let task1 = task_mgr
250            .add_task("Task 1".to_string(), None, None, None, None, None)
251            .await
252            .unwrap();
253        let task2 = task_mgr
254            .add_task("Task 2".to_string(), None, None, None, None, None)
255            .await
256            .unwrap();
257
258        // Set task1 as current
259        workspace_mgr
260            .set_current_task(task1.id, None)
261            .await
262            .unwrap();
263
264        // Update to task2
265        let response = workspace_mgr
266            .set_current_task(task2.id, None)
267            .await
268            .unwrap();
269
270        assert_eq!(response.current_task_id, Some(task2.id));
271        assert_eq!(response.task.unwrap().id, task2.id);
272    }
273
274    #[tokio::test]
275    async fn test_get_current_task_after_set() {
276        let ctx = TestContext::new().await;
277        let task_mgr = TaskManager::new(ctx.pool());
278        let workspace_mgr = WorkspaceManager::new(ctx.pool());
279
280        let task = task_mgr
281            .add_task("Test task".to_string(), None, None, None, None, None)
282            .await
283            .unwrap();
284        workspace_mgr.set_current_task(task.id, None).await.unwrap();
285
286        let response = workspace_mgr.get_current_task(None).await.unwrap();
287
288        assert_eq!(response.current_task_id, Some(task.id));
289        assert!(response.task.is_some());
290    }
291
292    #[tokio::test]
293    async fn test_current_task_response_serialization() {
294        let ctx = TestContext::new().await;
295        let task_mgr = TaskManager::new(ctx.pool());
296        let workspace_mgr = WorkspaceManager::new(ctx.pool());
297
298        let task = task_mgr
299            .add_task("Test task".to_string(), None, None, None, None, None)
300            .await
301            .unwrap();
302        let response = workspace_mgr.set_current_task(task.id, None).await.unwrap();
303
304        // Should serialize to JSON without errors
305        let json = serde_json::to_string(&response).unwrap();
306        assert!(json.contains("current_task_id"));
307        assert!(json.contains("task"));
308    }
309
310    #[tokio::test]
311    async fn test_current_task_response_none_serialization() {
312        let ctx = TestContext::new().await;
313        let workspace_mgr = WorkspaceManager::new(ctx.pool());
314
315        let response = workspace_mgr.get_current_task(None).await.unwrap();
316
317        // When no task, task field should be omitted (skip_serializing_if)
318        let json = serde_json::to_string(&response).unwrap();
319        assert!(json.contains("current_task_id"));
320        // task field should be omitted when None
321        assert!(!json.contains("\"task\""));
322    }
323
324    #[tokio::test]
325    async fn test_delete_focused_task_is_rejected() {
326        let ctx = TestContext::new().await;
327        let task_mgr = TaskManager::new(ctx.pool());
328        let workspace_mgr = WorkspaceManager::new(ctx.pool());
329
330        let task = task_mgr
331            .add_task("Test task".to_string(), None, None, None, None, None)
332            .await
333            .unwrap();
334        workspace_mgr.set_current_task(task.id, None).await.unwrap();
335
336        // Deleting a focused task must be rejected
337        let err = task_mgr.delete_task(task.id).await.unwrap_err();
338        assert!(
339            matches!(err, crate::error::IntentError::ActionNotAllowed(_)),
340            "expected ActionNotAllowed, got: {:?}",
341            err
342        );
343
344        // Task still exists
345        assert!(task_mgr.get_task(task.id).await.is_ok());
346    }
347
348    #[tokio::test]
349    async fn test_cascade_delete_focused_descendant_is_rejected() {
350        let ctx = TestContext::new().await;
351        let task_mgr = TaskManager::new(ctx.pool());
352        let workspace_mgr = WorkspaceManager::new(ctx.pool());
353
354        let parent = task_mgr
355            .add_task("Parent".to_string(), None, None, None, None, None)
356            .await
357            .unwrap();
358        let child = task_mgr
359            .add_task("Child".to_string(), None, Some(parent.id), None, None, None)
360            .await
361            .unwrap();
362        workspace_mgr
363            .set_current_task(child.id, None)
364            .await
365            .unwrap();
366
367        // Cascade-deleting the parent must be rejected because the child is focused
368        let err = task_mgr.delete_task_cascade(parent.id).await.unwrap_err();
369        assert!(
370            matches!(err, crate::error::IntentError::ActionNotAllowed(_)),
371            "expected ActionNotAllowed, got: {:?}",
372            err
373        );
374
375        // Both tasks still exist
376        assert!(task_mgr.get_task(parent.id).await.is_ok());
377        assert!(task_mgr.get_task(child.id).await.is_ok());
378    }
379
380    #[tokio::test]
381    async fn test_delete_unfocused_task_succeeds() {
382        let ctx = TestContext::new().await;
383        let task_mgr = TaskManager::new(ctx.pool());
384        let workspace_mgr = WorkspaceManager::new(ctx.pool());
385
386        let task = task_mgr
387            .add_task("Test task".to_string(), None, None, None, None, None)
388            .await
389            .unwrap();
390        workspace_mgr.set_current_task(task.id, None).await.unwrap();
391
392        // Unfocus, then delete succeeds
393        workspace_mgr.clear_current_task(None).await.unwrap();
394        task_mgr.delete_task(task.id).await.unwrap();
395
396        // Task is gone
397        assert!(task_mgr.get_task(task.id).await.is_err());
398    }
399
400    #[tokio::test]
401    async fn test_set_current_task_returns_complete_task() {
402        let ctx = TestContext::new().await;
403        let task_mgr = TaskManager::new(ctx.pool());
404        let workspace_mgr = WorkspaceManager::new(ctx.pool());
405
406        let task = task_mgr
407            .add_task(
408                "Test task".to_string(),
409                Some("Task spec".to_string()),
410                None,
411                None,
412                None,
413                None,
414            )
415            .await
416            .unwrap();
417
418        let response = workspace_mgr.set_current_task(task.id, None).await.unwrap();
419
420        // Verify task object is complete
421        let returned_task = response.task.unwrap();
422        assert_eq!(returned_task.id, task.id);
423        assert_eq!(returned_task.name, "Test task");
424        assert_eq!(returned_task.spec, Some("Task spec".to_string()));
425        assert_eq!(returned_task.status, "todo");
426    }
427
428    #[tokio::test]
429    async fn test_set_same_task_multiple_times() {
430        let ctx = TestContext::new().await;
431        let task_mgr = TaskManager::new(ctx.pool());
432        let workspace_mgr = WorkspaceManager::new(ctx.pool());
433
434        let task = task_mgr
435            .add_task("Test task".to_string(), None, None, None, None, None)
436            .await
437            .unwrap();
438
439        // Set the same task multiple times (idempotent)
440        workspace_mgr.set_current_task(task.id, None).await.unwrap();
441        workspace_mgr.set_current_task(task.id, None).await.unwrap();
442        let response = workspace_mgr.set_current_task(task.id, None).await.unwrap();
443
444        assert_eq!(response.current_task_id, Some(task.id));
445    }
446
447    #[tokio::test]
448    async fn test_session_isolation() {
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 task1 = task_mgr
454            .add_task("Task 1".to_string(), None, None, None, None, None)
455            .await
456            .unwrap();
457        let task2 = task_mgr
458            .add_task("Task 2".to_string(), None, None, None, None, None)
459            .await
460            .unwrap();
461
462        // Set different tasks for different sessions
463        workspace_mgr
464            .set_current_task(task1.id, Some("session-a"))
465            .await
466            .unwrap();
467        workspace_mgr
468            .set_current_task(task2.id, Some("session-b"))
469            .await
470            .unwrap();
471
472        // Each session should see its own task
473        let response_a = workspace_mgr
474            .get_current_task(Some("session-a"))
475            .await
476            .unwrap();
477        let response_b = workspace_mgr
478            .get_current_task(Some("session-b"))
479            .await
480            .unwrap();
481
482        assert_eq!(response_a.current_task_id, Some(task1.id));
483        assert_eq!(response_b.current_task_id, Some(task2.id));
484        assert_eq!(response_a.session_id, Some("session-a".to_string()));
485        assert_eq!(response_b.session_id, Some("session-b".to_string()));
486    }
487
488    #[tokio::test]
489    async fn test_session_upsert() {
490        let ctx = TestContext::new().await;
491        let task_mgr = TaskManager::new(ctx.pool());
492        let workspace_mgr = WorkspaceManager::new(ctx.pool());
493
494        let task1 = task_mgr
495            .add_task("Task 1".to_string(), None, None, None, None, None)
496            .await
497            .unwrap();
498        let task2 = task_mgr
499            .add_task("Task 2".to_string(), None, None, None, None, None)
500            .await
501            .unwrap();
502
503        // Update same session's task
504        workspace_mgr
505            .set_current_task(task1.id, Some("session-x"))
506            .await
507            .unwrap();
508        workspace_mgr
509            .set_current_task(task2.id, Some("session-x"))
510            .await
511            .unwrap();
512
513        // Should only have one session entry with the latest task
514        let response = workspace_mgr
515            .get_current_task(Some("session-x"))
516            .await
517            .unwrap();
518        assert_eq!(response.current_task_id, Some(task2.id));
519
520        // Check only one session row exists
521        let count: i64 =
522            sqlx::query_scalar("SELECT COUNT(*) FROM sessions WHERE session_id = 'session-x'")
523                .fetch_one(ctx.pool())
524                .await
525                .unwrap();
526        assert_eq!(count, 1);
527    }
528
529    #[tokio::test]
530    async fn test_get_current_task_with_changed_status() {
531        let ctx = TestContext::new().await;
532        let task_mgr = TaskManager::new(ctx.pool());
533        let workspace_mgr = WorkspaceManager::new(ctx.pool());
534
535        let task = task_mgr
536            .add_task("Test task".to_string(), None, None, None, None, None)
537            .await
538            .unwrap();
539        workspace_mgr.set_current_task(task.id, None).await.unwrap();
540
541        // Change task status
542        task_mgr.start_task(task.id, false).await.unwrap();
543
544        let response = workspace_mgr.get_current_task(None).await.unwrap();
545
546        // Should reflect updated status
547        assert_eq!(response.task.unwrap().status, "doing");
548    }
549
550    #[tokio::test]
551    async fn test_clear_current_task() {
552        let ctx = TestContext::new().await;
553        let task_mgr = TaskManager::new(ctx.pool());
554        let workspace_mgr = WorkspaceManager::new(ctx.pool());
555
556        let task = task_mgr
557            .add_task("Task".to_string(), None, None, None, None, None)
558            .await
559            .unwrap();
560        workspace_mgr
561            .set_current_task(task.id, Some("test-session"))
562            .await
563            .unwrap();
564
565        // Clear the current task
566        workspace_mgr
567            .clear_current_task(Some("test-session"))
568            .await
569            .unwrap();
570
571        let response = workspace_mgr
572            .get_current_task(Some("test-session"))
573            .await
574            .unwrap();
575        assert!(response.current_task_id.is_none());
576    }
577
578    #[tokio::test]
579    async fn test_cleanup_expired_sessions() {
580        let ctx = TestContext::new().await;
581        let task_mgr = TaskManager::new(ctx.pool());
582        let workspace_mgr = WorkspaceManager::new(ctx.pool());
583
584        let task = task_mgr
585            .add_task("Task".to_string(), None, None, None, None, None)
586            .await
587            .unwrap();
588
589        // Create a session
590        workspace_mgr
591            .set_current_task(task.id, Some("old-session"))
592            .await
593            .unwrap();
594
595        // Manually set last_active_at to 25 hours ago
596        sqlx::query(
597            "UPDATE sessions SET last_active_at = datetime('now', '-25 hours') WHERE session_id = 'old-session'"
598        )
599        .execute(ctx.pool())
600        .await
601        .unwrap();
602
603        // Create a recent session
604        workspace_mgr
605            .set_current_task(task.id, Some("new-session"))
606            .await
607            .unwrap();
608
609        // Cleanup sessions older than 24 hours
610        let deleted = workspace_mgr.cleanup_expired_sessions(24).await.unwrap();
611        assert_eq!(deleted, 1);
612
613        // Old session should be gone
614        let response = workspace_mgr
615            .get_current_task(Some("old-session"))
616            .await
617            .unwrap();
618        assert!(response.current_task_id.is_none());
619
620        // New session should still exist
621        let response = workspace_mgr
622            .get_current_task(Some("new-session"))
623            .await
624            .unwrap();
625        assert_eq!(response.current_task_id, Some(task.id));
626    }
627
628    #[tokio::test]
629    async fn test_resolve_session_id_priority() {
630        // Test explicit param takes priority
631        assert_eq!(resolve_session_id(Some("explicit")), "explicit");
632
633        // Test empty explicit falls through to env var or default
634        let empty_result = resolve_session_id(Some(""));
635        // When IE_SESSION_ID is set, it uses that; otherwise uses DEFAULT_SESSION_ID
636        if let Ok(env_session) = std::env::var("IE_SESSION_ID") {
637            if !env_session.is_empty() {
638                assert_eq!(empty_result, env_session);
639            } else {
640                assert_eq!(empty_result, DEFAULT_SESSION_ID);
641            }
642        } else {
643            assert_eq!(empty_result, DEFAULT_SESSION_ID);
644        }
645
646        // Test None falls through to default (env var may or may not be set)
647        let result = resolve_session_id(None);
648        // Either uses env var or default
649        assert!(!result.is_empty());
650    }
651}