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