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