1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use sqlx::{Executor, FromRow, Sqlite, SqlitePool, Type};
4use strum_macros::{Display, EnumString};
5use ts_rs_forge::TS;
6use uuid::Uuid;
7
8use super::{project::Project, task_attempt::TaskAttempt};
9
10#[derive(Debug, Clone, Copy, Type, Serialize, Deserialize, PartialEq, TS, EnumString, Display)]
11#[sqlx(type_name = "task_status", rename_all = "lowercase")]
12#[serde(rename_all = "lowercase")]
13#[strum(serialize_all = "kebab_case")]
14pub enum TaskStatus {
15 Todo,
16 InProgress,
17 InReview,
18 Done,
19 Cancelled,
20 Agent,
21 Archived,
22}
23
24#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)]
25pub struct Task {
26 pub id: Uuid,
27 pub project_id: Uuid, pub title: String,
29 pub description: Option<String>,
30 pub status: TaskStatus,
31 pub parent_task_attempt: Option<Uuid>, pub dev_server_id: Option<Uuid>, pub created_at: DateTime<Utc>,
34 pub updated_at: DateTime<Utc>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, TS)]
38pub struct TaskWithAttemptStatus {
39 #[serde(flatten)]
40 #[ts(flatten)]
41 pub task: Task,
42 pub has_in_progress_attempt: bool,
43 pub has_merged_attempt: bool,
44 pub last_attempt_failed: bool,
45 pub executor: String,
46 pub attempt_count: i64,
47}
48
49impl std::ops::Deref for TaskWithAttemptStatus {
50 type Target = Task;
51 fn deref(&self) -> &Self::Target {
52 &self.task
53 }
54}
55
56impl std::ops::DerefMut for TaskWithAttemptStatus {
57 fn deref_mut(&mut self) -> &mut Self::Target {
58 &mut self.task
59 }
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, TS)]
63pub struct TaskRelationships {
64 pub parent_task: Option<Task>, pub current_attempt: TaskAttempt, pub children: Vec<Task>, }
68
69#[derive(Debug, Serialize, Deserialize, TS)]
70pub struct CreateTask {
71 pub project_id: Uuid,
72 pub title: String,
73 pub description: Option<String>,
74 pub parent_task_attempt: Option<Uuid>,
75 pub image_ids: Option<Vec<Uuid>>,
76}
77
78impl CreateTask {
79 pub fn from_title_description(
80 project_id: Uuid,
81 title: String,
82 description: Option<String>,
83 ) -> Self {
84 Self {
85 project_id,
86 title,
87 description,
88 parent_task_attempt: None,
89 image_ids: None,
90 }
91 }
92}
93
94#[derive(Debug, Serialize, Deserialize, TS)]
95pub struct UpdateTask {
96 pub title: Option<String>,
97 pub description: Option<String>,
98 pub status: Option<TaskStatus>,
99 pub parent_task_attempt: Option<Uuid>,
100 pub image_ids: Option<Vec<Uuid>>,
101}
102
103impl Task {
104 pub fn to_prompt(&self) -> String {
105 if let Some(description) = self.description.as_ref().filter(|d| !d.trim().is_empty()) {
106 format!("{}\n\n{}", &self.title, description)
107 } else {
108 self.title.clone()
109 }
110 }
111
112 pub async fn parent_project(&self, pool: &SqlitePool) -> Result<Option<Project>, sqlx::Error> {
113 Project::find_by_id(pool, self.project_id).await
114 }
115
116 pub async fn find_by_project_id_with_attempt_status(
117 pool: &SqlitePool,
118 project_id: Uuid,
119 ) -> Result<Vec<TaskWithAttemptStatus>, sqlx::Error> {
120 let records = sqlx::query!(
121 r#"SELECT
122 t.id AS "id!: Uuid",
123 t.project_id AS "project_id!: Uuid",
124 t.title,
125 t.description,
126 t.status AS "status!: TaskStatus",
127 t.parent_task_attempt AS "parent_task_attempt: Uuid",
128 t.dev_server_id AS "dev_server_id: Uuid",
129 t.created_at AS "created_at!: DateTime<Utc>",
130 t.updated_at AS "updated_at!: DateTime<Utc>",
131
132 CASE WHEN EXISTS (
133 SELECT 1
134 FROM task_attempts ta
135 JOIN execution_processes ep
136 ON ep.task_attempt_id = ta.id
137 WHERE ta.task_id = t.id
138 AND ep.status = 'running'
139 AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')
140 LIMIT 1
141 ) THEN 1 ELSE 0 END AS "has_in_progress_attempt!: i64",
142
143 CASE WHEN (
144 SELECT ep.status
145 FROM task_attempts ta
146 JOIN execution_processes ep
147 ON ep.task_attempt_id = ta.id
148 WHERE ta.task_id = t.id
149 AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')
150 ORDER BY ep.created_at DESC
151 LIMIT 1
152 ) IN ('failed','killed') THEN 1 ELSE 0 END
153 AS "last_attempt_failed!: i64",
154
155 ( SELECT ta.executor
156 FROM task_attempts ta
157 WHERE ta.task_id = t.id
158 ORDER BY ta.created_at DESC
159 LIMIT 1
160 ) AS "executor!: String",
161
162 (SELECT COUNT(*) FROM task_attempts WHERE task_id = t.id) AS "attempt_count!: i64"
163
164FROM tasks t
165WHERE t.project_id = $1
166ORDER BY t.created_at DESC"#,
167 project_id
168 )
169 .fetch_all(pool)
170 .await?;
171
172 let tasks = records
173 .into_iter()
174 .map(|rec| TaskWithAttemptStatus {
175 task: Task {
176 id: rec.id,
177 project_id: rec.project_id,
178 title: rec.title,
179 description: rec.description,
180 status: rec.status,
181 parent_task_attempt: rec.parent_task_attempt,
182 dev_server_id: rec.dev_server_id,
183 created_at: rec.created_at,
184 updated_at: rec.updated_at,
185 },
186 has_in_progress_attempt: rec.has_in_progress_attempt != 0,
187 has_merged_attempt: false, last_attempt_failed: rec.last_attempt_failed != 0,
189 executor: rec.executor,
190 attempt_count: rec.attempt_count,
191 })
192 .collect();
193
194 Ok(tasks)
195 }
196
197 pub async fn find_by_id_with_attempt_status(
198 pool: &SqlitePool,
199 task_id: Uuid,
200 ) -> Result<Option<TaskWithAttemptStatus>, sqlx::Error> {
201 let rec = sqlx::query!(
202 r#"SELECT
203 t.id AS "id!: Uuid",
204 t.project_id AS "project_id!: Uuid",
205 t.title,
206 t.description,
207 t.status AS "status!: TaskStatus",
208 t.parent_task_attempt AS "parent_task_attempt: Uuid",
209 t.dev_server_id AS "dev_server_id: Uuid",
210 t.created_at AS "created_at!: DateTime<Utc>",
211 t.updated_at AS "updated_at!: DateTime<Utc>",
212
213 CASE WHEN EXISTS (
214 SELECT 1
215 FROM task_attempts ta
216 JOIN execution_processes ep
217 ON ep.task_attempt_id = ta.id
218 WHERE ta.task_id = t.id
219 AND ep.status = 'running'
220 AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')
221 LIMIT 1
222 ) THEN 1 ELSE 0 END AS "has_in_progress_attempt!: i64",
223
224 CASE WHEN (
225 SELECT ep.status
226 FROM task_attempts ta
227 JOIN execution_processes ep
228 ON ep.task_attempt_id = ta.id
229 WHERE ta.task_id = t.id
230 AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')
231 ORDER BY ep.created_at DESC
232 LIMIT 1
233 ) IN ('failed','killed') THEN 1 ELSE 0 END
234 AS "last_attempt_failed!: i64",
235
236 ( SELECT ta.executor
237 FROM task_attempts ta
238 WHERE ta.task_id = t.id
239 ORDER BY ta.created_at DESC
240 LIMIT 1
241 ) AS "executor!: String",
242
243 (SELECT COUNT(*) FROM task_attempts WHERE task_id = t.id) AS "attempt_count!: i64"
244
245FROM tasks t
246WHERE t.id = $1"#,
247 task_id
248 )
249 .fetch_optional(pool)
250 .await?;
251
252 Ok(rec.map(|rec| TaskWithAttemptStatus {
253 task: Task {
254 id: rec.id,
255 project_id: rec.project_id,
256 title: rec.title,
257 description: rec.description,
258 status: rec.status,
259 parent_task_attempt: rec.parent_task_attempt,
260 dev_server_id: rec.dev_server_id,
261 created_at: rec.created_at,
262 updated_at: rec.updated_at,
263 },
264 has_in_progress_attempt: rec.has_in_progress_attempt != 0,
265 has_merged_attempt: false,
266 last_attempt_failed: rec.last_attempt_failed != 0,
267 executor: rec.executor,
268 attempt_count: rec.attempt_count,
269 }))
270 }
271
272 pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
273 sqlx::query_as!(
274 Task,
275 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>"
276 FROM tasks
277 WHERE id = $1"#,
278 id
279 )
280 .fetch_optional(pool)
281 .await
282 }
283
284 pub async fn find_by_rowid(pool: &SqlitePool, rowid: i64) -> Result<Option<Self>, sqlx::Error> {
285 sqlx::query_as!(
286 Task,
287 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>"
288 FROM tasks
289 WHERE rowid = $1"#,
290 rowid
291 )
292 .fetch_optional(pool)
293 .await
294 }
295
296 pub async fn find_by_id_and_project_id(
297 pool: &SqlitePool,
298 id: Uuid,
299 project_id: Uuid,
300 ) -> Result<Option<Self>, sqlx::Error> {
301 sqlx::query_as!(
302 Task,
303 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>"
304 FROM tasks
305 WHERE id = $1 AND project_id = $2"#,
306 id,
307 project_id
308 )
309 .fetch_optional(pool)
310 .await
311 }
312
313 pub async fn create(
314 pool: &SqlitePool,
315 data: &CreateTask,
316 task_id: Uuid,
317 ) -> Result<Self, sqlx::Error> {
318 Self::create_with_status(pool, data, task_id, TaskStatus::Todo).await
319 }
320
321 pub async fn create_with_status(
326 pool: &SqlitePool,
327 data: &CreateTask,
328 task_id: Uuid,
329 status: TaskStatus,
330 ) -> Result<Self, sqlx::Error> {
331 sqlx::query_as!(
332 Task,
333 r#"INSERT INTO tasks (id, project_id, title, description, status, parent_task_attempt)
334 VALUES ($1, $2, $3, $4, $5, $6)
335 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>""#,
336 task_id,
337 data.project_id,
338 data.title,
339 data.description,
340 status,
341 data.parent_task_attempt
342 )
343 .fetch_one(pool)
344 .await
345 }
346
347 pub async fn update(
348 pool: &SqlitePool,
349 id: Uuid,
350 project_id: Uuid,
351 title: String,
352 description: Option<String>,
353 status: TaskStatus,
354 parent_task_attempt: Option<Uuid>,
355 ) -> Result<Self, sqlx::Error> {
356 sqlx::query_as!(
357 Task,
358 r#"UPDATE tasks
359 SET title = $3, description = $4, status = $5, parent_task_attempt = $6
360 WHERE id = $1 AND project_id = $2
361 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>""#,
362 id,
363 project_id,
364 title,
365 description,
366 status,
367 parent_task_attempt
368 )
369 .fetch_one(pool)
370 .await
371 }
372
373 pub async fn update_status(
374 pool: &SqlitePool,
375 id: Uuid,
376 status: TaskStatus,
377 ) -> Result<(), sqlx::Error> {
378 sqlx::query!(
379 "UPDATE tasks SET status = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1",
380 id,
381 status
382 )
383 .execute(pool)
384 .await?;
385 Ok(())
386 }
387
388 pub async fn nullify_children_by_attempt_id<'e, E>(
391 executor: E,
392 attempt_id: Uuid,
393 ) -> Result<u64, sqlx::Error>
394 where
395 E: Executor<'e, Database = Sqlite>,
396 {
397 let result = sqlx::query!(
398 "UPDATE tasks SET parent_task_attempt = NULL WHERE parent_task_attempt = $1",
399 attempt_id
400 )
401 .execute(executor)
402 .await?;
403 Ok(result.rows_affected())
404 }
405
406 pub async fn delete<'e, E>(executor: E, id: Uuid) -> Result<u64, sqlx::Error>
407 where
408 E: Executor<'e, Database = Sqlite>,
409 {
410 let result = sqlx::query!("DELETE FROM tasks WHERE id = $1", id)
411 .execute(executor)
412 .await?;
413 Ok(result.rows_affected())
414 }
415
416 pub async fn exists(
417 pool: &SqlitePool,
418 id: Uuid,
419 project_id: Uuid,
420 ) -> Result<bool, sqlx::Error> {
421 let result = sqlx::query!(
422 "SELECT id as \"id!: Uuid\" FROM tasks WHERE id = $1 AND project_id = $2",
423 id,
424 project_id
425 )
426 .fetch_optional(pool)
427 .await?;
428 Ok(result.is_some())
429 }
430
431 pub async fn find_children_by_attempt_id(
432 pool: &SqlitePool,
433 attempt_id: Uuid,
434 ) -> Result<Vec<Self>, sqlx::Error> {
435 sqlx::query_as!(
437 Task,
438 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>"
439 FROM tasks
440 WHERE parent_task_attempt = $1
441 ORDER BY created_at DESC"#,
442 attempt_id,
443 )
444 .fetch_all(pool)
445 .await
446 }
447
448 pub async fn find_relationships_for_attempt(
449 pool: &SqlitePool,
450 task_attempt: &TaskAttempt,
451 ) -> Result<TaskRelationships, sqlx::Error> {
452 let current_task = Self::find_by_id(pool, task_attempt.task_id)
454 .await?
455 .ok_or(sqlx::Error::RowNotFound)?;
456
457 let parent_task = if let Some(parent_attempt_id) = current_task.parent_task_attempt {
459 if let Ok(Some(parent_attempt)) = TaskAttempt::find_by_id(pool, parent_attempt_id).await
461 {
462 Self::find_by_id(pool, parent_attempt.task_id).await?
464 } else {
465 None
466 }
467 } else {
468 None
469 };
470
471 let children = Self::find_children_by_attempt_id(pool, task_attempt.id).await?;
473
474 Ok(TaskRelationships {
475 parent_task,
476 current_attempt: task_attempt.clone(),
477 children,
478 })
479 }
480}