Skip to main content

ai_crew_sync/store/
tasks.rs

1use sqlx::PgPool;
2use uuid::Uuid;
3
4use crate::{
5    auth::AuthCtx,
6    error::{BusError, BusResult},
7    model::{ClaimResult, TaskDetail, TaskEventInfo, TaskInfo, TaskList, ts, ts_opt},
8};
9
10const DEFAULT_LEASE_SECS: i64 = 900; // 15 minutes
11const MAX_LEASE_SECS: i64 = 86_400;
12const MAX_LIMIT: i64 = 200;
13
14#[derive(sqlx::FromRow)]
15struct TaskRow {
16    key: String,
17    title: String,
18    description: Option<String>,
19    status: String,
20    depends_on: Vec<String>,
21    blocked: bool,
22    claimed_by: Option<String>,
23    claimed_at: Option<chrono::DateTime<chrono::Utc>>,
24    lease_expires_at: Option<chrono::DateTime<chrono::Utc>>,
25    result: Option<String>,
26    metadata: serde_json::Value,
27    attachments: serde_json::Value,
28    created_by: Option<String>,
29    created_at: chrono::DateTime<chrono::Utc>,
30    updated_at: chrono::DateTime<chrono::Utc>,
31}
32
33impl From<TaskRow> for TaskInfo {
34    fn from(r: TaskRow) -> Self {
35        let lease_expired = r.status == "claimed"
36            && r.lease_expires_at
37                .map(|e| e < chrono::Utc::now())
38                .unwrap_or(false);
39        TaskInfo {
40            key: r.key,
41            title: r.title,
42            description: r.description,
43            status: r.status,
44            depends_on: r.depends_on,
45            blocked: r.blocked,
46            claimed_by: r.claimed_by,
47            claimed_at: ts_opt(r.claimed_at),
48            lease_expires_at: ts_opt(r.lease_expires_at),
49            lease_expired,
50            result: r.result,
51            metadata: r.metadata,
52            attachments: serde_json::from_value(r.attachments).unwrap_or_default(),
53            created_by: r.created_by,
54            created_at: ts(r.created_at),
55            updated_at: ts(r.updated_at),
56        }
57    }
58}
59
60const TASK_SELECT: &str = r#"
61    SELECT t.id,
62           t.key,
63           t.title,
64           t.description,
65           t.status,
66           COALESCE(
67               (SELECT array_agg(d.key ORDER BY d.key)
68                FROM task_deps td JOIN tasks d ON d.id = td.blocked_by_task_id
69                WHERE td.task_id = t.id),
70               '{}'
71           ) AS depends_on,
72           EXISTS (
73               SELECT 1
74               FROM task_deps td JOIN tasks d ON d.id = td.blocked_by_task_id
75               WHERE td.task_id = t.id AND d.status NOT IN ('done', 'cancelled')
76           ) AS blocked,
77           cb.name AS claimed_by,
78           t.claimed_at,
79           t.lease_expires_at,
80           t.result,
81           t.metadata,
82           COALESCE(
83               (SELECT json_agg(json_build_object(
84                           'id', a.id, 'filename', a.filename,
85                           'content_type', a.content_type, 'size_bytes', a.size_bytes)
86                       ORDER BY a.id)
87                FROM attachments a WHERE a.task_id = t.id),
88               '[]'::json
89           ) AS attachments,
90           crb.name AS created_by,
91           t.created_at,
92           t.updated_at
93    FROM tasks t
94    LEFT JOIN agents cb  ON cb.id = t.claimed_by
95    LEFT JOIN agents crb ON crb.id = t.created_by
96"#;
97
98async fn log_event(
99    pool: &PgPool,
100    task_id: Uuid,
101    agent_id: Uuid,
102    event: &str,
103    detail: Option<&str>,
104) -> BusResult<()> {
105    sqlx::query("INSERT INTO task_events (task_id, agent_id, event, detail) VALUES ($1,$2,$3,$4)")
106        .bind(task_id)
107        .bind(agent_id)
108        .bind(event)
109        .bind(detail)
110        .execute(pool)
111        .await?;
112    Ok(())
113}
114
115fn normalize_key(key: &str) -> BusResult<String> {
116    let key = key.trim();
117    if key.is_empty() {
118        return Err(BusError::invalid("task key cannot be empty"));
119    }
120    if key.len() > 128 {
121        return Err(BusError::invalid("task key is limited to 128 characters"));
122    }
123    Ok(key.to_owned())
124}
125
126// ------------------------------------------------------------------ create --
127
128pub struct CreateInput {
129    pub key: String,
130    pub title: String,
131    pub description: Option<String>,
132    pub metadata: Option<serde_json::Value>,
133    /// Keys of existing tasks this one depends on. The task cannot be claimed
134    /// until every dependency is done or cancelled.
135    pub depends_on: Vec<String>,
136}
137
138pub async fn create_task(pool: &PgPool, auth: &AuthCtx, input: CreateInput) -> BusResult<TaskInfo> {
139    let key = normalize_key(&input.key)?;
140    let title = input.title.trim();
141    if title.is_empty() {
142        return Err(BusError::invalid("task title cannot be empty"));
143    }
144    let metadata = input
145        .metadata
146        .unwrap_or_else(|| serde_json::Value::Object(Default::default()));
147
148    let existing: Option<(Uuid,)> =
149        sqlx::query_as("SELECT id FROM tasks WHERE team_id = $1 AND key = $2")
150            .bind(auth.team_id)
151            .bind(&key)
152            .fetch_optional(pool)
153            .await?;
154    if existing.is_some() {
155        return Err(BusError::conflict(format!(
156            "task '{key}' already exists; use get_task to inspect it"
157        )));
158    }
159
160    // Resolve dependency keys before inserting anything, so a typo fails the
161    // whole call instead of leaving a half-registered task.
162    let mut dep_ids: Vec<Uuid> = Vec::with_capacity(input.depends_on.len());
163    for dep_key in &input.depends_on {
164        let dep_key = normalize_key(dep_key)?;
165        if dep_key == key {
166            return Err(BusError::invalid("a task cannot depend on itself"));
167        }
168        let dep: Option<(Uuid,)> =
169            sqlx::query_as("SELECT id FROM tasks WHERE team_id = $1 AND key = $2")
170                .bind(auth.team_id)
171                .bind(&dep_key)
172                .fetch_optional(pool)
173                .await?;
174        match dep {
175            Some((id,)) => dep_ids.push(id),
176            None => {
177                return Err(BusError::not_found(format!(
178                    "dependency '{dep_key}' does not exist; create it first"
179                )));
180            }
181        }
182    }
183    dep_ids.sort();
184    dep_ids.dedup();
185
186    let (id,): (Uuid,) = sqlx::query_as(
187        r#"
188        INSERT INTO tasks (team_id, key, title, description, metadata, created_by)
189        VALUES ($1, $2, $3, $4, $5, $6)
190        RETURNING id
191        "#,
192    )
193    .bind(auth.team_id)
194    .bind(&key)
195    .bind(title)
196    .bind(input.description.as_deref())
197    .bind(&metadata)
198    .bind(auth.agent_id)
199    .fetch_one(pool)
200    .await?;
201
202    // Dependencies only point at pre-existing tasks and this task is brand
203    // new, so no cycle is possible by construction.
204    for dep_id in &dep_ids {
205        sqlx::query("INSERT INTO task_deps (task_id, blocked_by_task_id) VALUES ($1, $2)")
206            .bind(id)
207            .bind(dep_id)
208            .execute(pool)
209            .await?;
210    }
211
212    log_event(pool, id, auth.agent_id, "created", Some(title)).await?;
213    fetch_task(pool, auth, &key).await
214}
215
216async fn fetch_task(pool: &PgPool, auth: &AuthCtx, key: &str) -> BusResult<TaskInfo> {
217    let row: Option<TaskRow> = sqlx::query_as(&format!(
218        "{TASK_SELECT} WHERE t.team_id = $1 AND t.key = $2"
219    ))
220    .bind(auth.team_id)
221    .bind(key)
222    .fetch_optional(pool)
223    .await?;
224    row.map(Into::into)
225        .ok_or_else(|| BusError::not_found(format!("task '{key}'")))
226}
227
228pub async fn get_task(pool: &PgPool, auth: &AuthCtx, key: &str) -> BusResult<TaskDetail> {
229    let task = fetch_task(pool, auth, key).await?;
230    let rows: Vec<(
231        String,
232        Option<String>,
233        Option<String>,
234        chrono::DateTime<chrono::Utc>,
235    )> = sqlx::query_as(
236        r#"
237            SELECT e.event, a.name, e.detail, e.created_at
238            FROM task_events e
239            LEFT JOIN agents a ON a.id = e.agent_id
240            JOIN tasks t ON t.id = e.task_id
241            WHERE t.team_id = $1 AND t.key = $2
242            ORDER BY e.id
243            "#,
244    )
245    .bind(auth.team_id)
246    .bind(key.trim())
247    .fetch_all(pool)
248    .await?;
249
250    Ok(TaskDetail {
251        task,
252        history: rows
253            .into_iter()
254            .map(|(event, agent, detail, created_at)| TaskEventInfo {
255                event,
256                agent,
257                detail,
258                created_at: ts(created_at),
259            })
260            .collect(),
261    })
262}
263
264// -------------------------------------------------------------------- list --
265
266pub async fn list_tasks(
267    pool: &PgPool,
268    auth: &AuthCtx,
269    status: Option<String>,
270    mine_only: bool,
271    limit: i64,
272) -> BusResult<TaskList> {
273    let limit = limit.clamp(1, MAX_LIMIT);
274    let status = status
275        .map(|s| s.trim().to_lowercase())
276        .filter(|s| s != "any");
277    if let Some(s) = &status
278        && !["open", "claimed", "done", "cancelled"].contains(&s.as_str())
279    {
280        return Err(BusError::invalid(
281            "status must be one of: open, claimed, done, cancelled, any",
282        ));
283    }
284
285    let rows: Vec<TaskRow> = sqlx::query_as(&format!(
286        r#"{TASK_SELECT}
287           WHERE t.team_id = $1
288             AND ($2::text IS NULL OR t.status = $2)
289             AND (NOT $3::bool OR t.claimed_by = $4)
290           ORDER BY
291             CASE t.status WHEN 'claimed' THEN 0 WHEN 'open' THEN 1 ELSE 2 END,
292             t.updated_at DESC
293           LIMIT $5"#
294    ))
295    .bind(auth.team_id)
296    .bind(status.as_deref())
297    .bind(mine_only)
298    .bind(auth.agent_id)
299    .bind(limit)
300    .fetch_all(pool)
301    .await?;
302
303    let (open, claimed): (i64, i64) = sqlx::query_as(
304        r#"
305        SELECT count(*) FILTER (WHERE status = 'open'),
306               count(*) FILTER (WHERE status = 'claimed')
307        FROM tasks WHERE team_id = $1
308        "#,
309    )
310    .bind(auth.team_id)
311    .fetch_one(pool)
312    .await?;
313
314    Ok(TaskList {
315        tasks: rows.into_iter().map(Into::into).collect(),
316        open,
317        claimed,
318    })
319}
320
321// ------------------------------------------------------------------- claim --
322
323/// Claim a specific task. Succeeds when the task is open, when its lease has
324/// already expired, or when the caller already holds it (idempotent re-claim).
325pub async fn claim_task(
326    pool: &PgPool,
327    auth: &AuthCtx,
328    key: &str,
329    lease_seconds: Option<i64>,
330) -> BusResult<ClaimResult> {
331    let key = normalize_key(key)?;
332    let lease = lease_seconds
333        .unwrap_or(DEFAULT_LEASE_SECS)
334        .clamp(30, MAX_LEASE_SECS);
335
336    let updated: Option<(Uuid,)> = sqlx::query_as(
337        r#"
338        UPDATE tasks
339        SET status = 'claimed',
340            claimed_by = $1,
341            claimed_at = now(),
342            lease_expires_at = now() + make_interval(secs => $2),
343            updated_at = now()
344        WHERE team_id = $3
345          AND key = $4
346          AND status IN ('open', 'claimed')
347          AND (status = 'open'
348               OR claimed_by = $1
349               OR lease_expires_at IS NULL
350               OR lease_expires_at < now())
351          AND NOT EXISTS (
352              SELECT 1
353              FROM task_deps td JOIN tasks d ON d.id = td.blocked_by_task_id
354              WHERE td.task_id = tasks.id AND d.status NOT IN ('done', 'cancelled')
355          )
356        RETURNING id
357        "#,
358    )
359    .bind(auth.agent_id)
360    .bind(lease as f64)
361    .bind(auth.team_id)
362    .bind(&key)
363    .fetch_optional(pool)
364    .await?;
365
366    match updated {
367        Some((id,)) => {
368            log_event(pool, id, auth.agent_id, "claimed", None).await?;
369            Ok(ClaimResult {
370                claimed: true,
371                task: Some(fetch_task(pool, auth, &key).await?),
372                reason: None,
373            })
374        }
375        None => {
376            // Distinguish "does not exist" from "someone else holds it".
377            let current = fetch_task(pool, auth, &key).await?;
378            let reason = if current.blocked {
379                format!(
380                    "blocked by unfinished dependencies: {}",
381                    current.depends_on.join(", ")
382                )
383            } else {
384                match current.status.as_str() {
385                    "claimed" => format!(
386                        "held by {} until {}",
387                        current.claimed_by.clone().unwrap_or_else(|| "?".into()),
388                        current
389                            .lease_expires_at
390                            .clone()
391                            .unwrap_or_else(|| "unknown".into())
392                    ),
393                    other => format!("task is {other}"),
394                }
395            };
396            Ok(ClaimResult {
397                claimed: false,
398                task: Some(current),
399                reason: Some(reason),
400            })
401        }
402    }
403}
404
405/// Claim the oldest available task. Uses `SKIP LOCKED` so several agents can
406/// call this concurrently without handing the same task to two of them.
407pub async fn claim_next_task(
408    pool: &PgPool,
409    auth: &AuthCtx,
410    lease_seconds: Option<i64>,
411) -> BusResult<ClaimResult> {
412    let lease = lease_seconds
413        .unwrap_or(DEFAULT_LEASE_SECS)
414        .clamp(30, MAX_LEASE_SECS);
415
416    let picked: Option<(Uuid, String)> = sqlx::query_as(
417        r#"
418        WITH candidate AS (
419            SELECT id
420            FROM tasks
421            WHERE team_id = $1
422              AND (status = 'open'
423                   OR (status = 'claimed' AND lease_expires_at < now()))
424              AND NOT EXISTS (
425                  SELECT 1
426                  FROM task_deps td JOIN tasks d ON d.id = td.blocked_by_task_id
427                  WHERE td.task_id = tasks.id AND d.status NOT IN ('done', 'cancelled')
428              )
429            ORDER BY created_at
430            LIMIT 1
431            FOR UPDATE SKIP LOCKED
432        )
433        UPDATE tasks t
434        SET status = 'claimed',
435            claimed_by = $2,
436            claimed_at = now(),
437            lease_expires_at = now() + make_interval(secs => $3),
438            updated_at = now()
439        FROM candidate c
440        WHERE t.id = c.id
441        RETURNING t.id, t.key
442        "#,
443    )
444    .bind(auth.team_id)
445    .bind(auth.agent_id)
446    .bind(lease as f64)
447    .fetch_optional(pool)
448    .await?;
449
450    match picked {
451        Some((id, key)) => {
452            log_event(
453                pool,
454                id,
455                auth.agent_id,
456                "claimed",
457                Some("via claim_next_task"),
458            )
459            .await?;
460            Ok(ClaimResult {
461                claimed: true,
462                task: Some(fetch_task(pool, auth, &key).await?),
463                reason: None,
464            })
465        }
466        None => Ok(ClaimResult {
467            claimed: false,
468            task: None,
469            reason: Some("no unclaimed task available".into()),
470        }),
471    }
472}
473
474pub async fn renew_lease(
475    pool: &PgPool,
476    auth: &AuthCtx,
477    key: &str,
478    lease_seconds: Option<i64>,
479) -> BusResult<TaskInfo> {
480    let key = normalize_key(key)?;
481    let lease = lease_seconds
482        .unwrap_or(DEFAULT_LEASE_SECS)
483        .clamp(30, MAX_LEASE_SECS);
484
485    let updated: Option<(Uuid,)> = sqlx::query_as(
486        r#"
487        UPDATE tasks
488        SET lease_expires_at = now() + make_interval(secs => $1),
489            updated_at = now()
490        WHERE team_id = $2 AND key = $3 AND claimed_by = $4 AND status = 'claimed'
491        RETURNING id
492        "#,
493    )
494    .bind(lease as f64)
495    .bind(auth.team_id)
496    .bind(&key)
497    .bind(auth.agent_id)
498    .fetch_optional(pool)
499    .await?;
500
501    if updated.is_none() {
502        return Err(BusError::conflict(format!(
503            "you do not hold an active claim on '{key}'"
504        )));
505    }
506    fetch_task(pool, auth, &key).await
507}
508
509pub async fn release_task(pool: &PgPool, auth: &AuthCtx, key: &str) -> BusResult<TaskInfo> {
510    let key = normalize_key(key)?;
511    let updated: Option<(Uuid,)> = sqlx::query_as(
512        r#"
513        UPDATE tasks
514        SET status = 'open',
515            claimed_by = NULL,
516            claimed_at = NULL,
517            lease_expires_at = NULL,
518            updated_at = now()
519        WHERE team_id = $1 AND key = $2 AND claimed_by = $3 AND status = 'claimed'
520        RETURNING id
521        "#,
522    )
523    .bind(auth.team_id)
524    .bind(&key)
525    .bind(auth.agent_id)
526    .fetch_optional(pool)
527    .await?;
528
529    match updated {
530        Some((id,)) => {
531            log_event(pool, id, auth.agent_id, "released", None).await?;
532            fetch_task(pool, auth, &key).await
533        }
534        None => Err(BusError::conflict(format!(
535            "you do not hold an active claim on '{key}'"
536        ))),
537    }
538}
539
540pub async fn complete_task(
541    pool: &PgPool,
542    auth: &AuthCtx,
543    key: &str,
544    result: Option<String>,
545) -> BusResult<TaskInfo> {
546    let key = normalize_key(key)?;
547    let updated: Option<(Uuid,)> = sqlx::query_as(
548        r#"
549        UPDATE tasks
550        SET status = 'done',
551            result = $1,
552            lease_expires_at = NULL,
553            updated_at = now()
554        WHERE team_id = $2 AND key = $3 AND status IN ('open', 'claimed')
555        RETURNING id
556        "#,
557    )
558    .bind(result.as_deref())
559    .bind(auth.team_id)
560    .bind(&key)
561    .fetch_optional(pool)
562    .await?;
563
564    match updated {
565        Some((id,)) => {
566            log_event(pool, id, auth.agent_id, "completed", result.as_deref()).await?;
567            fetch_task(pool, auth, &key).await
568        }
569        None => {
570            let current = fetch_task(pool, auth, &key).await?;
571            Err(BusError::conflict(format!(
572                "task '{key}' is already {}",
573                current.status
574            )))
575        }
576    }
577}