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