use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{Executor, FromRow, Sqlite, SqlitePool, Type};
use strum_macros::{Display, EnumString};
use ts_rs_forge::TS;
use uuid::Uuid;
use super::{project::Project, task_attempt::TaskAttempt};
#[derive(Debug, Clone, Copy, Type, Serialize, Deserialize, PartialEq, TS, EnumString, Display)]
#[sqlx(type_name = "task_status", rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "kebab_case")]
pub enum TaskStatus {
Todo,
InProgress,
InReview,
Done,
Cancelled,
Agent,
Archived,
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)]
pub struct Task {
pub id: Uuid,
pub project_id: Uuid, pub title: String,
pub description: Option<String>,
pub status: TaskStatus,
pub parent_task_attempt: Option<Uuid>, pub dev_server_id: Option<Uuid>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
pub struct TaskWithAttemptStatus {
#[serde(flatten)]
#[ts(flatten)]
pub task: Task,
pub has_in_progress_attempt: bool,
pub has_merged_attempt: bool,
pub last_attempt_failed: bool,
pub executor: String,
pub attempt_count: i64,
}
impl std::ops::Deref for TaskWithAttemptStatus {
type Target = Task;
fn deref(&self) -> &Self::Target {
&self.task
}
}
impl std::ops::DerefMut for TaskWithAttemptStatus {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.task
}
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
pub struct TaskRelationships {
pub parent_task: Option<Task>, pub current_attempt: TaskAttempt, pub children: Vec<Task>, }
#[derive(Debug, Serialize, Deserialize, TS)]
pub struct CreateTask {
pub project_id: Uuid,
pub title: String,
pub description: Option<String>,
pub parent_task_attempt: Option<Uuid>,
pub image_ids: Option<Vec<Uuid>>,
}
impl CreateTask {
pub fn from_title_description(
project_id: Uuid,
title: String,
description: Option<String>,
) -> Self {
Self {
project_id,
title,
description,
parent_task_attempt: None,
image_ids: None,
}
}
}
#[derive(Debug, Serialize, Deserialize, TS)]
pub struct UpdateTask {
pub title: Option<String>,
pub description: Option<String>,
pub status: Option<TaskStatus>,
pub parent_task_attempt: Option<Uuid>,
pub image_ids: Option<Vec<Uuid>>,
}
impl Task {
pub fn to_prompt(&self) -> String {
if let Some(description) = self.description.as_ref().filter(|d| !d.trim().is_empty()) {
format!("{}\n\n{}", &self.title, description)
} else {
self.title.clone()
}
}
pub async fn parent_project(&self, pool: &SqlitePool) -> Result<Option<Project>, sqlx::Error> {
Project::find_by_id(pool, self.project_id).await
}
pub async fn find_by_project_id_with_attempt_status(
pool: &SqlitePool,
project_id: Uuid,
) -> Result<Vec<TaskWithAttemptStatus>, sqlx::Error> {
let records = sqlx::query!(
r#"SELECT
t.id AS "id!: Uuid",
t.project_id AS "project_id!: Uuid",
t.title,
t.description,
t.status AS "status!: TaskStatus",
t.parent_task_attempt AS "parent_task_attempt: Uuid",
t.dev_server_id AS "dev_server_id: Uuid",
t.created_at AS "created_at!: DateTime<Utc>",
t.updated_at AS "updated_at!: DateTime<Utc>",
CASE WHEN EXISTS (
SELECT 1
FROM task_attempts ta
JOIN execution_processes ep
ON ep.task_attempt_id = ta.id
WHERE ta.task_id = t.id
AND ep.status = 'running'
AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')
LIMIT 1
) THEN 1 ELSE 0 END AS "has_in_progress_attempt!: i64",
CASE WHEN (
SELECT ep.status
FROM task_attempts ta
JOIN execution_processes ep
ON ep.task_attempt_id = ta.id
WHERE ta.task_id = t.id
AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')
ORDER BY ep.created_at DESC
LIMIT 1
) IN ('failed','killed') THEN 1 ELSE 0 END
AS "last_attempt_failed!: i64",
( SELECT ta.executor
FROM task_attempts ta
WHERE ta.task_id = t.id
ORDER BY ta.created_at DESC
LIMIT 1
) AS "executor!: String",
(SELECT COUNT(*) FROM task_attempts WHERE task_id = t.id) AS "attempt_count!: i64"
FROM tasks t
WHERE t.project_id = $1
ORDER BY t.created_at DESC"#,
project_id
)
.fetch_all(pool)
.await?;
let tasks = records
.into_iter()
.map(|rec| TaskWithAttemptStatus {
task: Task {
id: rec.id,
project_id: rec.project_id,
title: rec.title,
description: rec.description,
status: rec.status,
parent_task_attempt: rec.parent_task_attempt,
dev_server_id: rec.dev_server_id,
created_at: rec.created_at,
updated_at: rec.updated_at,
},
has_in_progress_attempt: rec.has_in_progress_attempt != 0,
has_merged_attempt: false, last_attempt_failed: rec.last_attempt_failed != 0,
executor: rec.executor,
attempt_count: rec.attempt_count,
})
.collect();
Ok(tasks)
}
pub async fn find_by_id_with_attempt_status(
pool: &SqlitePool,
task_id: Uuid,
) -> Result<Option<TaskWithAttemptStatus>, sqlx::Error> {
let rec = sqlx::query!(
r#"SELECT
t.id AS "id!: Uuid",
t.project_id AS "project_id!: Uuid",
t.title,
t.description,
t.status AS "status!: TaskStatus",
t.parent_task_attempt AS "parent_task_attempt: Uuid",
t.dev_server_id AS "dev_server_id: Uuid",
t.created_at AS "created_at!: DateTime<Utc>",
t.updated_at AS "updated_at!: DateTime<Utc>",
CASE WHEN EXISTS (
SELECT 1
FROM task_attempts ta
JOIN execution_processes ep
ON ep.task_attempt_id = ta.id
WHERE ta.task_id = t.id
AND ep.status = 'running'
AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')
LIMIT 1
) THEN 1 ELSE 0 END AS "has_in_progress_attempt!: i64",
CASE WHEN (
SELECT ep.status
FROM task_attempts ta
JOIN execution_processes ep
ON ep.task_attempt_id = ta.id
WHERE ta.task_id = t.id
AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')
ORDER BY ep.created_at DESC
LIMIT 1
) IN ('failed','killed') THEN 1 ELSE 0 END
AS "last_attempt_failed!: i64",
( SELECT ta.executor
FROM task_attempts ta
WHERE ta.task_id = t.id
ORDER BY ta.created_at DESC
LIMIT 1
) AS "executor!: String",
(SELECT COUNT(*) FROM task_attempts WHERE task_id = t.id) AS "attempt_count!: i64"
FROM tasks t
WHERE t.id = $1"#,
task_id
)
.fetch_optional(pool)
.await?;
Ok(rec.map(|rec| TaskWithAttemptStatus {
task: Task {
id: rec.id,
project_id: rec.project_id,
title: rec.title,
description: rec.description,
status: rec.status,
parent_task_attempt: rec.parent_task_attempt,
dev_server_id: rec.dev_server_id,
created_at: rec.created_at,
updated_at: rec.updated_at,
},
has_in_progress_attempt: rec.has_in_progress_attempt != 0,
has_merged_attempt: false,
last_attempt_failed: rec.last_attempt_failed != 0,
executor: rec.executor,
attempt_count: rec.attempt_count,
}))
}
pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as!(
Task,
r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_task_attempt as "parent_task_attempt: Uuid", dev_server_id as "dev_server_id: Uuid", created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>"
FROM tasks
WHERE id = $1"#,
id
)
.fetch_optional(pool)
.await
}
pub async fn find_by_rowid(pool: &SqlitePool, rowid: i64) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as!(
Task,
r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_task_attempt as "parent_task_attempt: Uuid", dev_server_id as "dev_server_id: Uuid", created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>"
FROM tasks
WHERE rowid = $1"#,
rowid
)
.fetch_optional(pool)
.await
}
pub async fn find_by_id_and_project_id(
pool: &SqlitePool,
id: Uuid,
project_id: Uuid,
) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as!(
Task,
r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_task_attempt as "parent_task_attempt: Uuid", dev_server_id as "dev_server_id: Uuid", created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>"
FROM tasks
WHERE id = $1 AND project_id = $2"#,
id,
project_id
)
.fetch_optional(pool)
.await
}
pub async fn create(
pool: &SqlitePool,
data: &CreateTask,
task_id: Uuid,
) -> Result<Self, sqlx::Error> {
Self::create_with_status(pool, data, task_id, TaskStatus::Todo).await
}
pub async fn create_with_status(
pool: &SqlitePool,
data: &CreateTask,
task_id: Uuid,
status: TaskStatus,
) -> Result<Self, sqlx::Error> {
sqlx::query_as!(
Task,
r#"INSERT INTO tasks (id, project_id, title, description, status, parent_task_attempt)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_task_attempt as "parent_task_attempt: Uuid", dev_server_id as "dev_server_id: Uuid", created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>""#,
task_id,
data.project_id,
data.title,
data.description,
status,
data.parent_task_attempt
)
.fetch_one(pool)
.await
}
pub async fn update(
pool: &SqlitePool,
id: Uuid,
project_id: Uuid,
title: String,
description: Option<String>,
status: TaskStatus,
parent_task_attempt: Option<Uuid>,
) -> Result<Self, sqlx::Error> {
sqlx::query_as!(
Task,
r#"UPDATE tasks
SET title = $3, description = $4, status = $5, parent_task_attempt = $6
WHERE id = $1 AND project_id = $2
RETURNING id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_task_attempt as "parent_task_attempt: Uuid", dev_server_id as "dev_server_id: Uuid", created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>""#,
id,
project_id,
title,
description,
status,
parent_task_attempt
)
.fetch_one(pool)
.await
}
pub async fn update_status(
pool: &SqlitePool,
id: Uuid,
status: TaskStatus,
) -> Result<(), sqlx::Error> {
sqlx::query!(
"UPDATE tasks SET status = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1",
id,
status
)
.execute(pool)
.await?;
Ok(())
}
pub async fn nullify_children_by_attempt_id<'e, E>(
executor: E,
attempt_id: Uuid,
) -> Result<u64, sqlx::Error>
where
E: Executor<'e, Database = Sqlite>,
{
let result = sqlx::query!(
"UPDATE tasks SET parent_task_attempt = NULL WHERE parent_task_attempt = $1",
attempt_id
)
.execute(executor)
.await?;
Ok(result.rows_affected())
}
pub async fn delete<'e, E>(executor: E, id: Uuid) -> Result<u64, sqlx::Error>
where
E: Executor<'e, Database = Sqlite>,
{
let result = sqlx::query!("DELETE FROM tasks WHERE id = $1", id)
.execute(executor)
.await?;
Ok(result.rows_affected())
}
pub async fn exists(
pool: &SqlitePool,
id: Uuid,
project_id: Uuid,
) -> Result<bool, sqlx::Error> {
let result = sqlx::query!(
"SELECT id as \"id!: Uuid\" FROM tasks WHERE id = $1 AND project_id = $2",
id,
project_id
)
.fetch_optional(pool)
.await?;
Ok(result.is_some())
}
pub async fn find_children_by_attempt_id(
pool: &SqlitePool,
attempt_id: Uuid,
) -> Result<Vec<Self>, sqlx::Error> {
sqlx::query_as!(
Task,
r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_task_attempt as "parent_task_attempt: Uuid", dev_server_id as "dev_server_id: Uuid", created_at as "created_at!: DateTime<Utc>", updated_at as "updated_at!: DateTime<Utc>"
FROM tasks
WHERE parent_task_attempt = $1
ORDER BY created_at DESC"#,
attempt_id,
)
.fetch_all(pool)
.await
}
pub async fn find_relationships_for_attempt(
pool: &SqlitePool,
task_attempt: &TaskAttempt,
) -> Result<TaskRelationships, sqlx::Error> {
let current_task = Self::find_by_id(pool, task_attempt.task_id)
.await?
.ok_or(sqlx::Error::RowNotFound)?;
let parent_task = if let Some(parent_attempt_id) = current_task.parent_task_attempt {
if let Ok(Some(parent_attempt)) = TaskAttempt::find_by_id(pool, parent_attempt_id).await
{
Self::find_by_id(pool, parent_attempt.task_id).await?
} else {
None
}
} else {
None
};
let children = Self::find_children_by_attempt_id(pool, task_attempt.id).await?;
Ok(TaskRelationships {
parent_task,
current_attempt: task_attempt.clone(),
children,
})
}
}