use crate::db::models::Task;
use crate::error::{IntentError, Result};
use serde::Serialize;
use sqlx::SqlitePool;
pub const DEFAULT_SESSION_ID: &str = "-1";
pub fn resolve_session_id(explicit: Option<&str>) -> String {
if let Some(s) = explicit {
if !s.is_empty() {
return s.to_string();
}
}
if let Ok(s) = std::env::var("IE_SESSION_ID") {
if !s.is_empty() {
return s;
}
}
DEFAULT_SESSION_ID.to_string()
}
#[derive(Debug, Serialize)]
pub struct CurrentTaskResponse {
pub current_task_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub task: Option<Task>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
}
pub struct WorkspaceManager<'a> {
pool: &'a SqlitePool,
}
impl<'a> WorkspaceManager<'a> {
pub fn new(pool: &'a SqlitePool) -> Self {
Self { pool }
}
#[tracing::instrument(skip(self))]
pub async fn get_current_task(
&self,
session_id: Option<String>,
) -> Result<CurrentTaskResponse> {
let session_id = resolve_session_id(session_id.as_deref());
let current_task_id: Option<i64> = sqlx::query_scalar::<_, Option<i64>>(
"SELECT current_task_id FROM sessions WHERE session_id = ?",
)
.bind(&session_id)
.fetch_optional(self.pool)
.await?
.flatten();
if current_task_id.is_some() {
sqlx::query(
"UPDATE sessions SET last_active_at = datetime('now') WHERE session_id = ?",
)
.bind(&session_id)
.execute(self.pool)
.await?;
}
let task = if let Some(id) = current_task_id {
sqlx::query_as::<_, Task>(
r#"
SELECT id, parent_id, name, spec, status, complexity, priority, first_todo_at, first_doing_at, first_done_at, active_form, owner, metadata
FROM tasks
WHERE id = ?
"#,
)
.bind(id)
.fetch_optional(self.pool)
.await?
} else {
None
};
Ok(CurrentTaskResponse {
current_task_id,
task,
session_id: Some(session_id),
})
}
#[tracing::instrument(skip(self))]
pub async fn set_current_task(
&self,
task_id: i64,
session_id: Option<String>,
) -> Result<CurrentTaskResponse> {
let session_id = resolve_session_id(session_id.as_deref());
let task_exists: bool =
sqlx::query_scalar::<_, bool>(crate::sql_constants::CHECK_TASK_EXISTS)
.bind(task_id)
.fetch_one(self.pool)
.await?;
if !task_exists {
return Err(IntentError::TaskNotFound(task_id));
}
sqlx::query(
r#"
INSERT INTO sessions (session_id, current_task_id, created_at, last_active_at)
VALUES (?, ?, datetime('now'), datetime('now'))
ON CONFLICT(session_id) DO UPDATE SET
current_task_id = excluded.current_task_id,
last_active_at = datetime('now')
"#,
)
.bind(&session_id)
.bind(task_id)
.execute(self.pool)
.await?;
self.get_current_task(Some(session_id)).await
}
pub async fn clear_current_task(&self, session_id: Option<String>) -> Result<()> {
let session_id = resolve_session_id(session_id.as_deref());
sqlx::query(
"UPDATE sessions SET current_task_id = NULL, last_active_at = datetime('now') WHERE session_id = ?"
)
.bind(&session_id)
.execute(self.pool)
.await?;
Ok(())
}
pub async fn cleanup_expired_sessions(&self, hours: u32) -> Result<u64> {
let result = sqlx::query(&format!(
"DELETE FROM sessions WHERE last_active_at < datetime('now', '-{} hours')",
hours
))
.execute(self.pool)
.await?;
Ok(result.rows_affected())
}
pub async fn enforce_session_limit(&self, max_sessions: u32) -> Result<u64> {
let result = sqlx::query(
r#"
DELETE FROM sessions
WHERE session_id IN (
SELECT session_id FROM sessions
ORDER BY last_active_at DESC
LIMIT -1 OFFSET ?
)
"#,
)
.bind(max_sessions)
.execute(self.pool)
.await?;
Ok(result.rows_affected())
}
}
impl crate::backend::WorkspaceBackend for WorkspaceManager<'_> {
fn get_current_task(
&self,
session_id: Option<String>,
) -> impl std::future::Future<Output = Result<CurrentTaskResponse>> + Send {
self.get_current_task(session_id)
}
fn set_current_task(
&self,
task_id: i64,
session_id: Option<String>,
) -> impl std::future::Future<Output = Result<CurrentTaskResponse>> + Send {
self.set_current_task(task_id, session_id)
}
fn clear_current_task(
&self,
session_id: Option<String>,
) -> impl std::future::Future<Output = Result<()>> + Send {
self.clear_current_task(session_id)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tasks::TaskManager;
use crate::test_utils::test_helpers::TestContext;
#[tokio::test]
async fn test_get_current_task_none() {
let ctx = TestContext::new().await;
let workspace_mgr = WorkspaceManager::new(ctx.pool());
let response = workspace_mgr.get_current_task(None).await.unwrap();
assert!(response.current_task_id.is_none());
assert!(response.task.is_none());
}
#[tokio::test]
async fn test_set_current_task() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let workspace_mgr = WorkspaceManager::new(ctx.pool());
let task = task_mgr
.add_task("Test task".to_string(), None, None, None, None, None)
.await
.unwrap();
let response = workspace_mgr.set_current_task(task.id, None).await.unwrap();
assert_eq!(response.current_task_id, Some(task.id));
assert!(response.task.is_some());
assert_eq!(response.task.unwrap().id, task.id);
}
#[tokio::test]
async fn test_set_current_task_nonexistent() {
let ctx = TestContext::new().await;
let workspace_mgr = WorkspaceManager::new(ctx.pool());
let result = workspace_mgr.set_current_task(999, None).await;
assert!(matches!(result, Err(IntentError::TaskNotFound(999))));
}
#[tokio::test]
async fn test_update_current_task() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let workspace_mgr = WorkspaceManager::new(ctx.pool());
let task1 = task_mgr
.add_task("Task 1".to_string(), None, None, None, None, None)
.await
.unwrap();
let task2 = task_mgr
.add_task("Task 2".to_string(), None, None, None, None, None)
.await
.unwrap();
workspace_mgr
.set_current_task(task1.id, None)
.await
.unwrap();
let response = workspace_mgr
.set_current_task(task2.id, None)
.await
.unwrap();
assert_eq!(response.current_task_id, Some(task2.id));
assert_eq!(response.task.unwrap().id, task2.id);
}
#[tokio::test]
async fn test_get_current_task_after_set() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let workspace_mgr = WorkspaceManager::new(ctx.pool());
let task = task_mgr
.add_task("Test task".to_string(), None, None, None, None, None)
.await
.unwrap();
workspace_mgr.set_current_task(task.id, None).await.unwrap();
let response = workspace_mgr.get_current_task(None).await.unwrap();
assert_eq!(response.current_task_id, Some(task.id));
assert!(response.task.is_some());
}
#[tokio::test]
async fn test_current_task_response_serialization() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let workspace_mgr = WorkspaceManager::new(ctx.pool());
let task = task_mgr
.add_task("Test task".to_string(), None, None, None, None, None)
.await
.unwrap();
let response = workspace_mgr.set_current_task(task.id, None).await.unwrap();
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains("current_task_id"));
assert!(json.contains("task"));
}
#[tokio::test]
async fn test_current_task_response_none_serialization() {
let ctx = TestContext::new().await;
let workspace_mgr = WorkspaceManager::new(ctx.pool());
let response = workspace_mgr.get_current_task(None).await.unwrap();
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains("current_task_id"));
assert!(!json.contains("\"task\""));
}
#[tokio::test]
async fn test_delete_focused_task_is_rejected() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let workspace_mgr = WorkspaceManager::new(ctx.pool());
let task = task_mgr
.add_task("Test task".to_string(), None, None, None, None, None)
.await
.unwrap();
workspace_mgr.set_current_task(task.id, None).await.unwrap();
let err = task_mgr.delete_task(task.id).await.unwrap_err();
assert!(
matches!(err, crate::error::IntentError::ActionNotAllowed(_)),
"expected ActionNotAllowed, got: {:?}",
err
);
assert!(task_mgr.get_task(task.id).await.is_ok());
}
#[tokio::test]
async fn test_cascade_delete_focused_descendant_is_rejected() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let workspace_mgr = WorkspaceManager::new(ctx.pool());
let parent = task_mgr
.add_task("Parent".to_string(), None, None, None, None, None)
.await
.unwrap();
let child = task_mgr
.add_task("Child".to_string(), None, Some(parent.id), None, None, None)
.await
.unwrap();
workspace_mgr
.set_current_task(child.id, None)
.await
.unwrap();
let err = task_mgr.delete_task_cascade(parent.id).await.unwrap_err();
assert!(
matches!(err, crate::error::IntentError::ActionNotAllowed(_)),
"expected ActionNotAllowed, got: {:?}",
err
);
assert!(task_mgr.get_task(parent.id).await.is_ok());
assert!(task_mgr.get_task(child.id).await.is_ok());
}
#[tokio::test]
async fn test_delete_unfocused_task_succeeds() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let workspace_mgr = WorkspaceManager::new(ctx.pool());
let task = task_mgr
.add_task("Test task".to_string(), None, None, None, None, None)
.await
.unwrap();
workspace_mgr.set_current_task(task.id, None).await.unwrap();
workspace_mgr.clear_current_task(None).await.unwrap();
task_mgr.delete_task(task.id).await.unwrap();
assert!(task_mgr.get_task(task.id).await.is_err());
}
#[tokio::test]
async fn test_set_current_task_returns_complete_task() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let workspace_mgr = WorkspaceManager::new(ctx.pool());
let task = task_mgr
.add_task(
"Test task".to_string(),
Some("Task spec".to_string()),
None,
None,
None,
None,
)
.await
.unwrap();
let response = workspace_mgr.set_current_task(task.id, None).await.unwrap();
let returned_task = response.task.unwrap();
assert_eq!(returned_task.id, task.id);
assert_eq!(returned_task.name, "Test task");
assert_eq!(returned_task.spec, Some("Task spec".to_string()));
assert_eq!(returned_task.status, "todo");
}
#[tokio::test]
async fn test_set_same_task_multiple_times() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let workspace_mgr = WorkspaceManager::new(ctx.pool());
let task = task_mgr
.add_task("Test task".to_string(), None, None, None, None, None)
.await
.unwrap();
workspace_mgr.set_current_task(task.id, None).await.unwrap();
workspace_mgr.set_current_task(task.id, None).await.unwrap();
let response = workspace_mgr.set_current_task(task.id, None).await.unwrap();
assert_eq!(response.current_task_id, Some(task.id));
}
#[tokio::test]
async fn test_session_isolation() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let workspace_mgr = WorkspaceManager::new(ctx.pool());
let task1 = task_mgr
.add_task("Task 1".to_string(), None, None, None, None, None)
.await
.unwrap();
let task2 = task_mgr
.add_task("Task 2".to_string(), None, None, None, None, None)
.await
.unwrap();
workspace_mgr
.set_current_task(task1.id, Some("session-a".to_string()))
.await
.unwrap();
workspace_mgr
.set_current_task(task2.id, Some("session-b".to_string()))
.await
.unwrap();
let response_a = workspace_mgr
.get_current_task(Some("session-a".to_string()))
.await
.unwrap();
let response_b = workspace_mgr
.get_current_task(Some("session-b".to_string()))
.await
.unwrap();
assert_eq!(response_a.current_task_id, Some(task1.id));
assert_eq!(response_b.current_task_id, Some(task2.id));
assert_eq!(response_a.session_id, Some("session-a".to_string()));
assert_eq!(response_b.session_id, Some("session-b".to_string()));
}
#[tokio::test]
async fn test_session_upsert() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let workspace_mgr = WorkspaceManager::new(ctx.pool());
let task1 = task_mgr
.add_task("Task 1".to_string(), None, None, None, None, None)
.await
.unwrap();
let task2 = task_mgr
.add_task("Task 2".to_string(), None, None, None, None, None)
.await
.unwrap();
workspace_mgr
.set_current_task(task1.id, Some("session-x".to_string()))
.await
.unwrap();
workspace_mgr
.set_current_task(task2.id, Some("session-x".to_string()))
.await
.unwrap();
let response = workspace_mgr
.get_current_task(Some("session-x".to_string()))
.await
.unwrap();
assert_eq!(response.current_task_id, Some(task2.id));
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM sessions WHERE session_id = 'session-x'")
.fetch_one(ctx.pool())
.await
.unwrap();
assert_eq!(count, 1);
}
#[tokio::test]
async fn test_get_current_task_with_changed_status() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let workspace_mgr = WorkspaceManager::new(ctx.pool());
let task = task_mgr
.add_task("Test task".to_string(), None, None, None, None, None)
.await
.unwrap();
workspace_mgr.set_current_task(task.id, None).await.unwrap();
task_mgr.start_task(task.id, false).await.unwrap();
let response = workspace_mgr.get_current_task(None).await.unwrap();
assert_eq!(response.task.unwrap().status, "doing");
}
#[tokio::test]
async fn test_clear_current_task() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let workspace_mgr = WorkspaceManager::new(ctx.pool());
let task = task_mgr
.add_task("Task".to_string(), None, None, None, None, None)
.await
.unwrap();
workspace_mgr
.set_current_task(task.id, Some("test-session".to_string()))
.await
.unwrap();
workspace_mgr
.clear_current_task(Some("test-session".to_string()))
.await
.unwrap();
let response = workspace_mgr
.get_current_task(Some("test-session".to_string()))
.await
.unwrap();
assert!(response.current_task_id.is_none());
}
#[tokio::test]
async fn test_cleanup_expired_sessions() {
let ctx = TestContext::new().await;
let task_mgr = TaskManager::new(ctx.pool());
let workspace_mgr = WorkspaceManager::new(ctx.pool());
let task = task_mgr
.add_task("Task".to_string(), None, None, None, None, None)
.await
.unwrap();
workspace_mgr
.set_current_task(task.id, Some("old-session".to_string()))
.await
.unwrap();
sqlx::query(
"UPDATE sessions SET last_active_at = datetime('now', '-25 hours') WHERE session_id = 'old-session'"
)
.execute(ctx.pool())
.await
.unwrap();
workspace_mgr
.set_current_task(task.id, Some("new-session".to_string()))
.await
.unwrap();
let deleted = workspace_mgr.cleanup_expired_sessions(24).await.unwrap();
assert_eq!(deleted, 1);
let response = workspace_mgr
.get_current_task(Some("old-session".to_string()))
.await
.unwrap();
assert!(response.current_task_id.is_none());
let response = workspace_mgr
.get_current_task(Some("new-session".to_string()))
.await
.unwrap();
assert_eq!(response.current_task_id, Some(task.id));
}
#[tokio::test]
async fn test_resolve_session_id_priority() {
assert_eq!(resolve_session_id(Some("explicit")), "explicit");
let empty_result = resolve_session_id(Some(""));
if let Ok(env_session) = std::env::var("IE_SESSION_ID") {
if !env_session.is_empty() {
assert_eq!(empty_result, env_session);
} else {
assert_eq!(empty_result, DEFAULT_SESSION_ID);
}
} else {
assert_eq!(empty_result, DEFAULT_SESSION_ID);
}
let result = resolve_session_id(None);
assert!(!result.is_empty());
}
}