Skip to main content

ai_crew_sync/store/
tasks.rs

1use sqlx::{AssertSqlSafe, 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_session: Option<String>,
36    claimed_at: Option<chrono::DateTime<chrono::Utc>>,
37    lease_expires_at: Option<chrono::DateTime<chrono::Utc>>,
38    result: Option<String>,
39    metadata: serde_json::Value,
40    attachments: serde_json::Value,
41    created_by: Option<String>,
42    created_at: chrono::DateTime<chrono::Utc>,
43    updated_at: chrono::DateTime<chrono::Utc>,
44}
45
46impl From<TaskRow> for TaskInfo {
47    fn from(r: TaskRow) -> Self {
48        let now = chrono::Utc::now();
49        let lease_expired =
50            r.status == "claimed" && r.lease_expires_at.map(|e| e < now).unwrap_or(false);
51        // Seconds, not a timestamp: an error that says "expires in 240s" tells
52        // the caller whether waiting is an option; an RFC 3339 instant makes it
53        // do the arithmetic first.
54        let lease_seconds_remaining = r
55            .lease_expires_at
56            .filter(|_| r.status == "claimed")
57            .map(|e| (e - now).num_seconds().max(0));
58        TaskInfo {
59            key: r.key,
60            title: r.title,
61            description: r.description,
62            status: r.status,
63            depends_on: r.depends_on,
64            blocked: r.blocked,
65            claimed_by: r.claimed_by,
66            // '' and NULL both mean the shared session: NULL is a claim taken
67            // before sessions existed, by a client that sent no header.
68            claimed_session: r.claimed_session.filter(|s| !s.is_empty()),
69            claimed_at: ts_opt(r.claimed_at),
70            lease_expires_at: ts_opt(r.lease_expires_at),
71            lease_expired,
72            lease_seconds_remaining,
73            result: r.result,
74            metadata: r.metadata,
75            attachments: serde_json::from_value(r.attachments).unwrap_or_default(),
76            created_by: r.created_by,
77            created_at: ts(r.created_at),
78            updated_at: ts(r.updated_at),
79        }
80    }
81}
82
83const TASK_SELECT: &str = r#"
84    SELECT t.id,
85           t.key,
86           t.title,
87           t.description,
88           t.status,
89           COALESCE(
90               (SELECT array_agg(d.key ORDER BY d.key)
91                FROM task_deps td JOIN tasks d ON d.id = td.blocked_by_task_id
92                WHERE td.task_id = t.id),
93               '{}'
94           ) AS depends_on,
95           EXISTS (
96               SELECT 1
97               FROM task_deps td JOIN tasks d ON d.id = td.blocked_by_task_id
98               WHERE td.task_id = t.id AND d.status NOT IN ('done', 'cancelled')
99           ) AS blocked,
100           cb.name AS claimed_by,
101           t.claimed_session,
102           t.claimed_at,
103           t.lease_expires_at,
104           t.result,
105           t.metadata,
106           COALESCE(
107               (SELECT json_agg(json_build_object(
108                           'id', a.id, 'filename', a.filename,
109                           'content_type', a.content_type, 'size_bytes', a.size_bytes)
110                       ORDER BY a.id)
111                FROM attachments a WHERE a.task_id = t.id),
112               '[]'::json
113           ) AS attachments,
114           crb.name AS created_by,
115           t.created_at,
116           t.updated_at
117    FROM tasks t
118    LEFT JOIN agents cb  ON cb.id = t.claimed_by
119    LEFT JOIN agents crb ON crb.id = t.created_by
120"#;
121
122async fn log_event(
123    pool: &PgPool,
124    task_id: Uuid,
125    agent_id: Uuid,
126    event: &str,
127    detail: Option<&str>,
128) -> BusResult<()> {
129    sqlx::query("INSERT INTO task_events (task_id, agent_id, event, detail) VALUES ($1,$2,$3,$4)")
130        .bind(task_id)
131        .bind(agent_id)
132        .bind(event)
133        .bind(detail)
134        .execute(pool)
135        .await?;
136    Ok(())
137}
138
139fn normalize_key(key: &str) -> BusResult<String> {
140    let key = key.trim();
141    if key.is_empty() {
142        return Err(BusError::invalid("task key cannot be empty"));
143    }
144    if key.len() > 128 {
145        return Err(BusError::invalid("task key is limited to 128 characters"));
146    }
147    Ok(key.to_owned())
148}
149
150// ------------------------------------------------------------------ create --
151
152pub struct CreateInput {
153    pub key: String,
154    pub title: String,
155    pub description: Option<String>,
156    pub metadata: Option<serde_json::Value>,
157    /// Keys of existing tasks this one depends on. The task cannot be claimed
158    /// until every dependency is done or cancelled.
159    pub depends_on: Vec<String>,
160}
161
162pub async fn create_task(pool: &PgPool, auth: &AuthCtx, input: CreateInput) -> BusResult<TaskInfo> {
163    let key = normalize_key(&input.key)?;
164    let title = super::check_text("task title", &input.title, MAX_TITLE_BYTES)?;
165    if title.is_empty() {
166        return Err(BusError::invalid("task title cannot be empty"));
167    }
168    let description = match input.description.as_deref() {
169        Some(d) => Some(super::check_text(
170            "task description",
171            d,
172            MAX_DESCRIPTION_BYTES,
173        )?),
174        None => None,
175    };
176    let metadata_in = super::normalize_metadata(input.metadata);
177    super::check_metadata("task", metadata_in.as_ref())?;
178    let metadata = metadata_in.unwrap_or_else(|| serde_json::Value::Object(Default::default()));
179
180    let existing: Option<(Uuid,)> =
181        sqlx::query_as("SELECT id FROM tasks WHERE team_id = $1 AND key = $2")
182            .bind(auth.team_id)
183            .bind(&key)
184            .fetch_optional(pool)
185            .await?;
186    if existing.is_some() {
187        return Err(BusError::conflict(format!(
188            "task '{key}' already exists; use get_task to inspect it"
189        )));
190    }
191
192    if input.depends_on.len() > MAX_DEPENDENCIES {
193        return Err(BusError::invalid(format!(
194            "a task declares at most {MAX_DEPENDENCIES} dependencies; got {}. \
195             Group the upstream work into fewer tasks.",
196            input.depends_on.len()
197        )));
198    }
199
200    // Resolve dependency keys before inserting anything, so a typo fails the
201    // whole call instead of leaving a half-registered task.
202    let mut dep_ids: Vec<Uuid> = Vec::with_capacity(input.depends_on.len());
203    for dep_key in &input.depends_on {
204        let dep_key = normalize_key(dep_key)?;
205        if dep_key == key {
206            return Err(BusError::invalid("a task cannot depend on itself"));
207        }
208        let dep: Option<(Uuid,)> =
209            sqlx::query_as("SELECT id FROM tasks WHERE team_id = $1 AND key = $2")
210                .bind(auth.team_id)
211                .bind(&dep_key)
212                .fetch_optional(pool)
213                .await?;
214        match dep {
215            Some((id,)) => dep_ids.push(id),
216            None => {
217                return Err(BusError::not_found(format!(
218                    "dependency '{dep_key}' does not exist; create it first"
219                )));
220            }
221        }
222    }
223    dep_ids.sort();
224    dep_ids.dedup();
225
226    let (id,): (Uuid,) = sqlx::query_as(
227        r#"
228        INSERT INTO tasks (team_id, key, title, description, metadata, created_by)
229        VALUES ($1, $2, $3, $4, $5, $6)
230        RETURNING id
231        "#,
232    )
233    .bind(auth.team_id)
234    .bind(&key)
235    .bind(&title)
236    .bind(description.as_deref())
237    .bind(&metadata)
238    .bind(auth.agent_id)
239    .fetch_one(pool)
240    .await?;
241
242    // Dependencies only point at pre-existing tasks and this task is brand
243    // new, so no cycle is possible by construction.
244    for dep_id in &dep_ids {
245        sqlx::query("INSERT INTO task_deps (task_id, blocked_by_task_id) VALUES ($1, $2)")
246            .bind(id)
247            .bind(dep_id)
248            .execute(pool)
249            .await?;
250    }
251
252    log_event(pool, id, auth.agent_id, "created", Some(&title)).await?;
253    fetch_task(pool, auth, &key).await
254}
255
256async fn fetch_task(pool: &PgPool, auth: &AuthCtx, key: &str) -> BusResult<TaskInfo> {
257    let row: Option<TaskRow> = sqlx::query_as(AssertSqlSafe(format!(
258        "{TASK_SELECT} WHERE t.team_id = $1 AND t.key = $2"
259    )))
260    .bind(auth.team_id)
261    .bind(key)
262    .fetch_optional(pool)
263    .await?;
264    row.map(Into::into)
265        .ok_or_else(|| BusError::not_found(format!("task '{key}'")))
266}
267
268pub async fn get_task(pool: &PgPool, auth: &AuthCtx, key: &str) -> BusResult<TaskDetail> {
269    let task = fetch_task(pool, auth, key).await?;
270    let rows: Vec<(
271        String,
272        Option<String>,
273        Option<String>,
274        chrono::DateTime<chrono::Utc>,
275    )> = sqlx::query_as(
276        r#"
277            SELECT e.event, a.name, e.detail, e.created_at
278            FROM task_events e
279            LEFT JOIN agents a ON a.id = e.agent_id
280            JOIN tasks t ON t.id = e.task_id
281            WHERE t.team_id = $1 AND t.key = $2
282            ORDER BY e.id
283            "#,
284    )
285    .bind(auth.team_id)
286    .bind(key.trim())
287    .fetch_all(pool)
288    .await?;
289
290    Ok(TaskDetail {
291        task,
292        history: rows
293            .into_iter()
294            .map(|(event, agent, detail, created_at)| TaskEventInfo {
295                event,
296                agent,
297                detail,
298                created_at: ts(created_at),
299            })
300            .collect(),
301    })
302}
303
304// -------------------------------------------------------------------- list --
305
306pub async fn list_tasks(
307    pool: &PgPool,
308    auth: &AuthCtx,
309    status: Option<String>,
310    mine_only: bool,
311    limit: i64,
312) -> BusResult<TaskList> {
313    let limit = limit.clamp(1, MAX_LIMIT);
314    let status = status
315        .map(|s| s.trim().to_lowercase())
316        .filter(|s| s != "any");
317    if let Some(s) = &status
318        && !["open", "claimed", "done", "cancelled"].contains(&s.as_str())
319    {
320        return Err(BusError::invalid(
321            "status must be one of: open, claimed, done, cancelled, any",
322        ));
323    }
324
325    let rows: Vec<TaskRow> = sqlx::query_as(AssertSqlSafe(format!(
326        r#"{TASK_SELECT}
327           WHERE t.team_id = $1
328             AND ($2::text IS NULL OR t.status = $2)
329             -- "mine" means this session's, matching whoami, renew and
330             -- release. Matching the agent alone would report a task your
331             -- core-manager window is holding as this window's own work,
332             -- which is the duplication the session check exists to stop.
333             AND (NOT $3::bool
334                  OR (t.claimed_by = $4 AND COALESCE(t.claimed_session, '') = $6))
335           ORDER BY
336             CASE t.status WHEN 'claimed' THEN 0 WHEN 'open' THEN 1 ELSE 2 END,
337             t.updated_at DESC
338           LIMIT $5"#
339    )))
340    .bind(auth.team_id)
341    .bind(status.as_deref())
342    .bind(mine_only)
343    .bind(auth.agent_id)
344    .bind(limit)
345    .bind(&auth.session)
346    .fetch_all(pool)
347    .await?;
348
349    let (open, claimed): (i64, i64) = sqlx::query_as(
350        r#"
351        SELECT count(*) FILTER (WHERE status = 'open'),
352               count(*) FILTER (WHERE status = 'claimed')
353        FROM tasks WHERE team_id = $1
354        "#,
355    )
356    .bind(auth.team_id)
357    .fetch_one(pool)
358    .await?;
359
360    Ok(TaskList {
361        tasks: rows.into_iter().map(Into::into).collect(),
362        open,
363        claimed,
364    })
365}
366
367// ------------------------------------------------------------------- claim --
368
369/// Claim a specific task. Succeeds when the task is open, when its lease has
370/// already expired, or when the caller already holds it (idempotent re-claim).
371pub async fn claim_task(
372    pool: &PgPool,
373    auth: &AuthCtx,
374    key: &str,
375    lease_seconds: Option<i64>,
376) -> BusResult<ClaimResult> {
377    let key = normalize_key(key)?;
378    let lease = lease_seconds
379        .unwrap_or(DEFAULT_LEASE_SECS)
380        .clamp(30, MAX_LEASE_SECS);
381
382    let updated: Option<(Uuid,)> = sqlx::query_as(
383        r#"
384        UPDATE tasks
385        SET status = 'claimed',
386            claimed_by = $1,
387            claimed_session = $5,
388            claimed_at = now(),
389            lease_expires_at = now() + make_interval(secs => $2),
390            updated_at = now()
391        WHERE team_id = $3
392          AND key = $4
393          AND status IN ('open', 'claimed')
394          -- Re-claiming renews the lease, but only from the session that holds
395          -- it. Matching on the agent alone made the lease void between two
396          -- sessions of one person: both claimed, both were told they had it,
397          -- and both did the work. COALESCE so a claim taken before sessions
398          -- existed counts as the shared session, which is what it was.
399          AND (status = 'open'
400               OR (claimed_by = $1 AND COALESCE(claimed_session, '') = $5)
401               OR lease_expires_at IS NULL
402               OR lease_expires_at < now())
403          AND NOT EXISTS (
404              SELECT 1
405              FROM task_deps td JOIN tasks d ON d.id = td.blocked_by_task_id
406              WHERE td.task_id = tasks.id AND d.status NOT IN ('done', 'cancelled')
407          )
408        RETURNING id
409        "#,
410    )
411    .bind(auth.agent_id)
412    .bind(lease as f64)
413    .bind(auth.team_id)
414    .bind(&key)
415    .bind(&auth.session)
416    .fetch_optional(pool)
417    .await?;
418
419    match updated {
420        Some((id,)) => {
421            log_event(pool, id, auth.agent_id, "claimed", None).await?;
422            Ok(ClaimResult {
423                claimed: true,
424                task: Some(fetch_task(pool, auth, &key).await?),
425                reason: None,
426            })
427        }
428        None => {
429            // Distinguish "does not exist" from "someone else holds it".
430            let current = fetch_task(pool, auth, &key).await?;
431            let reason = if current.blocked {
432                format!(
433                    "blocked by unfinished dependencies: {}",
434                    current.depends_on.join(", ")
435                )
436            } else {
437                match current.status.as_str() {
438                    "claimed" => holder_reason(auth, &current),
439                    other => format!("task is {other}"),
440                }
441            };
442            Ok(ClaimResult {
443                claimed: false,
444                task: Some(current),
445                reason: Some(reason),
446            })
447        }
448    }
449}
450
451/// Why a claim was refused, written for the model that has to act on it.
452///
453/// The case worth spelling out is your *own* other session: "held by joaquin"
454/// reads as a bug when you are joaquin, and the fix — go to that window, or
455/// wait for the lease — is not guessable from the name alone.
456fn holder_reason(auth: &AuthCtx, current: &TaskInfo) -> String {
457    let holder = current.claimed_by.as_deref().unwrap_or("?");
458    let until = match current.lease_seconds_remaining {
459        Some(secs) => format!("the lease expires in {secs}s"),
460        None => "the lease expiry is unknown".to_owned(),
461    };
462    let mine = current.claimed_by.as_deref() == Some(auth.agent_name.as_str());
463    let same_session = current.claimed_session.as_deref().unwrap_or("") == auth.session;
464
465    if mine && !same_session {
466        let theirs = current
467            .claimed_session
468            .as_deref()
469            .map(|s| format!("'{s}'"))
470            .unwrap_or_else(|| "shared".to_owned());
471        format!(
472            "claimed by your own {theirs} session, and {until} — continue the \
473             work there, or wait for the lease to expire and claim it here"
474        )
475    } else {
476        let where_ = current
477            .claimed_session
478            .as_deref()
479            .map(|s| format!(" (session '{s}')"))
480            .unwrap_or_default();
481        format!("held by {holder}{where_}, {until}")
482    }
483}
484
485/// Refusal for renew/release when this session does not hold the claim.
486///
487/// "You do not hold a claim" is true but useless when your other window does:
488/// look up who actually has it and say so.
489async fn no_claim_here(pool: &PgPool, auth: &AuthCtx, key: &str) -> BusError {
490    match fetch_task(pool, auth, key).await {
491        Ok(current) if current.status == "claimed" => BusError::conflict(format!(
492            "you do not hold the claim on '{key}': it is {}",
493            holder_reason(auth, &current)
494        )),
495        Ok(current) => BusError::conflict(format!(
496            "you do not hold an active claim on '{key}' (it is {})",
497            current.status
498        )),
499        // The task is gone or not ours to see; the original message is still
500        // the honest answer.
501        Err(_) => BusError::conflict(format!("you do not hold an active claim on '{key}'")),
502    }
503}
504
505/// Claim the oldest available task. Uses `SKIP LOCKED` so several agents can
506/// call this concurrently without handing the same task to two of them.
507pub async fn claim_next_task(
508    pool: &PgPool,
509    auth: &AuthCtx,
510    lease_seconds: Option<i64>,
511) -> BusResult<ClaimResult> {
512    let lease = lease_seconds
513        .unwrap_or(DEFAULT_LEASE_SECS)
514        .clamp(30, MAX_LEASE_SECS);
515
516    let picked: Option<(Uuid, String)> = sqlx::query_as(
517        r#"
518        WITH candidate AS (
519            SELECT id
520            FROM tasks
521            WHERE team_id = $1
522              AND (status = 'open'
523                   OR (status = 'claimed' AND lease_expires_at < now()))
524              AND NOT EXISTS (
525                  SELECT 1
526                  FROM task_deps td JOIN tasks d ON d.id = td.blocked_by_task_id
527                  WHERE td.task_id = tasks.id AND d.status NOT IN ('done', 'cancelled')
528              )
529            ORDER BY created_at
530            LIMIT 1
531            FOR UPDATE SKIP LOCKED
532        )
533        UPDATE tasks t
534        SET status = 'claimed',
535            claimed_by = $2,
536            claimed_session = $4,
537            claimed_at = now(),
538            lease_expires_at = now() + make_interval(secs => $3),
539            updated_at = now()
540        FROM candidate c
541        WHERE t.id = c.id
542        RETURNING t.id, t.key
543        "#,
544    )
545    .bind(auth.team_id)
546    .bind(auth.agent_id)
547    .bind(lease as f64)
548    .bind(&auth.session)
549    .fetch_optional(pool)
550    .await?;
551
552    match picked {
553        Some((id, key)) => {
554            log_event(
555                pool,
556                id,
557                auth.agent_id,
558                "claimed",
559                Some("via claim_next_task"),
560            )
561            .await?;
562            Ok(ClaimResult {
563                claimed: true,
564                task: Some(fetch_task(pool, auth, &key).await?),
565                reason: None,
566            })
567        }
568        None => Ok(ClaimResult {
569            claimed: false,
570            task: None,
571            reason: Some("no unclaimed task available".into()),
572        }),
573    }
574}
575
576pub async fn renew_lease(
577    pool: &PgPool,
578    auth: &AuthCtx,
579    key: &str,
580    lease_seconds: Option<i64>,
581) -> BusResult<TaskInfo> {
582    let key = normalize_key(key)?;
583    let lease = lease_seconds
584        .unwrap_or(DEFAULT_LEASE_SECS)
585        .clamp(30, MAX_LEASE_SECS);
586
587    let updated: Option<(Uuid,)> = sqlx::query_as(
588        r#"
589        UPDATE tasks
590        SET lease_expires_at = now() + make_interval(secs => $1),
591            updated_at = now()
592        WHERE team_id = $2 AND key = $3 AND claimed_by = $4 AND status = 'claimed'
593          AND COALESCE(claimed_session, '') = $5
594        RETURNING id
595        "#,
596    )
597    .bind(lease as f64)
598    .bind(auth.team_id)
599    .bind(&key)
600    .bind(auth.agent_id)
601    .bind(&auth.session)
602    .fetch_optional(pool)
603    .await?;
604
605    if updated.is_none() {
606        return Err(no_claim_here(pool, auth, &key).await);
607    }
608    fetch_task(pool, auth, &key).await
609}
610
611pub async fn release_task(pool: &PgPool, auth: &AuthCtx, key: &str) -> BusResult<TaskInfo> {
612    let key = normalize_key(key)?;
613    let updated: Option<(Uuid,)> = sqlx::query_as(
614        r#"
615        UPDATE tasks
616        SET status = 'open',
617            claimed_by = NULL,
618            -- Cleared with the holder it belongs to. Leaving it behind made a
619            -- released task report claimed_by null next to a session name,
620            -- which reads as an active holder that does not exist.
621            claimed_session = NULL,
622            claimed_at = NULL,
623            lease_expires_at = NULL,
624            updated_at = now()
625        WHERE team_id = $1 AND key = $2 AND claimed_by = $3 AND status = 'claimed'
626          AND COALESCE(claimed_session, '') = $4
627        RETURNING id
628        "#,
629    )
630    .bind(auth.team_id)
631    .bind(&key)
632    .bind(auth.agent_id)
633    .bind(&auth.session)
634    .fetch_optional(pool)
635    .await?;
636
637    match updated {
638        Some((id,)) => {
639            log_event(pool, id, auth.agent_id, "released", None).await?;
640            fetch_task(pool, auth, &key).await
641        }
642        None => Err(no_claim_here(pool, auth, &key).await),
643    }
644}
645
646pub async fn complete_task(
647    pool: &PgPool,
648    auth: &AuthCtx,
649    key: &str,
650    result: Option<String>,
651) -> BusResult<TaskInfo> {
652    let key = normalize_key(key)?;
653    let result = match result.as_deref() {
654        Some(r) => Some(super::check_text("task result", r, MAX_RESULT_BYTES)?),
655        None => None,
656    };
657    let updated: Option<(Uuid,)> = sqlx::query_as(
658        r#"
659        UPDATE tasks
660        SET status = 'done',
661            result = $1,
662            lease_expires_at = NULL,
663            updated_at = now()
664        WHERE team_id = $2 AND key = $3 AND status IN ('open', 'claimed')
665        RETURNING id
666        "#,
667    )
668    .bind(result.as_deref())
669    .bind(auth.team_id)
670    .bind(&key)
671    .fetch_optional(pool)
672    .await?;
673
674    match updated {
675        Some((id,)) => {
676            log_event(pool, id, auth.agent_id, "completed", result.as_deref()).await?;
677            fetch_task(pool, auth, &key).await
678        }
679        None => {
680            let current = fetch_task(pool, auth, &key).await?;
681            Err(BusError::conflict(format!(
682                "task '{key}' is already {}",
683                current.status
684            )))
685        }
686    }
687}