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
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)).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
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use crate::tasks::TaskManager;
179 use crate::test_utils::test_helpers::TestContext;
180
181 #[tokio::test]
182 async fn test_get_current_task_none() {
183 let ctx = TestContext::new().await;
184 let workspace_mgr = WorkspaceManager::new(ctx.pool());
185
186 let response = workspace_mgr.get_current_task(None).await.unwrap();
187
188 assert!(response.current_task_id.is_none());
189 assert!(response.task.is_none());
190 }
191
192 #[tokio::test]
193 async fn test_set_current_task() {
194 let ctx = TestContext::new().await;
195 let task_mgr = TaskManager::new(ctx.pool());
196 let workspace_mgr = WorkspaceManager::new(ctx.pool());
197
198 let task = task_mgr
199 .add_task("Test task", None, None, None)
200 .await
201 .unwrap();
202
203 let response = workspace_mgr.set_current_task(task.id, None).await.unwrap();
204
205 assert_eq!(response.current_task_id, Some(task.id));
206 assert!(response.task.is_some());
207 assert_eq!(response.task.unwrap().id, task.id);
208 }
209
210 #[tokio::test]
211 async fn test_set_current_task_nonexistent() {
212 let ctx = TestContext::new().await;
213 let workspace_mgr = WorkspaceManager::new(ctx.pool());
214
215 let result = workspace_mgr.set_current_task(999, None).await;
216 assert!(matches!(result, Err(IntentError::TaskNotFound(999))));
217 }
218
219 #[tokio::test]
220 async fn test_update_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 task1 = task_mgr.add_task("Task 1", None, None, None).await.unwrap();
226 let task2 = task_mgr.add_task("Task 2", None, None, None).await.unwrap();
227
228 workspace_mgr
230 .set_current_task(task1.id, None)
231 .await
232 .unwrap();
233
234 let response = workspace_mgr
236 .set_current_task(task2.id, None)
237 .await
238 .unwrap();
239
240 assert_eq!(response.current_task_id, Some(task2.id));
241 assert_eq!(response.task.unwrap().id, task2.id);
242 }
243
244 #[tokio::test]
245 async fn test_get_current_task_after_set() {
246 let ctx = TestContext::new().await;
247 let task_mgr = TaskManager::new(ctx.pool());
248 let workspace_mgr = WorkspaceManager::new(ctx.pool());
249
250 let task = task_mgr
251 .add_task("Test task", None, None, None)
252 .await
253 .unwrap();
254 workspace_mgr.set_current_task(task.id, None).await.unwrap();
255
256 let response = workspace_mgr.get_current_task(None).await.unwrap();
257
258 assert_eq!(response.current_task_id, Some(task.id));
259 assert!(response.task.is_some());
260 }
261
262 #[tokio::test]
263 async fn test_current_task_response_serialization() {
264 let ctx = TestContext::new().await;
265 let task_mgr = TaskManager::new(ctx.pool());
266 let workspace_mgr = WorkspaceManager::new(ctx.pool());
267
268 let task = task_mgr
269 .add_task("Test task", None, None, None)
270 .await
271 .unwrap();
272 let response = workspace_mgr.set_current_task(task.id, None).await.unwrap();
273
274 let json = serde_json::to_string(&response).unwrap();
276 assert!(json.contains("current_task_id"));
277 assert!(json.contains("task"));
278 }
279
280 #[tokio::test]
281 async fn test_current_task_response_none_serialization() {
282 let ctx = TestContext::new().await;
283 let workspace_mgr = WorkspaceManager::new(ctx.pool());
284
285 let response = workspace_mgr.get_current_task(None).await.unwrap();
286
287 let json = serde_json::to_string(&response).unwrap();
289 assert!(json.contains("current_task_id"));
290 assert!(!json.contains("\"task\""));
292 }
293
294 #[tokio::test]
295 async fn test_get_current_task_with_deleted_task() {
296 let ctx = TestContext::new().await;
297 let task_mgr = TaskManager::new(ctx.pool());
298 let workspace_mgr = WorkspaceManager::new(ctx.pool());
299
300 let task = task_mgr
301 .add_task("Test task", None, None, None)
302 .await
303 .unwrap();
304 workspace_mgr.set_current_task(task.id, None).await.unwrap();
305
306 task_mgr.delete_task(task.id).await.unwrap();
308
309 let response = workspace_mgr.get_current_task(None).await.unwrap();
310
311 assert!(response.current_task_id.is_none());
313 assert!(response.task.is_none());
314 }
315
316 #[tokio::test]
317 async fn test_set_current_task_returns_complete_task() {
318 let ctx = TestContext::new().await;
319 let task_mgr = TaskManager::new(ctx.pool());
320 let workspace_mgr = WorkspaceManager::new(ctx.pool());
321
322 let task = task_mgr
323 .add_task("Test task", Some("Task spec"), None, None)
324 .await
325 .unwrap();
326
327 let response = workspace_mgr.set_current_task(task.id, None).await.unwrap();
328
329 let returned_task = response.task.unwrap();
331 assert_eq!(returned_task.id, task.id);
332 assert_eq!(returned_task.name, "Test task");
333 assert_eq!(returned_task.spec, Some("Task spec".to_string()));
334 assert_eq!(returned_task.status, "todo");
335 }
336
337 #[tokio::test]
338 async fn test_set_same_task_multiple_times() {
339 let ctx = TestContext::new().await;
340 let task_mgr = TaskManager::new(ctx.pool());
341 let workspace_mgr = WorkspaceManager::new(ctx.pool());
342
343 let task = task_mgr
344 .add_task("Test task", None, None, None)
345 .await
346 .unwrap();
347
348 workspace_mgr.set_current_task(task.id, None).await.unwrap();
350 workspace_mgr.set_current_task(task.id, None).await.unwrap();
351 let response = workspace_mgr.set_current_task(task.id, None).await.unwrap();
352
353 assert_eq!(response.current_task_id, Some(task.id));
354 }
355
356 #[tokio::test]
357 async fn test_session_isolation() {
358 let ctx = TestContext::new().await;
359 let task_mgr = TaskManager::new(ctx.pool());
360 let workspace_mgr = WorkspaceManager::new(ctx.pool());
361
362 let task1 = task_mgr.add_task("Task 1", None, None, None).await.unwrap();
363 let task2 = task_mgr.add_task("Task 2", None, None, None).await.unwrap();
364
365 workspace_mgr
367 .set_current_task(task1.id, Some("session-a"))
368 .await
369 .unwrap();
370 workspace_mgr
371 .set_current_task(task2.id, Some("session-b"))
372 .await
373 .unwrap();
374
375 let response_a = workspace_mgr
377 .get_current_task(Some("session-a"))
378 .await
379 .unwrap();
380 let response_b = workspace_mgr
381 .get_current_task(Some("session-b"))
382 .await
383 .unwrap();
384
385 assert_eq!(response_a.current_task_id, Some(task1.id));
386 assert_eq!(response_b.current_task_id, Some(task2.id));
387 assert_eq!(response_a.session_id, Some("session-a".to_string()));
388 assert_eq!(response_b.session_id, Some("session-b".to_string()));
389 }
390
391 #[tokio::test]
392 async fn test_session_upsert() {
393 let ctx = TestContext::new().await;
394 let task_mgr = TaskManager::new(ctx.pool());
395 let workspace_mgr = WorkspaceManager::new(ctx.pool());
396
397 let task1 = task_mgr.add_task("Task 1", None, None, None).await.unwrap();
398 let task2 = task_mgr.add_task("Task 2", None, None, None).await.unwrap();
399
400 workspace_mgr
402 .set_current_task(task1.id, Some("session-x"))
403 .await
404 .unwrap();
405 workspace_mgr
406 .set_current_task(task2.id, Some("session-x"))
407 .await
408 .unwrap();
409
410 let response = workspace_mgr
412 .get_current_task(Some("session-x"))
413 .await
414 .unwrap();
415 assert_eq!(response.current_task_id, Some(task2.id));
416
417 let count: i64 =
419 sqlx::query_scalar("SELECT COUNT(*) FROM sessions WHERE session_id = 'session-x'")
420 .fetch_one(ctx.pool())
421 .await
422 .unwrap();
423 assert_eq!(count, 1);
424 }
425
426 #[tokio::test]
427 async fn test_get_current_task_with_changed_status() {
428 let ctx = TestContext::new().await;
429 let task_mgr = TaskManager::new(ctx.pool());
430 let workspace_mgr = WorkspaceManager::new(ctx.pool());
431
432 let task = task_mgr
433 .add_task("Test task", None, None, None)
434 .await
435 .unwrap();
436 workspace_mgr.set_current_task(task.id, None).await.unwrap();
437
438 task_mgr.start_task(task.id, false).await.unwrap();
440
441 let response = workspace_mgr.get_current_task(None).await.unwrap();
442
443 assert_eq!(response.task.unwrap().status, "doing");
445 }
446
447 #[tokio::test]
448 async fn test_clear_current_task() {
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 task = task_mgr.add_task("Task", None, None, None).await.unwrap();
454 workspace_mgr
455 .set_current_task(task.id, Some("test-session"))
456 .await
457 .unwrap();
458
459 workspace_mgr
461 .clear_current_task(Some("test-session"))
462 .await
463 .unwrap();
464
465 let response = workspace_mgr
466 .get_current_task(Some("test-session"))
467 .await
468 .unwrap();
469 assert!(response.current_task_id.is_none());
470 }
471
472 #[tokio::test]
473 async fn test_cleanup_expired_sessions() {
474 let ctx = TestContext::new().await;
475 let task_mgr = TaskManager::new(ctx.pool());
476 let workspace_mgr = WorkspaceManager::new(ctx.pool());
477
478 let task = task_mgr.add_task("Task", None, None, None).await.unwrap();
479
480 workspace_mgr
482 .set_current_task(task.id, Some("old-session"))
483 .await
484 .unwrap();
485
486 sqlx::query(
488 "UPDATE sessions SET last_active_at = datetime('now', '-25 hours') WHERE session_id = 'old-session'"
489 )
490 .execute(ctx.pool())
491 .await
492 .unwrap();
493
494 workspace_mgr
496 .set_current_task(task.id, Some("new-session"))
497 .await
498 .unwrap();
499
500 let deleted = workspace_mgr.cleanup_expired_sessions(24).await.unwrap();
502 assert_eq!(deleted, 1);
503
504 let response = workspace_mgr
506 .get_current_task(Some("old-session"))
507 .await
508 .unwrap();
509 assert!(response.current_task_id.is_none());
510
511 let response = workspace_mgr
513 .get_current_task(Some("new-session"))
514 .await
515 .unwrap();
516 assert_eq!(response.current_task_id, Some(task.id));
517 }
518
519 #[tokio::test]
520 async fn test_resolve_session_id_priority() {
521 assert_eq!(resolve_session_id(Some("explicit")), "explicit");
523
524 let empty_result = resolve_session_id(Some(""));
526 if let Ok(env_session) = std::env::var("IE_SESSION_ID") {
528 if !env_session.is_empty() {
529 assert_eq!(empty_result, env_session);
530 } else {
531 assert_eq!(empty_result, DEFAULT_SESSION_ID);
532 }
533 } else {
534 assert_eq!(empty_result, DEFAULT_SESSION_ID);
535 }
536
537 let result = resolve_session_id(None);
539 assert!(!result.is_empty());
541 }
542}