use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{
fs_atomic::write_file_atomic,
lock::RepoLock,
store::{HeddleError, Result},
};
pub const AGENT_TASK_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentTaskStatus {
Open,
InProgress,
Blocked,
Complete,
Abandoned,
}
impl std::fmt::Display for AgentTaskStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Open => write!(f, "open"),
Self::InProgress => write!(f, "in_progress"),
Self::Blocked => write!(f, "blocked"),
Self::Complete => write!(f, "complete"),
Self::Abandoned => write!(f, "abandoned"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentTaskRecord {
pub schema_version: u32,
pub task_id: String,
pub title: String,
pub body: String,
pub status: AgentTaskStatus,
pub target_thread: String,
#[serde(default)]
pub base_state: Option<String>,
#[serde(default)]
pub base_root: Option<String>,
#[serde(default)]
pub parent_task_id: Option<String>,
#[serde(default)]
pub coordination_discussion_id: Option<String>,
#[serde(default)]
pub allow_offline: bool,
#[serde(default)]
pub delegated_by: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(default)]
pub completed_at: Option<DateTime<Utc>>,
}
impl AgentTaskRecord {
pub fn new(task_id: String, title: String, target_thread: String) -> Self {
let now = Utc::now();
Self {
schema_version: AGENT_TASK_SCHEMA_VERSION,
task_id,
title,
body: String::new(),
status: AgentTaskStatus::Open,
target_thread,
base_state: None,
base_root: None,
parent_task_id: None,
coordination_discussion_id: None,
allow_offline: false,
delegated_by: None,
created_at: now,
updated_at: now,
completed_at: None,
}
}
}
pub struct AgentTaskStore {
tasks_dir: PathBuf,
}
impl AgentTaskStore {
pub fn new(heddle_dir: &Path) -> Self {
Self {
tasks_dir: heddle_dir.join("agent-tasks"),
}
}
fn task_path(&self, task_id: &str) -> Result<PathBuf> {
validate_task_id(task_id)?;
Ok(self.tasks_dir.join(format!("{task_id}.toml")))
}
fn lock_path(&self) -> PathBuf {
self.tasks_dir.join(".lock")
}
fn write_lock(&self) -> Result<crate::lock::WriteLockGuard> {
RepoLock::at(self.lock_path())
.write()
.map_err(|err| HeddleError::Config(format!("failed to acquire agent task lock: {err}")))
}
fn write_record_file(&self, record: &AgentTaskRecord) -> Result<()> {
std::fs::create_dir_all(&self.tasks_dir)?;
let path = self.task_path(&record.task_id)?;
let content =
toml::to_string_pretty(record).map_err(|err| HeddleError::Config(err.to_string()))?;
Ok(write_file_atomic(&path, content.as_bytes())?)
}
fn load_record_from_path(
&self,
path: &Path,
expected_task_id: &str,
) -> Result<Option<AgentTaskRecord>> {
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(path)?;
let record: AgentTaskRecord =
toml::from_str(&content).map_err(|err| HeddleError::Config(err.to_string()))?;
if record.task_id != expected_task_id {
return Err(HeddleError::Config(format!(
"agent task file '{}' contains mismatched task_id '{}'",
path.display(),
record.task_id
)));
}
Ok(Some(record))
}
pub fn create(&self, mut record: AgentTaskRecord) -> Result<AgentTaskRecord> {
let _lock = self.write_lock()?;
std::fs::create_dir_all(&self.tasks_dir)?;
if record.task_id.is_empty() {
record.task_id = generate_agent_task_id();
}
record.schema_version = AGENT_TASK_SCHEMA_VERSION;
validate_task_id(&record.task_id)?;
let path = self.task_path(&record.task_id)?;
if path.exists() {
return Err(HeddleError::Config(format!(
"agent task '{}' already exists",
record.task_id
)));
}
self.write_record_file(&record)?;
Ok(record)
}
pub fn load(&self, task_id: &str) -> Result<Option<AgentTaskRecord>> {
let path = self.task_path(task_id)?;
self.load_record_from_path(&path, task_id)
}
pub fn list(&self) -> Result<Vec<AgentTaskRecord>> {
if !self.tasks_dir.exists() {
return Ok(Vec::new());
}
let mut records = Vec::new();
for dir_entry in std::fs::read_dir(&self.tasks_dir)? {
let path = dir_entry?.path();
if path.extension().map(|ext| ext == "toml").unwrap_or(false) {
let Some(task_id) = path.file_stem().and_then(|stem| stem.to_str()) else {
continue;
};
validate_task_id(task_id)?;
if let Some(record) = self.load_record_from_path(&path, task_id)? {
records.push(record);
}
}
}
records.sort_by_key(|record| std::cmp::Reverse(record.updated_at));
Ok(records)
}
pub fn update<F>(&self, task_id: &str, mut update: F) -> Result<Option<AgentTaskRecord>>
where
F: FnMut(&mut AgentTaskRecord),
{
let _lock = self.write_lock()?;
let path = self.task_path(task_id)?;
let Some(mut record) = self.load_record_from_path(&path, task_id)? else {
return Ok(None);
};
update(&mut record);
if record.task_id != task_id {
return Err(HeddleError::Config(format!(
"agent task update attempted to change task_id from '{}' to '{}'",
task_id, record.task_id
)));
}
record.schema_version = AGENT_TASK_SCHEMA_VERSION;
record.updated_at = Utc::now();
record.completed_at = match record.status {
AgentTaskStatus::Complete | AgentTaskStatus::Abandoned => {
record.completed_at.or(Some(record.updated_at))
}
AgentTaskStatus::Open | AgentTaskStatus::InProgress | AgentTaskStatus::Blocked => None,
};
self.write_record_file(&record)?;
Ok(Some(record))
}
}
pub fn generate_agent_task_id() -> String {
format!("task-{}", uuid::Uuid::now_v7())
}
pub fn validate_task_id(task_id: &str) -> Result<()> {
if task_id.is_empty()
|| !task_id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
{
return Err(HeddleError::Config(format!(
"invalid task ID '{task_id}': only lowercase alphanumeric and hyphens allowed"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use tempfile::TempDir;
use super::*;
fn store() -> (TempDir, AgentTaskStore) {
let temp = TempDir::new().unwrap();
let store = AgentTaskStore::new(&temp.path().join(".heddle"));
(temp, store)
}
#[test]
fn agent_task_create_loads_toml_record() {
let (_temp, store) = store();
let mut task = AgentTaskRecord::new(
"task-demo".to_string(),
"Demo task".to_string(),
"main".into(),
);
task.body = "Do the thing".into();
task.base_state = Some("hd-base".into());
task.base_root = Some("root123".into());
let created = store.create(task).unwrap();
let loaded = store.load("task-demo").unwrap().unwrap();
assert_eq!(created.schema_version, AGENT_TASK_SCHEMA_VERSION);
assert_eq!(loaded.title, "Demo task");
assert_eq!(loaded.body, "Do the thing");
assert_eq!(loaded.target_thread, "main");
assert_eq!(loaded.base_state.as_deref(), Some("hd-base"));
assert_eq!(loaded.base_root.as_deref(), Some("root123"));
}
#[test]
fn agent_task_update_sets_completion_time_for_terminal_status() {
let (_temp, store) = store();
store
.create(AgentTaskRecord::new(
"task-update".to_string(),
"Update".to_string(),
"main".into(),
))
.unwrap();
let updated = store
.update("task-update", |task| {
task.status = AgentTaskStatus::Complete;
})
.unwrap()
.unwrap();
assert_eq!(updated.status, AgentTaskStatus::Complete);
assert!(updated.completed_at.is_some());
}
#[test]
fn agent_task_rejects_path_traversal_ids() {
let (_temp, store) = store();
let err = store.load("../nope").unwrap_err();
assert!(err.to_string().contains("invalid task ID"));
}
#[test]
fn agent_task_rejects_mismatched_filename_and_record_id() {
let (_temp, store) = store();
std::fs::create_dir_all(&store.tasks_dir).unwrap();
let record = AgentTaskRecord::new(
"task-other".to_string(),
"Tampered".to_string(),
"main".into(),
);
let content = toml::to_string_pretty(&record).unwrap();
std::fs::write(store.tasks_dir.join("task-requested.toml"), content).unwrap();
let err = store.load("task-requested").unwrap_err();
assert!(err.to_string().contains("mismatched task_id"));
}
#[test]
fn agent_task_update_rejects_identity_mutation() {
let (_temp, store) = store();
store
.create(AgentTaskRecord::new(
"task-stable".to_string(),
"Stable".to_string(),
"main".into(),
))
.unwrap();
let err = store
.update("task-stable", |task| {
task.task_id = "task-other".to_string();
})
.unwrap_err();
assert!(err.to_string().contains("attempted to change task_id"));
assert!(store.load("task-stable").unwrap().is_some());
assert!(store.load("task-other").unwrap().is_none());
}
}