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 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 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 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
304pub 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
367pub 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 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, ¤t),
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
451fn 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
485async 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, ¤t)
494 )),
495 Ok(current) => BusError::conflict(format!(
496 "you do not hold an active claim on '{key}' (it is {})",
497 current.status
498 )),
499 Err(_) => BusError::conflict(format!("you do not hold an active claim on '{key}'")),
502 }
503}
504
505pub 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}