pub mod store;
pub mod worker;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use self::store::TaskStore;
static TASK_STORE: std::sync::OnceLock<TaskStore> = std::sync::OnceLock::new();
pub fn init_task_store(eli_home: &std::path::Path) {
let db_path = eli_home.join("taskboard.db");
match TaskStore::open(db_path) {
Ok(store) => {
let _ = TASK_STORE.set(store);
}
Err(e) => {
tracing::error!("failed to initialize taskboard: {e}");
}
}
}
pub fn task_store() -> Option<&'static TaskStore> {
TASK_STORE.get()
}
pub type TaskId = Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
pub id: TaskId,
pub kind: String,
pub status: Status,
pub parent: Option<TaskId>,
pub session_origin: String,
pub context: serde_json::Value,
pub result: Option<serde_json::Value>,
pub assigned_to: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub priority: u8,
pub metadata: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum Status {
Todo,
Claimed {
agent_id: String,
claimed_at: DateTime<Utc>,
},
Running {
progress: f32,
last_heartbeat: DateTime<Utc>,
},
Done,
Failed {
error: String,
agent_id: Option<String>,
stage: Option<String>,
tool_trace: Vec<String>,
retries: u32,
suggested_fix: Option<String>,
},
Blocked {
reason: String,
waiting_on: Option<TaskId>,
},
Cancelled {
reason: String,
},
}
impl Status {
pub fn label(&self) -> &'static str {
match self {
Status::Todo => "todo",
Status::Claimed { .. } => "claimed",
Status::Running { .. } => "running",
Status::Done => "done",
Status::Failed { .. } => "failed",
Status::Blocked { .. } => "blocked",
Status::Cancelled { .. } => "cancelled",
}
}
pub fn is_terminal(&self) -> bool {
matches!(
self,
Status::Done | Status::Failed { .. } | Status::Cancelled { .. }
)
}
}
#[derive(Debug, Clone)]
pub enum TaskEvent {
Created(TaskId),
StatusChanged {
id: TaskId,
from: Box<Status>,
to: Box<Status>,
},
Completed {
id: TaskId,
result: serde_json::Value,
},
Failed {
id: TaskId,
error: String,
},
}
#[derive(Debug, Default, Clone)]
pub struct TaskFilter {
pub status: Option<String>,
pub kind: Option<String>,
pub parent: Option<TaskId>,
pub session_origin: Option<String>,
pub limit: Option<usize>,
}
pub struct NewTask {
pub kind: String,
pub session_origin: String,
pub context: serde_json::Value,
pub parent: Option<TaskId>,
pub priority: u8,
pub metadata: serde_json::Value,
}
impl NewTask {
pub fn into_task(self) -> Task {
let now = Utc::now();
Task {
id: Uuid::new_v4(),
kind: self.kind,
status: Status::Todo,
parent: self.parent,
session_origin: self.session_origin,
context: self.context,
result: None,
assigned_to: None,
created_at: now,
updated_at: now,
priority: self.priority,
metadata: self.metadata,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_labels() {
assert_eq!(Status::Todo.label(), "todo");
assert_eq!(Status::Done.label(), "done");
assert!(Status::Done.is_terminal());
assert!(!Status::Todo.is_terminal());
}
#[test]
fn new_task_builder() {
let task = NewTask {
kind: "explore".into(),
session_origin: "test-session".into(),
context: serde_json::json!({"prompt": "find callers"}),
parent: None,
priority: 1,
metadata: serde_json::Value::Null,
}
.into_task();
assert_eq!(task.kind, "explore");
assert_eq!(task.status.label(), "todo");
assert!(task.result.is_none());
}
#[test]
fn status_serialization_roundtrip() {
let status = Status::Failed {
error: "timeout".into(),
agent_id: Some("worker-1".into()),
stage: Some("execute".into()),
tool_trace: vec!["fs.read".into()],
retries: 2,
suggested_fix: Some("increase timeout".into()),
};
let json = serde_json::to_string(&status).unwrap();
let parsed: Status = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.label(), "failed");
}
}