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