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; const MAX_LEASE_SECS: i64 = 86_400;
12const MAX_LIMIT: i64 = 200;
13
14const MAX_TITLE_BYTES: usize = 512;
16const MAX_DESCRIPTION_BYTES: usize = 64 * 1024;
21const MAX_RESULT_BYTES: usize = 64 * 1024;
22const 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 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 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
150pub struct CreateInput {
153 pub key: String,
154 pub title: String,
155 pub description: Option<String>,
156 pub metadata: Option<serde_json::Value>,
157 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 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 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
305pub 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
368pub 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 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, ¤t),
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
452fn 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
486async 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, ¤t)
495 )),
496 Ok(current) => BusError::conflict(format!(
497 "you do not hold an active claim on '{key}' (it is {})",
498 current.status
499 )),
500 Err(_) => BusError::conflict(format!("you do not hold an active claim on '{key}'")),
503 }
504}
505
506pub 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}