Skip to main content

objects/store/
agent_task.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Local agent task assignment store.
3//!
4//! Stores one TOML file per task in `.heddle/agent-tasks/<task-id>.toml`.
5//! These records are local operational provenance: they explain delegated
6//! agent work without becoming source attribution or repository history.
7
8use 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
19/// Current agent task TOML schema version.
20pub const AGENT_TASK_SCHEMA_VERSION: u32 = 1;
21
22/// Lifecycle status for a local agent task assignment.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum AgentTaskStatus {
26    /// The task is delegated but no active reservation is known.
27    Open,
28    /// An agent is actively working the task.
29    InProgress,
30    /// Work is blocked on external input.
31    Blocked,
32    /// Work completed successfully.
33    Complete,
34    /// Work was abandoned or superseded.
35    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/// Local agent task assignment record.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct AgentTaskRecord {
53    /// Version of this TOML schema.
54    pub schema_version: u32,
55    /// Opaque stable task identifier.
56    pub task_id: String,
57    /// Human-readable task title.
58    pub title: String,
59    /// Detailed task body.
60    pub body: String,
61    /// Current task lifecycle status.
62    pub status: AgentTaskStatus,
63    /// Thread this task is delegated to.
64    pub target_thread: String,
65    /// Optional base state the task was delegated from.
66    #[serde(default)]
67    pub base_state: Option<String>,
68    /// Optional base root the task was delegated from.
69    #[serde(default)]
70    pub base_root: Option<String>,
71    /// Optional parent task.
72    #[serde(default)]
73    pub parent_task_id: Option<String>,
74    /// Optional coordination discussion id.
75    #[serde(default)]
76    pub coordination_discussion_id: Option<String>,
77    /// Whether this task may continue without hosted connectivity.
78    #[serde(default)]
79    pub allow_offline: bool,
80    /// Principal or agent that delegated the task.
81    #[serde(default)]
82    pub delegated_by: Option<String>,
83    /// Creation time.
84    pub created_at: DateTime<Utc>,
85    /// Last update time.
86    pub updated_at: DateTime<Utc>,
87    /// Completion time for terminal statuses.
88    #[serde(default)]
89    pub completed_at: Option<DateTime<Utc>>,
90}
91
92impl AgentTaskRecord {
93    /// Create a new local task assignment.
94    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
116/// Manages local task assignment records stored in `.heddle/agent-tasks/`.
117pub struct AgentTaskStore {
118    tasks_dir: PathBuf,
119}
120
121impl AgentTaskStore {
122    /// Create a task store backed by `<heddle_dir>/agent-tasks/`.
123    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    /// Create and persist a task, generating a task id when absent.
174    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    /// Load a task by id.
194    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    /// List all task records, most-recently-updated first.
200    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    /// Mutate an existing task under the task-store write lock.
222    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
251/// Generate a local task assignment id.
252pub fn generate_agent_task_id() -> String {
253    format!("task-{}", uuid::Uuid::now_v7())
254}
255
256/// Validate a task id for direct use as a single TOML filename stem.
257pub 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}