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
14#[derive(sqlx::FromRow)]
15struct TaskRow {
16 key: String,
17 title: String,
18 description: Option<String>,
19 status: String,
20 depends_on: Vec<String>,
21 blocked: bool,
22 claimed_by: Option<String>,
23 claimed_at: Option<chrono::DateTime<chrono::Utc>>,
24 lease_expires_at: Option<chrono::DateTime<chrono::Utc>>,
25 result: Option<String>,
26 metadata: serde_json::Value,
27 attachments: serde_json::Value,
28 created_by: Option<String>,
29 created_at: chrono::DateTime<chrono::Utc>,
30 updated_at: chrono::DateTime<chrono::Utc>,
31}
32
33impl From<TaskRow> for TaskInfo {
34 fn from(r: TaskRow) -> Self {
35 let lease_expired = r.status == "claimed"
36 && r.lease_expires_at
37 .map(|e| e < chrono::Utc::now())
38 .unwrap_or(false);
39 TaskInfo {
40 key: r.key,
41 title: r.title,
42 description: r.description,
43 status: r.status,
44 depends_on: r.depends_on,
45 blocked: r.blocked,
46 claimed_by: r.claimed_by,
47 claimed_at: ts_opt(r.claimed_at),
48 lease_expires_at: ts_opt(r.lease_expires_at),
49 lease_expired,
50 result: r.result,
51 metadata: r.metadata,
52 attachments: serde_json::from_value(r.attachments).unwrap_or_default(),
53 created_by: r.created_by,
54 created_at: ts(r.created_at),
55 updated_at: ts(r.updated_at),
56 }
57 }
58}
59
60const TASK_SELECT: &str = r#"
61 SELECT t.id,
62 t.key,
63 t.title,
64 t.description,
65 t.status,
66 COALESCE(
67 (SELECT array_agg(d.key ORDER BY d.key)
68 FROM task_deps td JOIN tasks d ON d.id = td.blocked_by_task_id
69 WHERE td.task_id = t.id),
70 '{}'
71 ) AS depends_on,
72 EXISTS (
73 SELECT 1
74 FROM task_deps td JOIN tasks d ON d.id = td.blocked_by_task_id
75 WHERE td.task_id = t.id AND d.status NOT IN ('done', 'cancelled')
76 ) AS blocked,
77 cb.name AS claimed_by,
78 t.claimed_at,
79 t.lease_expires_at,
80 t.result,
81 t.metadata,
82 COALESCE(
83 (SELECT json_agg(json_build_object(
84 'id', a.id, 'filename', a.filename,
85 'content_type', a.content_type, 'size_bytes', a.size_bytes)
86 ORDER BY a.id)
87 FROM attachments a WHERE a.task_id = t.id),
88 '[]'::json
89 ) AS attachments,
90 crb.name AS created_by,
91 t.created_at,
92 t.updated_at
93 FROM tasks t
94 LEFT JOIN agents cb ON cb.id = t.claimed_by
95 LEFT JOIN agents crb ON crb.id = t.created_by
96"#;
97
98async fn log_event(
99 pool: &PgPool,
100 task_id: Uuid,
101 agent_id: Uuid,
102 event: &str,
103 detail: Option<&str>,
104) -> BusResult<()> {
105 sqlx::query("INSERT INTO task_events (task_id, agent_id, event, detail) VALUES ($1,$2,$3,$4)")
106 .bind(task_id)
107 .bind(agent_id)
108 .bind(event)
109 .bind(detail)
110 .execute(pool)
111 .await?;
112 Ok(())
113}
114
115fn normalize_key(key: &str) -> BusResult<String> {
116 let key = key.trim();
117 if key.is_empty() {
118 return Err(BusError::invalid("task key cannot be empty"));
119 }
120 if key.len() > 128 {
121 return Err(BusError::invalid("task key is limited to 128 characters"));
122 }
123 Ok(key.to_owned())
124}
125
126pub struct CreateInput {
129 pub key: String,
130 pub title: String,
131 pub description: Option<String>,
132 pub metadata: Option<serde_json::Value>,
133 pub depends_on: Vec<String>,
136}
137
138pub async fn create_task(pool: &PgPool, auth: &AuthCtx, input: CreateInput) -> BusResult<TaskInfo> {
139 let key = normalize_key(&input.key)?;
140 let title = input.title.trim();
141 if title.is_empty() {
142 return Err(BusError::invalid("task title cannot be empty"));
143 }
144 let metadata = input
145 .metadata
146 .unwrap_or_else(|| serde_json::Value::Object(Default::default()));
147
148 let existing: Option<(Uuid,)> =
149 sqlx::query_as("SELECT id FROM tasks WHERE team_id = $1 AND key = $2")
150 .bind(auth.team_id)
151 .bind(&key)
152 .fetch_optional(pool)
153 .await?;
154 if existing.is_some() {
155 return Err(BusError::conflict(format!(
156 "task '{key}' already exists; use get_task to inspect it"
157 )));
158 }
159
160 let mut dep_ids: Vec<Uuid> = Vec::with_capacity(input.depends_on.len());
163 for dep_key in &input.depends_on {
164 let dep_key = normalize_key(dep_key)?;
165 if dep_key == key {
166 return Err(BusError::invalid("a task cannot depend on itself"));
167 }
168 let dep: Option<(Uuid,)> =
169 sqlx::query_as("SELECT id FROM tasks WHERE team_id = $1 AND key = $2")
170 .bind(auth.team_id)
171 .bind(&dep_key)
172 .fetch_optional(pool)
173 .await?;
174 match dep {
175 Some((id,)) => dep_ids.push(id),
176 None => {
177 return Err(BusError::not_found(format!(
178 "dependency '{dep_key}' does not exist; create it first"
179 )));
180 }
181 }
182 }
183 dep_ids.sort();
184 dep_ids.dedup();
185
186 let (id,): (Uuid,) = sqlx::query_as(
187 r#"
188 INSERT INTO tasks (team_id, key, title, description, metadata, created_by)
189 VALUES ($1, $2, $3, $4, $5, $6)
190 RETURNING id
191 "#,
192 )
193 .bind(auth.team_id)
194 .bind(&key)
195 .bind(title)
196 .bind(input.description.as_deref())
197 .bind(&metadata)
198 .bind(auth.agent_id)
199 .fetch_one(pool)
200 .await?;
201
202 for dep_id in &dep_ids {
205 sqlx::query("INSERT INTO task_deps (task_id, blocked_by_task_id) VALUES ($1, $2)")
206 .bind(id)
207 .bind(dep_id)
208 .execute(pool)
209 .await?;
210 }
211
212 log_event(pool, id, auth.agent_id, "created", Some(title)).await?;
213 fetch_task(pool, auth, &key).await
214}
215
216async fn fetch_task(pool: &PgPool, auth: &AuthCtx, key: &str) -> BusResult<TaskInfo> {
217 let row: Option<TaskRow> = sqlx::query_as(&format!(
218 "{TASK_SELECT} WHERE t.team_id = $1 AND t.key = $2"
219 ))
220 .bind(auth.team_id)
221 .bind(key)
222 .fetch_optional(pool)
223 .await?;
224 row.map(Into::into)
225 .ok_or_else(|| BusError::not_found(format!("task '{key}'")))
226}
227
228pub async fn get_task(pool: &PgPool, auth: &AuthCtx, key: &str) -> BusResult<TaskDetail> {
229 let task = fetch_task(pool, auth, key).await?;
230 let rows: Vec<(
231 String,
232 Option<String>,
233 Option<String>,
234 chrono::DateTime<chrono::Utc>,
235 )> = sqlx::query_as(
236 r#"
237 SELECT e.event, a.name, e.detail, e.created_at
238 FROM task_events e
239 LEFT JOIN agents a ON a.id = e.agent_id
240 JOIN tasks t ON t.id = e.task_id
241 WHERE t.team_id = $1 AND t.key = $2
242 ORDER BY e.id
243 "#,
244 )
245 .bind(auth.team_id)
246 .bind(key.trim())
247 .fetch_all(pool)
248 .await?;
249
250 Ok(TaskDetail {
251 task,
252 history: rows
253 .into_iter()
254 .map(|(event, agent, detail, created_at)| TaskEventInfo {
255 event,
256 agent,
257 detail,
258 created_at: ts(created_at),
259 })
260 .collect(),
261 })
262}
263
264pub async fn list_tasks(
267 pool: &PgPool,
268 auth: &AuthCtx,
269 status: Option<String>,
270 mine_only: bool,
271 limit: i64,
272) -> BusResult<TaskList> {
273 let limit = limit.clamp(1, MAX_LIMIT);
274 let status = status
275 .map(|s| s.trim().to_lowercase())
276 .filter(|s| s != "any");
277 if let Some(s) = &status
278 && !["open", "claimed", "done", "cancelled"].contains(&s.as_str())
279 {
280 return Err(BusError::invalid(
281 "status must be one of: open, claimed, done, cancelled, any",
282 ));
283 }
284
285 let rows: Vec<TaskRow> = sqlx::query_as(&format!(
286 r#"{TASK_SELECT}
287 WHERE t.team_id = $1
288 AND ($2::text IS NULL OR t.status = $2)
289 AND (NOT $3::bool OR t.claimed_by = $4)
290 ORDER BY
291 CASE t.status WHEN 'claimed' THEN 0 WHEN 'open' THEN 1 ELSE 2 END,
292 t.updated_at DESC
293 LIMIT $5"#
294 ))
295 .bind(auth.team_id)
296 .bind(status.as_deref())
297 .bind(mine_only)
298 .bind(auth.agent_id)
299 .bind(limit)
300 .fetch_all(pool)
301 .await?;
302
303 let (open, claimed): (i64, i64) = sqlx::query_as(
304 r#"
305 SELECT count(*) FILTER (WHERE status = 'open'),
306 count(*) FILTER (WHERE status = 'claimed')
307 FROM tasks WHERE team_id = $1
308 "#,
309 )
310 .bind(auth.team_id)
311 .fetch_one(pool)
312 .await?;
313
314 Ok(TaskList {
315 tasks: rows.into_iter().map(Into::into).collect(),
316 open,
317 claimed,
318 })
319}
320
321pub async fn claim_task(
326 pool: &PgPool,
327 auth: &AuthCtx,
328 key: &str,
329 lease_seconds: Option<i64>,
330) -> BusResult<ClaimResult> {
331 let key = normalize_key(key)?;
332 let lease = lease_seconds
333 .unwrap_or(DEFAULT_LEASE_SECS)
334 .clamp(30, MAX_LEASE_SECS);
335
336 let updated: Option<(Uuid,)> = sqlx::query_as(
337 r#"
338 UPDATE tasks
339 SET status = 'claimed',
340 claimed_by = $1,
341 claimed_at = now(),
342 lease_expires_at = now() + make_interval(secs => $2),
343 updated_at = now()
344 WHERE team_id = $3
345 AND key = $4
346 AND status IN ('open', 'claimed')
347 AND (status = 'open'
348 OR claimed_by = $1
349 OR lease_expires_at IS NULL
350 OR lease_expires_at < now())
351 AND NOT EXISTS (
352 SELECT 1
353 FROM task_deps td JOIN tasks d ON d.id = td.blocked_by_task_id
354 WHERE td.task_id = tasks.id AND d.status NOT IN ('done', 'cancelled')
355 )
356 RETURNING id
357 "#,
358 )
359 .bind(auth.agent_id)
360 .bind(lease as f64)
361 .bind(auth.team_id)
362 .bind(&key)
363 .fetch_optional(pool)
364 .await?;
365
366 match updated {
367 Some((id,)) => {
368 log_event(pool, id, auth.agent_id, "claimed", None).await?;
369 Ok(ClaimResult {
370 claimed: true,
371 task: Some(fetch_task(pool, auth, &key).await?),
372 reason: None,
373 })
374 }
375 None => {
376 let current = fetch_task(pool, auth, &key).await?;
378 let reason = if current.blocked {
379 format!(
380 "blocked by unfinished dependencies: {}",
381 current.depends_on.join(", ")
382 )
383 } else {
384 match current.status.as_str() {
385 "claimed" => format!(
386 "held by {} until {}",
387 current.claimed_by.clone().unwrap_or_else(|| "?".into()),
388 current
389 .lease_expires_at
390 .clone()
391 .unwrap_or_else(|| "unknown".into())
392 ),
393 other => format!("task is {other}"),
394 }
395 };
396 Ok(ClaimResult {
397 claimed: false,
398 task: Some(current),
399 reason: Some(reason),
400 })
401 }
402 }
403}
404
405pub async fn claim_next_task(
408 pool: &PgPool,
409 auth: &AuthCtx,
410 lease_seconds: Option<i64>,
411) -> BusResult<ClaimResult> {
412 let lease = lease_seconds
413 .unwrap_or(DEFAULT_LEASE_SECS)
414 .clamp(30, MAX_LEASE_SECS);
415
416 let picked: Option<(Uuid, String)> = sqlx::query_as(
417 r#"
418 WITH candidate AS (
419 SELECT id
420 FROM tasks
421 WHERE team_id = $1
422 AND (status = 'open'
423 OR (status = 'claimed' AND lease_expires_at < now()))
424 AND NOT EXISTS (
425 SELECT 1
426 FROM task_deps td JOIN tasks d ON d.id = td.blocked_by_task_id
427 WHERE td.task_id = tasks.id AND d.status NOT IN ('done', 'cancelled')
428 )
429 ORDER BY created_at
430 LIMIT 1
431 FOR UPDATE SKIP LOCKED
432 )
433 UPDATE tasks t
434 SET status = 'claimed',
435 claimed_by = $2,
436 claimed_at = now(),
437 lease_expires_at = now() + make_interval(secs => $3),
438 updated_at = now()
439 FROM candidate c
440 WHERE t.id = c.id
441 RETURNING t.id, t.key
442 "#,
443 )
444 .bind(auth.team_id)
445 .bind(auth.agent_id)
446 .bind(lease as f64)
447 .fetch_optional(pool)
448 .await?;
449
450 match picked {
451 Some((id, key)) => {
452 log_event(
453 pool,
454 id,
455 auth.agent_id,
456 "claimed",
457 Some("via claim_next_task"),
458 )
459 .await?;
460 Ok(ClaimResult {
461 claimed: true,
462 task: Some(fetch_task(pool, auth, &key).await?),
463 reason: None,
464 })
465 }
466 None => Ok(ClaimResult {
467 claimed: false,
468 task: None,
469 reason: Some("no unclaimed task available".into()),
470 }),
471 }
472}
473
474pub async fn renew_lease(
475 pool: &PgPool,
476 auth: &AuthCtx,
477 key: &str,
478 lease_seconds: Option<i64>,
479) -> BusResult<TaskInfo> {
480 let key = normalize_key(key)?;
481 let lease = lease_seconds
482 .unwrap_or(DEFAULT_LEASE_SECS)
483 .clamp(30, MAX_LEASE_SECS);
484
485 let updated: Option<(Uuid,)> = sqlx::query_as(
486 r#"
487 UPDATE tasks
488 SET lease_expires_at = now() + make_interval(secs => $1),
489 updated_at = now()
490 WHERE team_id = $2 AND key = $3 AND claimed_by = $4 AND status = 'claimed'
491 RETURNING id
492 "#,
493 )
494 .bind(lease as f64)
495 .bind(auth.team_id)
496 .bind(&key)
497 .bind(auth.agent_id)
498 .fetch_optional(pool)
499 .await?;
500
501 if updated.is_none() {
502 return Err(BusError::conflict(format!(
503 "you do not hold an active claim on '{key}'"
504 )));
505 }
506 fetch_task(pool, auth, &key).await
507}
508
509pub async fn release_task(pool: &PgPool, auth: &AuthCtx, key: &str) -> BusResult<TaskInfo> {
510 let key = normalize_key(key)?;
511 let updated: Option<(Uuid,)> = sqlx::query_as(
512 r#"
513 UPDATE tasks
514 SET status = 'open',
515 claimed_by = NULL,
516 claimed_at = NULL,
517 lease_expires_at = NULL,
518 updated_at = now()
519 WHERE team_id = $1 AND key = $2 AND claimed_by = $3 AND status = 'claimed'
520 RETURNING id
521 "#,
522 )
523 .bind(auth.team_id)
524 .bind(&key)
525 .bind(auth.agent_id)
526 .fetch_optional(pool)
527 .await?;
528
529 match updated {
530 Some((id,)) => {
531 log_event(pool, id, auth.agent_id, "released", None).await?;
532 fetch_task(pool, auth, &key).await
533 }
534 None => Err(BusError::conflict(format!(
535 "you do not hold an active claim on '{key}'"
536 ))),
537 }
538}
539
540pub async fn complete_task(
541 pool: &PgPool,
542 auth: &AuthCtx,
543 key: &str,
544 result: Option<String>,
545) -> BusResult<TaskInfo> {
546 let key = normalize_key(key)?;
547 let updated: Option<(Uuid,)> = sqlx::query_as(
548 r#"
549 UPDATE tasks
550 SET status = 'done',
551 result = $1,
552 lease_expires_at = NULL,
553 updated_at = now()
554 WHERE team_id = $2 AND key = $3 AND status IN ('open', 'claimed')
555 RETURNING id
556 "#,
557 )
558 .bind(result.as_deref())
559 .bind(auth.team_id)
560 .bind(&key)
561 .fetch_optional(pool)
562 .await?;
563
564 match updated {
565 Some((id,)) => {
566 log_event(pool, id, auth.agent_id, "completed", result.as_deref()).await?;
567 fetch_task(pool, auth, &key).await
568 }
569 None => {
570 let current = fetch_task(pool, auth, &key).await?;
571 Err(BusError::conflict(format!(
572 "task '{key}' is already {}",
573 current.status
574 )))
575 }
576 }
577}