1use crate::db::models::Task;
2use crate::error::{IntentError, Result};
3use serde::Serialize;
4use sqlx::SqlitePool;
5
6pub const DEFAULT_SESSION_ID: &str = "-1";
8
9pub 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 pub async fn get_current_task(&self, session_id: Option<&str>) -> Result<CurrentTaskResponse> {
47 let session_id = resolve_session_id(session_id);
48
49 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 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 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 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 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 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 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 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 workspace_mgr
226 .set_current_task(task1.id, None)
227 .await
228 .unwrap();
229
230 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 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 let json = serde_json::to_string(&response).unwrap();
285 assert!(json.contains("current_task_id"));
286 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 task_mgr.delete_task(task.id).await.unwrap();
304
305 let response = workspace_mgr.get_current_task(None).await.unwrap();
306
307 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 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 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 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 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 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 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 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 task_mgr.start_task(task.id, false).await.unwrap();
436
437 let response = workspace_mgr.get_current_task(None).await.unwrap();
438
439 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 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 workspace_mgr
478 .set_current_task(task.id, Some("old-session"))
479 .await
480 .unwrap();
481
482 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 workspace_mgr
492 .set_current_task(task.id, Some("new-session"))
493 .await
494 .unwrap();
495
496 let deleted = workspace_mgr.cleanup_expired_sessions(24).await.unwrap();
498 assert_eq!(deleted, 1);
499
500 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 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 assert_eq!(resolve_session_id(Some("explicit")), "explicit");
519
520 let empty_result = resolve_session_id(Some(""));
522 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 let result = resolve_session_id(None);
535 assert!(!result.is_empty());
537 }
538}