Skip to main content

pe_tasks/
file.rs

1//! # FileTaskRegistry - JSON-backed task storage.
2//!
3//! A small persistent registry for local agents. It keeps the same semantics as
4//! [`InMemoryTaskRegistry`](crate::InMemoryTaskRegistry) while storing tasks and
5//! dependencies in a workspace file.
6
7use std::collections::HashMap;
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::sync::{Mutex, MutexGuard};
11
12use chrono::Utc;
13use pe_core::PeError;
14use serde::{Deserialize, Serialize};
15
16use crate::dependency::{self, TaskDependency};
17use crate::lifecycle::{DefaultLifecycle, TaskLifecycle};
18use crate::registry::{TaskFilter, TaskRegistry};
19use crate::task::{Task, TaskId, TaskStatus};
20
21#[derive(Clone, Debug, Serialize, Deserialize)]
22struct TaskStoreSnapshot {
23    #[serde(default = "current_version")]
24    version: u32,
25    #[serde(default)]
26    tasks: Vec<Task>,
27    #[serde(default)]
28    dependencies: Vec<TaskDependency>,
29}
30
31impl Default for TaskStoreSnapshot {
32    fn default() -> Self {
33        Self {
34            version: current_version(),
35            tasks: Vec::new(),
36            dependencies: Vec::new(),
37        }
38    }
39}
40
41fn current_version() -> u32 {
42    1
43}
44
45/// JSON-file-backed [`TaskRegistry`].
46///
47/// This backend is meant for local app use: durable across short-lived process
48/// launches, simple to inspect, and dependency-free. It is not a multi-writer
49/// database; concurrent writers can still race at the file level.
50pub struct FileTaskRegistry {
51    path: PathBuf,
52    state: Mutex<TaskStoreSnapshot>,
53    lifecycle: Box<dyn TaskLifecycle>,
54}
55
56impl FileTaskRegistry {
57    /// Create a registry backed by `path` with the default lifecycle.
58    pub fn new(path: impl Into<PathBuf>) -> Result<Self, PeError> {
59        Self::with_lifecycle(path, DefaultLifecycle)
60    }
61
62    /// Create a registry backed by `path` with a custom lifecycle.
63    pub fn with_lifecycle(
64        path: impl Into<PathBuf>,
65        lifecycle: impl TaskLifecycle + 'static,
66    ) -> Result<Self, PeError> {
67        let path = path.into();
68        let state = load_snapshot(&path)?;
69        Ok(Self {
70            path,
71            state: Mutex::new(state),
72            lifecycle: Box::new(lifecycle),
73        })
74    }
75
76    /// Path to the JSON store.
77    #[must_use]
78    pub fn path(&self) -> &Path {
79        &self.path
80    }
81
82    fn state_guard(&self) -> MutexGuard<'_, TaskStoreSnapshot> {
83        match self.state.lock() {
84            Ok(guard) => guard,
85            Err(poisoned) => poisoned.into_inner(),
86        }
87    }
88
89    fn persist_snapshot(&self, snapshot: &TaskStoreSnapshot) -> Result<(), PeError> {
90        if let Some(parent) = self.path.parent() {
91            fs::create_dir_all(parent).map_err(|error| PeError::Storage {
92                details: format!("failed to create task store {}: {error}", parent.display()),
93            })?;
94        }
95        let content = serde_json::to_string_pretty(snapshot).map_err(|error| PeError::Storage {
96            details: format!(
97                "failed to serialize task store {}: {error}",
98                self.path.display()
99            ),
100        })?;
101        fs::write(&self.path, content).map_err(|error| PeError::Storage {
102            details: format!(
103                "failed to write task store {}: {error}",
104                self.path.display()
105            ),
106        })
107    }
108
109    fn collect_tree(root_id: &str, tasks: &[Task]) -> Vec<Task> {
110        let mut result = Vec::new();
111        let mut stack = vec![root_id.to_string()];
112        while let Some(id) = stack.pop() {
113            for task in tasks {
114                if task.parent_task_id.as_deref() == Some(&id) && task.deleted_at.is_none() {
115                    stack.push(task.id.clone());
116                    result.push(task.clone());
117                }
118            }
119        }
120        result
121    }
122}
123
124#[async_trait::async_trait]
125impl TaskRegistry for FileTaskRegistry {
126    async fn create(&self, task: Task) -> Result<Task, PeError> {
127        {
128            let mut state = self.state_guard();
129            let mut next = state.clone();
130            dependency::validate_parent_link(&task, &next.tasks)?;
131            next.tasks.push(task.clone());
132            self.persist_snapshot(&next)?;
133            *state = next;
134        }
135        self.lifecycle.on_create(&task);
136        Ok(task)
137    }
138
139    async fn get(&self, id: &TaskId) -> Result<Option<Task>, PeError> {
140        let state = self.state_guard();
141        Ok(state
142            .tasks
143            .iter()
144            .find(|task| task.id == *id && task.deleted_at.is_none())
145            .cloned())
146    }
147
148    async fn update(&self, task: &Task) -> Result<Task, PeError> {
149        let mut updated = task.clone();
150        updated.updated_at = Some(Utc::now());
151
152        let mut state = self.state_guard();
153        let mut next = state.clone();
154        dependency::validate_parent_link(&updated, &next.tasks)?;
155        if let Some(existing) = next
156            .tasks
157            .iter_mut()
158            .find(|existing| existing.id == task.id)
159        {
160            *existing = updated.clone();
161        } else {
162            next.tasks.push(updated.clone());
163        }
164        self.persist_snapshot(&next)?;
165        *state = next;
166        Ok(updated)
167    }
168
169    async fn delete(&self, id: &TaskId) -> Result<bool, PeError> {
170        let mut state = self.state_guard();
171        let mut next = state.clone();
172        let Some(task) = next.tasks.iter_mut().find(|task| task.id == *id) else {
173            return Ok(false);
174        };
175        task.deleted_at = Some(Utc::now());
176        self.persist_snapshot(&next)?;
177        *state = next;
178        Ok(true)
179    }
180
181    async fn restore(&self, id: &TaskId) -> Result<bool, PeError> {
182        let mut state = self.state_guard();
183        let mut next = state.clone();
184        let Some(task) = next
185            .tasks
186            .iter_mut()
187            .find(|task| task.id == *id && task.deleted_at.is_some())
188        else {
189            return Ok(false);
190        };
191        task.deleted_at = None;
192        self.persist_snapshot(&next)?;
193        *state = next;
194        Ok(true)
195    }
196
197    async fn update_status(
198        &self,
199        id: &TaskId,
200        status: TaskStatus,
201        result: Option<serde_json::Value>,
202        error: Option<String>,
203    ) -> Result<Task, PeError> {
204        let (updated, old_status) = {
205            let mut state = self.state_guard();
206            let mut next = state.clone();
207            let task = next
208                .tasks
209                .iter_mut()
210                .find(|task| task.id == *id)
211                .ok_or_else(|| PeError::NodeNotFound { node: id.clone() })?;
212
213            self.lifecycle.validate_transition(task, &status)?;
214
215            let old_status = task.status.clone();
216            task.status = status.clone();
217            task.updated_at = Some(Utc::now());
218            if let Some(result) = result {
219                task.result = Some(result);
220            }
221            if let Some(error) = error {
222                task.error = Some(error);
223            }
224            if status == TaskStatus::Completed {
225                task.completed_at = Some(Utc::now());
226            }
227            let updated = task.clone();
228
229            self.persist_snapshot(&next)?;
230            *state = next;
231            (updated, old_status)
232        };
233
234        self.lifecycle.on_transition(&updated, &old_status);
235        match &status {
236            TaskStatus::Completed => self.lifecycle.on_complete(&updated),
237            TaskStatus::Failed => self
238                .lifecycle
239                .on_fail(&updated, updated.error.as_deref().unwrap_or("")),
240            TaskStatus::Cancelled => self.lifecycle.on_cancel(&updated),
241            _ => {}
242        }
243
244        Ok(updated)
245    }
246
247    async fn list(&self, filter: &TaskFilter) -> Result<Vec<Task>, PeError> {
248        let state = self.state_guard();
249        let limit = if filter.limit == 0 { 100 } else { filter.limit };
250        let results = state
251            .tasks
252            .iter()
253            .filter(|task| filter.include_deleted || task.deleted_at.is_none())
254            .filter(|task| filter.status.as_ref().is_none_or(|s| task.status == *s))
255            .filter(|task| {
256                filter
257                    .task_type
258                    .as_ref()
259                    .is_none_or(|task_type| task.task_type == *task_type)
260            })
261            .filter(|task| {
262                filter
263                    .priority
264                    .as_ref()
265                    .is_none_or(|priority| task.priority == *priority)
266            })
267            .filter(|task| {
268                filter
269                    .agent_id
270                    .as_ref()
271                    .is_none_or(|agent| task.agent_id.as_ref() == Some(agent))
272            })
273            .filter(|task| {
274                filter
275                    .assignee
276                    .as_ref()
277                    .is_none_or(|assignee| task.assignee == *assignee)
278            })
279            .filter(|task| {
280                filter
281                    .parent_id
282                    .as_ref()
283                    .is_none_or(|parent| task.parent_task_id.as_ref() == Some(parent))
284            })
285            .filter(|task| {
286                filter
287                    .tag
288                    .as_ref()
289                    .is_none_or(|tag| task.tags.contains(tag))
290            })
291            .take(limit)
292            .cloned()
293            .collect();
294        Ok(results)
295    }
296
297    async fn get_subtasks(&self, parent_id: &TaskId) -> Result<Vec<Task>, PeError> {
298        let state = self.state_guard();
299        Ok(state
300            .tasks
301            .iter()
302            .filter(|task| {
303                task.parent_task_id.as_ref() == Some(parent_id) && task.deleted_at.is_none()
304            })
305            .cloned()
306            .collect())
307    }
308
309    async fn get_tree(&self, root_id: &TaskId) -> Result<Vec<Task>, PeError> {
310        let state = self.state_guard();
311        Ok(Self::collect_tree(root_id, &state.tasks))
312    }
313
314    async fn add_dependency(&self, dep: TaskDependency) -> Result<(), PeError> {
315        let mut state = self.state_guard();
316        let mut next = state.clone();
317        dependency::validate_dependency_endpoints(&dep, &next.tasks)?;
318        dependency::validate_dependency_unique(&dep, &next.dependencies)?;
319        if dependency::would_create_cycle(&dep.task_id, &dep.depends_on_id, &next.dependencies) {
320            return Err(PeError::InvalidUpdate {
321                details: format!(
322                    "Adding dependency {} -> {} would create a cycle",
323                    dep.task_id, dep.depends_on_id
324                ),
325            });
326        }
327        next.dependencies.push(dep);
328        self.persist_snapshot(&next)?;
329        *state = next;
330        Ok(())
331    }
332
333    async fn remove_dependency(
334        &self,
335        task_id: &TaskId,
336        depends_on_id: &TaskId,
337    ) -> Result<(), PeError> {
338        let mut state = self.state_guard();
339        let mut next = state.clone();
340        next.dependencies
341            .retain(|dep| !(dep.task_id == *task_id && dep.depends_on_id == *depends_on_id));
342        self.persist_snapshot(&next)?;
343        *state = next;
344        Ok(())
345    }
346
347    async fn get_dependencies(&self, task_id: &TaskId) -> Result<Vec<TaskDependency>, PeError> {
348        let state = self.state_guard();
349        Ok(state
350            .dependencies
351            .iter()
352            .filter(|dep| dep.task_id == *task_id)
353            .cloned()
354            .collect())
355    }
356
357    async fn get_dependents(&self, task_id: &TaskId) -> Result<Vec<TaskDependency>, PeError> {
358        let state = self.state_guard();
359        Ok(state
360            .dependencies
361            .iter()
362            .filter(|dep| dep.depends_on_id == *task_id)
363            .cloned()
364            .collect())
365    }
366
367    async fn get_ready_tasks(&self) -> Result<Vec<Task>, PeError> {
368        let state = self.state_guard();
369        let pending = state
370            .tasks
371            .iter()
372            .filter(|task| task.status == TaskStatus::Pending && task.deleted_at.is_none())
373            .map(|task| task.id.clone())
374            .collect::<Vec<_>>();
375        let statuses: HashMap<TaskId, TaskStatus> = state
376            .tasks
377            .iter()
378            .map(|task| (task.id.clone(), task.status.clone()))
379            .collect();
380        let ready_ids = dependency::find_ready_tasks(&pending, &state.dependencies, &statuses);
381        Ok(ready_ids
382            .iter()
383            .filter_map(|id| state.tasks.iter().find(|task| task.id == *id).cloned())
384            .collect())
385    }
386}
387
388fn load_snapshot(path: &Path) -> Result<TaskStoreSnapshot, PeError> {
389    let content = match fs::read_to_string(path) {
390        Ok(content) => content,
391        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
392            return Ok(TaskStoreSnapshot::default());
393        }
394        Err(error) => {
395            return Err(PeError::Storage {
396                details: format!("failed to read task store {}: {error}", path.display()),
397            });
398        }
399    };
400    if content.trim().is_empty() {
401        return Ok(TaskStoreSnapshot::default());
402    }
403    serde_json::from_str(&content).map_err(|error| PeError::Storage {
404        details: format!("failed to parse task store {}: {error}", path.display()),
405    })
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use crate::dependency::DependencyType;
412
413    fn temp_path(name: &str) -> PathBuf {
414        std::env::temp_dir().join(format!("pe-tasks-{name}-{}.json", uuid::Uuid::new_v4()))
415    }
416
417    #[tokio::test]
418    async fn persists_tasks_between_instances() {
419        let path = temp_path("persist");
420        let registry = FileTaskRegistry::new(&path).unwrap();
421        let created = registry
422            .create(Task::agent_task("Persistent task", "agent-1"))
423            .await
424            .unwrap();
425        drop(registry);
426
427        let reloaded = FileTaskRegistry::new(&path).unwrap();
428        let task = reloaded.get(&created.id).await.unwrap().unwrap();
429        assert_eq!(task.title, "Persistent task");
430        assert_eq!(task.agent_id.as_deref(), Some("agent-1"));
431
432        let _ = fs::remove_file(path);
433    }
434
435    #[tokio::test]
436    async fn persists_dependencies_between_instances() {
437        let path = temp_path("deps");
438        let registry = FileTaskRegistry::new(&path).unwrap();
439        let blocker = registry.create(Task::new("Blocker")).await.unwrap();
440        let blocked = registry.create(Task::new("Blocked")).await.unwrap();
441        registry
442            .add_dependency(TaskDependency::new(
443                &blocked.id,
444                &blocker.id,
445                DependencyType::Blocks,
446            ))
447            .await
448            .unwrap();
449        drop(registry);
450
451        let reloaded = FileTaskRegistry::new(&path).unwrap();
452        let deps = reloaded.get_dependencies(&blocked.id).await.unwrap();
453        assert_eq!(deps.len(), 1);
454        assert_eq!(deps[0].depends_on_id, blocker.id);
455
456        let _ = fs::remove_file(path);
457    }
458
459    #[tokio::test]
460    async fn validates_dependency_endpoints_and_duplicates() {
461        let path = temp_path("dep-validation");
462        let registry = FileTaskRegistry::new(&path).unwrap();
463        let blocker = registry.create(Task::new("Blocker")).await.unwrap();
464        let blocked = registry.create(Task::new("Blocked")).await.unwrap();
465
466        let err = registry
467            .add_dependency(TaskDependency::new(
468                &blocked.id,
469                "missing",
470                DependencyType::Blocks,
471            ))
472            .await
473            .unwrap_err();
474        assert!(matches!(err, PeError::NodeNotFound { node } if node == "missing"));
475
476        registry
477            .add_dependency(TaskDependency::new(
478                &blocked.id,
479                &blocker.id,
480                DependencyType::Blocks,
481            ))
482            .await
483            .unwrap();
484
485        let err = registry
486            .add_dependency(TaskDependency::new(
487                &blocked.id,
488                &blocker.id,
489                DependencyType::Related,
490            ))
491            .await
492            .unwrap_err();
493        assert!(
494            matches!(err, PeError::InvalidUpdate { details } if details.contains("already exists"))
495        );
496
497        let _ = fs::remove_file(path);
498    }
499
500    #[tokio::test]
501    async fn validates_parent_links() {
502        let path = temp_path("parent-validation");
503        let registry = FileTaskRegistry::new(&path).unwrap();
504
505        let err = registry
506            .create(Task::new("Orphan").with_parent("missing-parent"))
507            .await
508            .unwrap_err();
509        assert!(matches!(err, PeError::NodeNotFound { node } if node == "missing-parent"));
510
511        let parent = registry.create(Task::new("Parent")).await.unwrap();
512        let child = registry
513            .create(Task::new("Child").with_parent(&parent.id))
514            .await
515            .unwrap();
516        let mut updated_parent = parent.clone();
517        updated_parent.parent_task_id = Some(child.id.clone());
518
519        let err = registry.update(&updated_parent).await.unwrap_err();
520        assert!(matches!(err, PeError::InvalidUpdate { .. }));
521
522        let stored_parent = registry.get(&parent.id).await.unwrap().unwrap();
523        assert!(stored_parent.parent_task_id.is_none());
524
525        let _ = fs::remove_file(path);
526    }
527}