forge_core_db/models/
task.rs

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, // Foreign key to Project
28    pub title: String,
29    pub description: Option<String>,
30    pub status: TaskStatus,
31    pub parent_task_attempt: Option<Uuid>, // Foreign key to parent TaskAttempt
32    pub dev_server_id: Option<Uuid>,       // Foreign key to DevServer (for analytics)
33    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>,    // The task that owns this attempt
65    pub current_attempt: TaskAttempt, // The attempt we're viewing
66    pub children: Vec<Task>,          // Tasks created by this attempt
67}
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, // TODO use merges table
188                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    /// Create a task with a specific initial status.
322    /// Use this when creating agent tasks (status = Agent) to ensure the task
323    /// is created with the correct status from the start, avoiding race conditions
324    /// with WebSocket broadcasts.
325    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    /// Nullify parent_task_attempt for all tasks that reference the given attempt ID
389    /// This breaks parent-child relationships before deleting a parent task
390    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        // Find only child tasks that have this attempt as their parent
436        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        // 1. Get the current task (task that owns this attempt)
453        let current_task = Self::find_by_id(pool, task_attempt.task_id)
454            .await?
455            .ok_or(sqlx::Error::RowNotFound)?;
456
457        // 2. Get parent task (if current task was created by another task's attempt)
458        let parent_task = if let Some(parent_attempt_id) = current_task.parent_task_attempt {
459            // Find the attempt that created the current task
460            if let Ok(Some(parent_attempt)) = TaskAttempt::find_by_id(pool, parent_attempt_id).await
461            {
462                // Find the task that owns that parent attempt - THAT's the real parent
463                Self::find_by_id(pool, parent_attempt.task_id).await?
464            } else {
465                None
466            }
467        } else {
468            None
469        };
470
471        // 3. Get children tasks (created by this attempt)
472        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}