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(
48        &self,
49        session_id: Option<String>,
50    ) -> Result<CurrentTaskResponse> {
51        let session_id = resolve_session_id(session_id.as_deref());
52
53        // Try to get from sessions table first
54        let current_task_id: Option<i64> = sqlx::query_scalar::<_, Option<i64>>(
55            "SELECT current_task_id FROM sessions WHERE session_id = ?",
56        )
57        .bind(&session_id)
58        .fetch_optional(self.pool)
59        .await?
60        .flatten();
61
62        // Update last_active_at if session exists
63        if current_task_id.is_some() {
64            sqlx::query(
65                "UPDATE sessions SET last_active_at = datetime('now') WHERE session_id = ?",
66            )
67            .bind(&session_id)
68            .execute(self.pool)
69            .await?;
70        }
71
72        let task = if let Some(id) = current_task_id {
73            sqlx::query_as::<_, Task>(
74                r#"
75                SELECT id, parent_id, name, spec, status, complexity, priority, first_todo_at, first_doing_at, first_done_at, active_form, owner, metadata
76                FROM tasks
77                WHERE id = ?
78                "#,
79            )
80            .bind(id)
81            .fetch_optional(self.pool)
82            .await?
83        } else {
84            None
85        };
86
87        Ok(CurrentTaskResponse {
88            current_task_id,
89            task,
90            session_id: Some(session_id),
91        })
92    }
93
94    /// Set the current task for a session
95    #[tracing::instrument(skip(self))]
96    pub async fn set_current_task(
97        &self,
98        task_id: i64,
99        session_id: Option<String>,
100    ) -> Result<CurrentTaskResponse> {
101        let session_id = resolve_session_id(session_id.as_deref());
102
103        // Check if task exists
104        let task_exists: bool =
105            sqlx::query_scalar::<_, bool>(crate::sql_constants::CHECK_TASK_EXISTS)
106                .bind(task_id)
107                .fetch_one(self.pool)
108                .await?;
109
110        if !task_exists {
111            return Err(IntentError::TaskNotFound(task_id));
112        }
113
114        // Upsert session with current task
115        sqlx::query(
116            r#"
117            INSERT INTO sessions (session_id, current_task_id, created_at, last_active_at)
118            VALUES (?, ?, datetime('now'), datetime('now'))
119            ON CONFLICT(session_id) DO UPDATE SET
120                current_task_id = excluded.current_task_id,
121                last_active_at = datetime('now')
122            "#,
123        )
124        .bind(&session_id)
125        .bind(task_id)
126        .execute(self.pool)
127        .await?;
128
129        self.get_current_task(Some(session_id)).await
130    }
131
132    /// Clear the current task for a session
133    pub async fn clear_current_task(&self, session_id: Option<String>) -> Result<()> {
134        let session_id = resolve_session_id(session_id.as_deref());
135
136        sqlx::query(
137            "UPDATE sessions SET current_task_id = NULL, last_active_at = datetime('now') WHERE session_id = ?"
138        )
139        .bind(&session_id)
140        .execute(self.pool)
141        .await?;
142
143        Ok(())
144    }
145
146    /// Clean up expired sessions (older than given hours)
147    pub async fn cleanup_expired_sessions(&self, hours: u32) -> Result<u64> {
148        let result = sqlx::query(&format!(
149            "DELETE FROM sessions WHERE last_active_at < datetime('now', '-{} hours')",
150            hours
151        ))
152        .execute(self.pool)
153        .await?;
154
155        Ok(result.rows_affected())
156    }
157
158    /// Enforce session limit (keep most recent N sessions)
159    pub async fn enforce_session_limit(&self, max_sessions: u32) -> Result<u64> {
160        let result = sqlx::query(
161            r#"
162            DELETE FROM sessions
163            WHERE session_id IN (
164                SELECT session_id FROM sessions
165                ORDER BY last_active_at DESC
166                LIMIT -1 OFFSET ?
167            )
168            "#,
169        )
170        .bind(max_sessions)
171        .execute(self.pool)
172        .await?;
173
174        Ok(result.rows_affected())
175    }
176}
177
178impl crate::backend::WorkspaceBackend for WorkspaceManager<'_> {
179    fn get_current_task(
180        &self,
181        session_id: Option<String>,
182    ) -> impl std::future::Future<Output = Result<CurrentTaskResponse>> + Send {
183        self.get_current_task(session_id)
184    }
185
186    fn set_current_task(
187        &self,
188        task_id: i64,
189        session_id: Option<String>,
190    ) -> impl std::future::Future<Output = Result<CurrentTaskResponse>> + Send {
191        self.set_current_task(task_id, session_id)
192    }
193
194    fn clear_current_task(
195        &self,
196        session_id: Option<String>,
197    ) -> impl std::future::Future<Output = Result<()>> + Send {
198        self.clear_current_task(session_id)
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::tasks::TaskManager;
206    use crate::test_utils::test_helpers::TestContext;
207
208    #[tokio::test]
209    async fn test_get_current_task_none() {
210        let ctx = TestContext::new().await;
211        let workspace_mgr = WorkspaceManager::new(ctx.pool());
212
213        let response = workspace_mgr.get_current_task(None).await.unwrap();
214
215        assert!(response.current_task_id.is_none());
216        assert!(response.task.is_none());
217    }
218
219    #[tokio::test]
220    async fn test_set_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 task = task_mgr
226            .add_task("Test task".to_string(), None, None, None, None, None)
227            .await
228            .unwrap();
229
230        let response = workspace_mgr.set_current_task(task.id, None).await.unwrap();
231
232        assert_eq!(response.current_task_id, Some(task.id));
233        assert!(response.task.is_some());
234        assert_eq!(response.task.unwrap().id, task.id);
235    }
236
237    #[tokio::test]
238    async fn test_set_current_task_nonexistent() {
239        let ctx = TestContext::new().await;
240        let workspace_mgr = WorkspaceManager::new(ctx.pool());
241
242        let result = workspace_mgr.set_current_task(999, None).await;
243        assert!(matches!(result, Err(IntentError::TaskNotFound(999))));
244    }
245
246    #[tokio::test]
247    async fn test_update_current_task() {
248        let ctx = TestContext::new().await;
249        let task_mgr = TaskManager::new(ctx.pool());
250        let workspace_mgr = WorkspaceManager::new(ctx.pool());
251
252        let task1 = task_mgr
253            .add_task("Task 1".to_string(), None, None, None, None, None)
254            .await
255            .unwrap();
256        let task2 = task_mgr
257            .add_task("Task 2".to_string(), None, None, None, None, None)
258            .await
259            .unwrap();
260
261        // Set task1 as current
262        workspace_mgr
263            .set_current_task(task1.id, None)
264            .await
265            .unwrap();
266
267        // Update to task2
268        let response = workspace_mgr
269            .set_current_task(task2.id, None)
270            .await
271            .unwrap();
272
273        assert_eq!(response.current_task_id, Some(task2.id));
274        assert_eq!(response.task.unwrap().id, task2.id);
275    }
276
277    #[tokio::test]
278    async fn test_get_current_task_after_set() {
279        let ctx = TestContext::new().await;
280        let task_mgr = TaskManager::new(ctx.pool());
281        let workspace_mgr = WorkspaceManager::new(ctx.pool());
282
283        let task = task_mgr
284            .add_task("Test task".to_string(), None, None, None, None, None)
285            .await
286            .unwrap();
287        workspace_mgr.set_current_task(task.id, None).await.unwrap();
288
289        let response = workspace_mgr.get_current_task(None).await.unwrap();
290
291        assert_eq!(response.current_task_id, Some(task.id));
292        assert!(response.task.is_some());
293    }
294
295    #[tokio::test]
296    async fn test_current_task_response_serialization() {
297        let ctx = TestContext::new().await;
298        let task_mgr = TaskManager::new(ctx.pool());
299        let workspace_mgr = WorkspaceManager::new(ctx.pool());
300
301        let task = task_mgr
302            .add_task("Test task".to_string(), None, None, None, None, None)
303            .await
304            .unwrap();
305        let response = workspace_mgr.set_current_task(task.id, None).await.unwrap();
306
307        // Should serialize to JSON without errors
308        let json = serde_json::to_string(&response).unwrap();
309        assert!(json.contains("current_task_id"));
310        assert!(json.contains("task"));
311    }
312
313    #[tokio::test]
314    async fn test_current_task_response_none_serialization() {
315        let ctx = TestContext::new().await;
316        let workspace_mgr = WorkspaceManager::new(ctx.pool());
317
318        let response = workspace_mgr.get_current_task(None).await.unwrap();
319
320        // When no task, task field should be omitted (skip_serializing_if)
321        let json = serde_json::to_string(&response).unwrap();
322        assert!(json.contains("current_task_id"));
323        // task field should be omitted when None
324        assert!(!json.contains("\"task\""));
325    }
326
327    #[tokio::test]
328    async fn test_delete_focused_task_is_rejected() {
329        let ctx = TestContext::new().await;
330        let task_mgr = TaskManager::new(ctx.pool());
331        let workspace_mgr = WorkspaceManager::new(ctx.pool());
332
333        let task = task_mgr
334            .add_task("Test task".to_string(), None, None, None, None, None)
335            .await
336            .unwrap();
337        workspace_mgr.set_current_task(task.id, None).await.unwrap();
338
339        // Deleting a focused task must be rejected
340        let err = task_mgr.delete_task(task.id).await.unwrap_err();
341        assert!(
342            matches!(err, crate::error::IntentError::ActionNotAllowed(_)),
343            "expected ActionNotAllowed, got: {:?}",
344            err
345        );
346
347        // Task still exists
348        assert!(task_mgr.get_task(task.id).await.is_ok());
349    }
350
351    #[tokio::test]
352    async fn test_cascade_delete_focused_descendant_is_rejected() {
353        let ctx = TestContext::new().await;
354        let task_mgr = TaskManager::new(ctx.pool());
355        let workspace_mgr = WorkspaceManager::new(ctx.pool());
356
357        let parent = task_mgr
358            .add_task("Parent".to_string(), None, None, None, None, None)
359            .await
360            .unwrap();
361        let child = task_mgr
362            .add_task("Child".to_string(), None, Some(parent.id), None, None, None)
363            .await
364            .unwrap();
365        workspace_mgr
366            .set_current_task(child.id, None)
367            .await
368            .unwrap();
369
370        // Cascade-deleting the parent must be rejected because the child is focused
371        let err = task_mgr.delete_task_cascade(parent.id).await.unwrap_err();
372        assert!(
373            matches!(err, crate::error::IntentError::ActionNotAllowed(_)),
374            "expected ActionNotAllowed, got: {:?}",
375            err
376        );
377
378        // Both tasks still exist
379        assert!(task_mgr.get_task(parent.id).await.is_ok());
380        assert!(task_mgr.get_task(child.id).await.is_ok());
381    }
382
383    #[tokio::test]
384    async fn test_delete_unfocused_task_succeeds() {
385        let ctx = TestContext::new().await;
386        let task_mgr = TaskManager::new(ctx.pool());
387        let workspace_mgr = WorkspaceManager::new(ctx.pool());
388
389        let task = task_mgr
390            .add_task("Test task".to_string(), None, None, None, None, None)
391            .await
392            .unwrap();
393        workspace_mgr.set_current_task(task.id, None).await.unwrap();
394
395        // Unfocus, then delete succeeds
396        workspace_mgr.clear_current_task(None).await.unwrap();
397        task_mgr.delete_task(task.id).await.unwrap();
398
399        // Task is gone
400        assert!(task_mgr.get_task(task.id).await.is_err());
401    }
402
403    #[tokio::test]
404    async fn test_set_current_task_returns_complete_task() {
405        let ctx = TestContext::new().await;
406        let task_mgr = TaskManager::new(ctx.pool());
407        let workspace_mgr = WorkspaceManager::new(ctx.pool());
408
409        let task = task_mgr
410            .add_task(
411                "Test task".to_string(),
412                Some("Task spec".to_string()),
413                None,
414                None,
415                None,
416                None,
417            )
418            .await
419            .unwrap();
420
421        let response = workspace_mgr.set_current_task(task.id, None).await.unwrap();
422
423        // Verify task object is complete
424        let returned_task = response.task.unwrap();
425        assert_eq!(returned_task.id, task.id);
426        assert_eq!(returned_task.name, "Test task");
427        assert_eq!(returned_task.spec, Some("Task spec".to_string()));
428        assert_eq!(returned_task.status, "todo");
429    }
430
431    #[tokio::test]
432    async fn test_set_same_task_multiple_times() {
433        let ctx = TestContext::new().await;
434        let task_mgr = TaskManager::new(ctx.pool());
435        let workspace_mgr = WorkspaceManager::new(ctx.pool());
436
437        let task = task_mgr
438            .add_task("Test task".to_string(), None, None, None, None, None)
439            .await
440            .unwrap();
441
442        // Set the same task multiple times (idempotent)
443        workspace_mgr.set_current_task(task.id, None).await.unwrap();
444        workspace_mgr.set_current_task(task.id, None).await.unwrap();
445        let response = workspace_mgr.set_current_task(task.id, None).await.unwrap();
446
447        assert_eq!(response.current_task_id, Some(task.id));
448    }
449
450    #[tokio::test]
451    async fn test_session_isolation() {
452        let ctx = TestContext::new().await;
453        let task_mgr = TaskManager::new(ctx.pool());
454        let workspace_mgr = WorkspaceManager::new(ctx.pool());
455
456        let task1 = task_mgr
457            .add_task("Task 1".to_string(), None, None, None, None, None)
458            .await
459            .unwrap();
460        let task2 = task_mgr
461            .add_task("Task 2".to_string(), None, None, None, None, None)
462            .await
463            .unwrap();
464
465        // Set different tasks for different sessions
466        workspace_mgr
467            .set_current_task(task1.id, Some("session-a".to_string()))
468            .await
469            .unwrap();
470        workspace_mgr
471            .set_current_task(task2.id, Some("session-b".to_string()))
472            .await
473            .unwrap();
474
475        // Each session should see its own task
476        let response_a = workspace_mgr
477            .get_current_task(Some("session-a".to_string()))
478            .await
479            .unwrap();
480        let response_b = workspace_mgr
481            .get_current_task(Some("session-b".to_string()))
482            .await
483            .unwrap();
484
485        assert_eq!(response_a.current_task_id, Some(task1.id));
486        assert_eq!(response_b.current_task_id, Some(task2.id));
487        assert_eq!(response_a.session_id, Some("session-a".to_string()));
488        assert_eq!(response_b.session_id, Some("session-b".to_string()));
489    }
490
491    #[tokio::test]
492    async fn test_session_upsert() {
493        let ctx = TestContext::new().await;
494        let task_mgr = TaskManager::new(ctx.pool());
495        let workspace_mgr = WorkspaceManager::new(ctx.pool());
496
497        let task1 = task_mgr
498            .add_task("Task 1".to_string(), None, None, None, None, None)
499            .await
500            .unwrap();
501        let task2 = task_mgr
502            .add_task("Task 2".to_string(), None, None, None, None, None)
503            .await
504            .unwrap();
505
506        // Update same session's task
507        workspace_mgr
508            .set_current_task(task1.id, Some("session-x".to_string()))
509            .await
510            .unwrap();
511        workspace_mgr
512            .set_current_task(task2.id, Some("session-x".to_string()))
513            .await
514            .unwrap();
515
516        // Should only have one session entry with the latest task
517        let response = workspace_mgr
518            .get_current_task(Some("session-x".to_string()))
519            .await
520            .unwrap();
521        assert_eq!(response.current_task_id, Some(task2.id));
522
523        // Check only one session row exists
524        let count: i64 =
525            sqlx::query_scalar("SELECT COUNT(*) FROM sessions WHERE session_id = 'session-x'")
526                .fetch_one(ctx.pool())
527                .await
528                .unwrap();
529        assert_eq!(count, 1);
530    }
531
532    #[tokio::test]
533    async fn test_get_current_task_with_changed_status() {
534        let ctx = TestContext::new().await;
535        let task_mgr = TaskManager::new(ctx.pool());
536        let workspace_mgr = WorkspaceManager::new(ctx.pool());
537
538        let task = task_mgr
539            .add_task("Test task".to_string(), None, None, None, None, None)
540            .await
541            .unwrap();
542        workspace_mgr.set_current_task(task.id, None).await.unwrap();
543
544        // Change task status
545        task_mgr.start_task(task.id, false).await.unwrap();
546
547        let response = workspace_mgr.get_current_task(None).await.unwrap();
548
549        // Should reflect updated status
550        assert_eq!(response.task.unwrap().status, "doing");
551    }
552
553    #[tokio::test]
554    async fn test_clear_current_task() {
555        let ctx = TestContext::new().await;
556        let task_mgr = TaskManager::new(ctx.pool());
557        let workspace_mgr = WorkspaceManager::new(ctx.pool());
558
559        let task = task_mgr
560            .add_task("Task".to_string(), None, None, None, None, None)
561            .await
562            .unwrap();
563        workspace_mgr
564            .set_current_task(task.id, Some("test-session".to_string()))
565            .await
566            .unwrap();
567
568        // Clear the current task
569        workspace_mgr
570            .clear_current_task(Some("test-session".to_string()))
571            .await
572            .unwrap();
573
574        let response = workspace_mgr
575            .get_current_task(Some("test-session".to_string()))
576            .await
577            .unwrap();
578        assert!(response.current_task_id.is_none());
579    }
580
581    #[tokio::test]
582    async fn test_cleanup_expired_sessions() {
583        let ctx = TestContext::new().await;
584        let task_mgr = TaskManager::new(ctx.pool());
585        let workspace_mgr = WorkspaceManager::new(ctx.pool());
586
587        let task = task_mgr
588            .add_task("Task".to_string(), None, None, None, None, None)
589            .await
590            .unwrap();
591
592        // Create a session
593        workspace_mgr
594            .set_current_task(task.id, Some("old-session".to_string()))
595            .await
596            .unwrap();
597
598        // Manually set last_active_at to 25 hours ago
599        sqlx::query(
600            "UPDATE sessions SET last_active_at = datetime('now', '-25 hours') WHERE session_id = 'old-session'"
601        )
602        .execute(ctx.pool())
603        .await
604        .unwrap();
605
606        // Create a recent session
607        workspace_mgr
608            .set_current_task(task.id, Some("new-session".to_string()))
609            .await
610            .unwrap();
611
612        // Cleanup sessions older than 24 hours
613        let deleted = workspace_mgr.cleanup_expired_sessions(24).await.unwrap();
614        assert_eq!(deleted, 1);
615
616        // Old session should be gone
617        let response = workspace_mgr
618            .get_current_task(Some("old-session".to_string()))
619            .await
620            .unwrap();
621        assert!(response.current_task_id.is_none());
622
623        // New session should still exist
624        let response = workspace_mgr
625            .get_current_task(Some("new-session".to_string()))
626            .await
627            .unwrap();
628        assert_eq!(response.current_task_id, Some(task.id));
629    }
630
631    #[tokio::test]
632    async fn test_resolve_session_id_priority() {
633        // Test explicit param takes priority
634        assert_eq!(resolve_session_id(Some("explicit")), "explicit");
635
636        // Test empty explicit falls through to env var or default
637        let empty_result = resolve_session_id(Some(""));
638        // When IE_SESSION_ID is set, it uses that; otherwise uses DEFAULT_SESSION_ID
639        if let Ok(env_session) = std::env::var("IE_SESSION_ID") {
640            if !env_session.is_empty() {
641                assert_eq!(empty_result, env_session);
642            } else {
643                assert_eq!(empty_result, DEFAULT_SESSION_ID);
644            }
645        } else {
646            assert_eq!(empty_result, DEFAULT_SESSION_ID);
647        }
648
649        // Test None falls through to default (env var may or may not be set)
650        let result = resolve_session_id(None);
651        // Either uses env var or default
652        assert!(!result.is_empty());
653    }
654}