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; 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_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
138pub struct CreateInput {
141 pub key: String,
142 pub title: String,
143 pub description: Option<String>,
144 pub metadata: Option<serde_json::Value>,
145 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 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 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
293pub 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
350pub 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 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
434pub 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}