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 #[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 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 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 #[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 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 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 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 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 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 workspace_mgr
260 .set_current_task(task1.id, None)
261 .await
262 .unwrap();
263
264 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 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 let json = serde_json::to_string(&response).unwrap();
319 assert!(json.contains("current_task_id"));
320 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 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 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 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 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 workspace_mgr.clear_current_task(None).await.unwrap();
394 task_mgr.delete_task(task.id).await.unwrap();
395
396 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 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 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 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 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 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 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 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 task_mgr.start_task(task.id, false).await.unwrap();
543
544 let response = workspace_mgr.get_current_task(None).await.unwrap();
545
546 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 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 workspace_mgr
591 .set_current_task(task.id, Some("old-session"))
592 .await
593 .unwrap();
594
595 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 workspace_mgr
605 .set_current_task(task.id, Some("new-session"))
606 .await
607 .unwrap();
608
609 let deleted = workspace_mgr.cleanup_expired_sessions(24).await.unwrap();
611 assert_eq!(deleted, 1);
612
613 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 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 assert_eq!(resolve_session_id(Some("explicit")), "explicit");
632
633 let empty_result = resolve_session_id(Some(""));
635 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 let result = resolve_session_id(None);
648 assert!(!result.is_empty());
650 }
651}