1use std::path::{Path, PathBuf};
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13use crate::{
14 fs_atomic::write_file_atomic,
15 lock::RepoLock,
16 store::{HeddleError, Result},
17};
18
19pub const AGENT_TASK_SCHEMA_VERSION: u32 = 1;
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum AgentTaskStatus {
26 Open,
28 InProgress,
30 Blocked,
32 Complete,
34 Abandoned,
36}
37
38impl std::fmt::Display for AgentTaskStatus {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 Self::Open => write!(f, "open"),
42 Self::InProgress => write!(f, "in_progress"),
43 Self::Blocked => write!(f, "blocked"),
44 Self::Complete => write!(f, "complete"),
45 Self::Abandoned => write!(f, "abandoned"),
46 }
47 }
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct AgentTaskRecord {
53 pub schema_version: u32,
55 pub task_id: String,
57 pub title: String,
59 pub body: String,
61 pub status: AgentTaskStatus,
63 pub target_thread: String,
65 #[serde(default)]
67 pub base_state: Option<String>,
68 #[serde(default)]
70 pub base_root: Option<String>,
71 #[serde(default)]
73 pub parent_task_id: Option<String>,
74 #[serde(default)]
76 pub coordination_discussion_id: Option<String>,
77 #[serde(default)]
79 pub allow_offline: bool,
80 #[serde(default)]
82 pub delegated_by: Option<String>,
83 pub created_at: DateTime<Utc>,
85 pub updated_at: DateTime<Utc>,
87 #[serde(default)]
89 pub completed_at: Option<DateTime<Utc>>,
90}
91
92impl AgentTaskRecord {
93 pub fn new(task_id: String, title: String, target_thread: String) -> Self {
95 let now = Utc::now();
96 Self {
97 schema_version: AGENT_TASK_SCHEMA_VERSION,
98 task_id,
99 title,
100 body: String::new(),
101 status: AgentTaskStatus::Open,
102 target_thread,
103 base_state: None,
104 base_root: None,
105 parent_task_id: None,
106 coordination_discussion_id: None,
107 allow_offline: false,
108 delegated_by: None,
109 created_at: now,
110 updated_at: now,
111 completed_at: None,
112 }
113 }
114}
115
116pub struct AgentTaskStore {
118 tasks_dir: PathBuf,
119}
120
121impl AgentTaskStore {
122 pub fn new(heddle_dir: &Path) -> Self {
124 Self {
125 tasks_dir: heddle_dir.join("agent-tasks"),
126 }
127 }
128
129 fn task_path(&self, task_id: &str) -> Result<PathBuf> {
130 validate_task_id(task_id)?;
131 Ok(self.tasks_dir.join(format!("{task_id}.toml")))
132 }
133
134 fn lock_path(&self) -> PathBuf {
135 self.tasks_dir.join(".lock")
136 }
137
138 fn write_lock(&self) -> Result<crate::lock::WriteLockGuard> {
139 RepoLock::at(self.lock_path())
140 .write()
141 .map_err(|err| HeddleError::Config(format!("failed to acquire agent task lock: {err}")))
142 }
143
144 fn write_record_file(&self, record: &AgentTaskRecord) -> Result<()> {
145 std::fs::create_dir_all(&self.tasks_dir)?;
146 let path = self.task_path(&record.task_id)?;
147 let content =
148 toml::to_string_pretty(record).map_err(|err| HeddleError::Config(err.to_string()))?;
149 Ok(write_file_atomic(&path, content.as_bytes())?)
150 }
151
152 fn load_record_from_path(
153 &self,
154 path: &Path,
155 expected_task_id: &str,
156 ) -> Result<Option<AgentTaskRecord>> {
157 if !path.exists() {
158 return Ok(None);
159 }
160 let content = std::fs::read_to_string(path)?;
161 let record: AgentTaskRecord =
162 toml::from_str(&content).map_err(|err| HeddleError::Config(err.to_string()))?;
163 if record.task_id != expected_task_id {
164 return Err(HeddleError::Config(format!(
165 "agent task file '{}' contains mismatched task_id '{}'",
166 path.display(),
167 record.task_id
168 )));
169 }
170 Ok(Some(record))
171 }
172
173 pub fn create(&self, mut record: AgentTaskRecord) -> Result<AgentTaskRecord> {
175 let _lock = self.write_lock()?;
176 std::fs::create_dir_all(&self.tasks_dir)?;
177 if record.task_id.is_empty() {
178 record.task_id = generate_agent_task_id();
179 }
180 record.schema_version = AGENT_TASK_SCHEMA_VERSION;
181 validate_task_id(&record.task_id)?;
182 let path = self.task_path(&record.task_id)?;
183 if path.exists() {
184 return Err(HeddleError::Config(format!(
185 "agent task '{}' already exists",
186 record.task_id
187 )));
188 }
189 self.write_record_file(&record)?;
190 Ok(record)
191 }
192
193 pub fn load(&self, task_id: &str) -> Result<Option<AgentTaskRecord>> {
195 let path = self.task_path(task_id)?;
196 self.load_record_from_path(&path, task_id)
197 }
198
199 pub fn list(&self) -> Result<Vec<AgentTaskRecord>> {
201 if !self.tasks_dir.exists() {
202 return Ok(Vec::new());
203 }
204 let mut records = Vec::new();
205 for dir_entry in std::fs::read_dir(&self.tasks_dir)? {
206 let path = dir_entry?.path();
207 if path.extension().map(|ext| ext == "toml").unwrap_or(false) {
208 let Some(task_id) = path.file_stem().and_then(|stem| stem.to_str()) else {
209 continue;
210 };
211 validate_task_id(task_id)?;
212 if let Some(record) = self.load_record_from_path(&path, task_id)? {
213 records.push(record);
214 }
215 }
216 }
217 records.sort_by_key(|record| std::cmp::Reverse(record.updated_at));
218 Ok(records)
219 }
220
221 pub fn update<F>(&self, task_id: &str, mut update: F) -> Result<Option<AgentTaskRecord>>
223 where
224 F: FnMut(&mut AgentTaskRecord),
225 {
226 let _lock = self.write_lock()?;
227 let path = self.task_path(task_id)?;
228 let Some(mut record) = self.load_record_from_path(&path, task_id)? else {
229 return Ok(None);
230 };
231 update(&mut record);
232 if record.task_id != task_id {
233 return Err(HeddleError::Config(format!(
234 "agent task update attempted to change task_id from '{}' to '{}'",
235 task_id, record.task_id
236 )));
237 }
238 record.schema_version = AGENT_TASK_SCHEMA_VERSION;
239 record.updated_at = Utc::now();
240 record.completed_at = match record.status {
241 AgentTaskStatus::Complete | AgentTaskStatus::Abandoned => {
242 record.completed_at.or(Some(record.updated_at))
243 }
244 AgentTaskStatus::Open | AgentTaskStatus::InProgress | AgentTaskStatus::Blocked => None,
245 };
246 self.write_record_file(&record)?;
247 Ok(Some(record))
248 }
249}
250
251pub fn generate_agent_task_id() -> String {
253 format!("task-{}", uuid::Uuid::now_v7())
254}
255
256pub fn validate_task_id(task_id: &str) -> Result<()> {
258 if task_id.is_empty()
259 || !task_id
260 .bytes()
261 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
262 {
263 return Err(HeddleError::Config(format!(
264 "invalid task ID '{task_id}': only lowercase alphanumeric and hyphens allowed"
265 )));
266 }
267 Ok(())
268}
269
270#[cfg(test)]
271mod tests {
272 use tempfile::TempDir;
273
274 use super::*;
275
276 fn store() -> (TempDir, AgentTaskStore) {
277 let temp = TempDir::new().unwrap();
278 let store = AgentTaskStore::new(&temp.path().join(".heddle"));
279 (temp, store)
280 }
281
282 #[test]
283 fn agent_task_create_loads_toml_record() {
284 let (_temp, store) = store();
285 let mut task = AgentTaskRecord::new(
286 "task-demo".to_string(),
287 "Demo task".to_string(),
288 "main".into(),
289 );
290 task.body = "Do the thing".into();
291 task.base_state = Some("hd-base".into());
292 task.base_root = Some("root123".into());
293
294 let created = store.create(task).unwrap();
295 let loaded = store.load("task-demo").unwrap().unwrap();
296
297 assert_eq!(created.schema_version, AGENT_TASK_SCHEMA_VERSION);
298 assert_eq!(loaded.title, "Demo task");
299 assert_eq!(loaded.body, "Do the thing");
300 assert_eq!(loaded.target_thread, "main");
301 assert_eq!(loaded.base_state.as_deref(), Some("hd-base"));
302 assert_eq!(loaded.base_root.as_deref(), Some("root123"));
303 }
304
305 #[test]
306 fn agent_task_update_sets_completion_time_for_terminal_status() {
307 let (_temp, store) = store();
308 store
309 .create(AgentTaskRecord::new(
310 "task-update".to_string(),
311 "Update".to_string(),
312 "main".into(),
313 ))
314 .unwrap();
315
316 let updated = store
317 .update("task-update", |task| {
318 task.status = AgentTaskStatus::Complete;
319 })
320 .unwrap()
321 .unwrap();
322
323 assert_eq!(updated.status, AgentTaskStatus::Complete);
324 assert!(updated.completed_at.is_some());
325 }
326
327 #[test]
328 fn agent_task_rejects_path_traversal_ids() {
329 let (_temp, store) = store();
330 let err = store.load("../nope").unwrap_err();
331 assert!(err.to_string().contains("invalid task ID"));
332 }
333
334 #[test]
335 fn agent_task_rejects_mismatched_filename_and_record_id() {
336 let (_temp, store) = store();
337 std::fs::create_dir_all(&store.tasks_dir).unwrap();
338 let record = AgentTaskRecord::new(
339 "task-other".to_string(),
340 "Tampered".to_string(),
341 "main".into(),
342 );
343 let content = toml::to_string_pretty(&record).unwrap();
344 std::fs::write(store.tasks_dir.join("task-requested.toml"), content).unwrap();
345
346 let err = store.load("task-requested").unwrap_err();
347 assert!(err.to_string().contains("mismatched task_id"));
348 }
349
350 #[test]
351 fn agent_task_update_rejects_identity_mutation() {
352 let (_temp, store) = store();
353 store
354 .create(AgentTaskRecord::new(
355 "task-stable".to_string(),
356 "Stable".to_string(),
357 "main".into(),
358 ))
359 .unwrap();
360
361 let err = store
362 .update("task-stable", |task| {
363 task.task_id = "task-other".to_string();
364 })
365 .unwrap_err();
366
367 assert!(err.to_string().contains("attempted to change task_id"));
368 assert!(store.load("task-stable").unwrap().is_some());
369 assert!(store.load("task-other").unwrap().is_none());
370 }
371}