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(
48 &self,
49 session_id: Option<String>,
50 ) -> Result<CurrentTaskResponse> {
51 let session_id = resolve_session_id(session_id.as_deref());
52
53 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 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 #[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 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 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 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 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 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 workspace_mgr
263 .set_current_task(task1.id, None)
264 .await
265 .unwrap();
266
267 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 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 let json = serde_json::to_string(&response).unwrap();
322 assert!(json.contains("current_task_id"));
323 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 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 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 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 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 workspace_mgr.clear_current_task(None).await.unwrap();
397 task_mgr.delete_task(task.id).await.unwrap();
398
399 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 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 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 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 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 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 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 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 task_mgr.start_task(task.id, false).await.unwrap();
546
547 let response = workspace_mgr.get_current_task(None).await.unwrap();
548
549 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 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 workspace_mgr
594 .set_current_task(task.id, Some("old-session".to_string()))
595 .await
596 .unwrap();
597
598 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 workspace_mgr
608 .set_current_task(task.id, Some("new-session".to_string()))
609 .await
610 .unwrap();
611
612 let deleted = workspace_mgr.cleanup_expired_sessions(24).await.unwrap();
614 assert_eq!(deleted, 1);
615
616 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 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 assert_eq!(resolve_session_id(Some("explicit")), "explicit");
635
636 let empty_result = resolve_session_id(Some(""));
638 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 let result = resolve_session_id(None);
651 assert!(!result.is_empty());
653 }
654}