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