Skip to main content

lean_ctx/core/a2a/task/
store.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::fs::{File, OpenOptions};
6use std::io::{Read, Write};
7use std::path::{Path, PathBuf};
8
9use super::authority::TaskDescriptorV1;
10use super::model::{Task, TaskPart, TaskState};
11use super::{
12    IDEMPOTENCY_RETENTION_SECONDS, MAX_DESCRIPTION_BYTES, MAX_HISTORY, MAX_ID_BYTES,
13    MAX_IDEMPOTENCY_RECORDS, MAX_MESSAGES, MAX_METADATA_ENTRIES, MAX_METADATA_VALUE_BYTES,
14    MAX_PARTS, MAX_PARTS_PER_MESSAGE, MAX_TASK_STORE_BYTES, MAX_TASKS, MAX_TEXT_BYTES,
15    TASK_DESCRIPTOR_VERSION,
16};
17
18use crate::core::context_admission::carrier::{Withheld, admit_for_store};
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21#[serde(deny_unknown_fields)]
22pub(super) struct TaskIdempotencyRecordV1 {
23    schema_version: u32,
24    key: String,
25    sender: String,
26    recipient: String,
27    tenant_id: String,
28    project_id: String,
29    action: String,
30    key_id: String,
31    descriptor_digest: String,
32    task_id: String,
33    pub(super) created_at: DateTime<Utc>,
34}
35
36impl TaskIdempotencyRecordV1 {
37    fn matches_authority(&self, descriptor: &TaskDescriptorV1) -> bool {
38        self.sender == descriptor.sender
39            && self.recipient == descriptor.recipient
40            && self.tenant_id == descriptor.tenant_id
41            && self.project_id == descriptor.project_id
42            && self.action == descriptor.action
43            && self.key_id == descriptor.key_id
44    }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct RemoteTaskMaterialization {
49    pub task_id: String,
50    pub duplicate: bool,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, Default)]
54pub struct TaskStore {
55    pub tasks: Vec<Task>,
56    pub updated_at: Option<DateTime<Utc>>,
57    #[serde(default)]
58    pub(super) idempotency: Vec<TaskIdempotencyRecordV1>,
59}
60
61impl TaskStore {
62    pub fn default_path() -> std::io::Result<PathBuf> {
63        task_store_path()
64    }
65
66    /// Snapshot of the default store, read under the store lock so it never
67    /// observes a writer's half-applied update. Read-modify-write callers use
68    /// [`Self::mutate_locked`]; a `load` → change → [`Self::save`] sequence
69    /// overwrites concurrent updates.
70    pub fn load() -> std::io::Result<Self> {
71        Self::read_locked(&task_store_path()?, Self::clone)
72    }
73
74    /// Overwrite the default store with this snapshot. Prefer
75    /// [`Self::mutate_locked`] for read-modify-write updates.
76    pub fn save(&self) -> std::io::Result<()> {
77        let path = task_store_path()?;
78        Self::prepare_parent(&path)?;
79        let lock_path = path.with_extension("lock");
80        let _lock =
81            crate::core::agents::FileLock::acquire(&lock_path).map_err(std::io::Error::other)?;
82        self.save_to_path(&path)
83    }
84
85    pub fn scoped_path(project_root: &str) -> std::io::Result<PathBuf> {
86        let root = Path::new(project_root);
87        let metadata = std::fs::symlink_metadata(root)?;
88        if metadata.file_type().is_symlink() || !metadata.is_dir() {
89            return Err(std::io::Error::new(
90                std::io::ErrorKind::InvalidData,
91                "project root must be a real directory",
92            ));
93        }
94        Ok(root
95            .canonicalize()?
96            .join(".lean-ctx")
97            .join("a2a")
98            .join("tasks-v1.json"))
99    }
100
101    pub fn read_locked<R>(path: &Path, read: impl FnOnce(&Self) -> R) -> std::io::Result<R> {
102        Self::prepare_parent(path)?;
103        let _lock = crate::core::agents::FileLock::acquire(&path.with_extension("lock"))
104            .map_err(std::io::Error::other)?;
105        let store = Self::load_from_path(path)?;
106        Ok(read(&store))
107    }
108
109    pub fn mutate_locked<R, E>(
110        path: &Path,
111        mutate: impl FnOnce(&mut Self) -> Result<R, E>,
112    ) -> Result<R, E>
113    where
114        E: From<std::io::Error>,
115    {
116        Self::prepare_parent(path).map_err(E::from)?;
117        let lock = crate::core::agents::FileLock::acquire(&path.with_extension("lock"));
118        Self::mutate_under(path, lock, mutate)
119    }
120
121    /// [`Self::mutate_locked`] that waits up to `wait` for the store lock.
122    /// The lock is a polled try-lock, not a fair queue: a caller that runs
123    /// once per request (such as `ctx_agent export`) can be kept out past
124    /// the default timeout by a writer taking the lock back to back.
125    pub fn mutate_locked_within<R, E>(
126        path: &Path,
127        wait: std::time::Duration,
128        mutate: impl FnOnce(&mut Self) -> Result<R, E>,
129    ) -> Result<R, E>
130    where
131        E: From<std::io::Error>,
132    {
133        Self::prepare_parent(path).map_err(E::from)?;
134        let lock =
135            crate::core::agents::FileLock::acquire_with_timeout(&path.with_extension("lock"), wait);
136        Self::mutate_under(path, lock, mutate)
137    }
138
139    fn mutate_under<R, E>(
140        path: &Path,
141        lock: Result<crate::core::agents::FileLock, String>,
142        mutate: impl FnOnce(&mut Self) -> Result<R, E>,
143    ) -> Result<R, E>
144    where
145        E: From<std::io::Error>,
146    {
147        let _lock = lock.map_err(std::io::Error::other).map_err(E::from)?;
148        let mut store = Self::load_from_path(path).map_err(E::from)?;
149        let result = mutate(&mut store)?;
150        if store.tasks.len() > MAX_TASKS {
151            return Err(E::from(std::io::Error::new(
152                std::io::ErrorKind::InvalidData,
153                "task store entry limit exceeded",
154            )));
155        }
156        if store.idempotency.len() > MAX_IDEMPOTENCY_RECORDS {
157            return Err(E::from(std::io::Error::new(
158                std::io::ErrorKind::InvalidData,
159                "task idempotency entry limit exceeded",
160            )));
161        }
162        store.updated_at = Some(Utc::now());
163        store.save_to_path(path).map_err(E::from)?;
164        Ok(result)
165    }
166
167    fn prepare_parent(path: &Path) -> std::io::Result<()> {
168        let parent = path.parent().ok_or_else(|| {
169            std::io::Error::new(std::io::ErrorKind::InvalidInput, "task store has no parent")
170        })?;
171        for directory in [Some(parent), parent.parent()].into_iter().flatten() {
172            validate_existing_directory(directory)?;
173        }
174        std::fs::create_dir_all(parent)?;
175        for directory in [Some(parent), parent.parent()].into_iter().flatten() {
176            validate_existing_directory(directory)?;
177        }
178        Ok(())
179    }
180
181    fn load_from_path(path: &Path) -> std::io::Result<Self> {
182        let metadata = match std::fs::symlink_metadata(path) {
183            Ok(metadata) => metadata,
184            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
185                return Ok(Self::default());
186            }
187            Err(error) => return Err(error),
188        };
189        if metadata.file_type().is_symlink() || !metadata.is_file() {
190            return Err(std::io::Error::new(
191                std::io::ErrorKind::InvalidData,
192                "task store must be a regular file",
193            ));
194        }
195        if metadata.len() > MAX_TASK_STORE_BYTES {
196            return Err(std::io::Error::new(
197                std::io::ErrorKind::InvalidData,
198                "task store exceeds size limit",
199            ));
200        }
201        let file = open_read_nofollow(path)?;
202        let mut content = String::new();
203        file.take(MAX_TASK_STORE_BYTES + 1)
204            .read_to_string(&mut content)?;
205        if content.len() as u64 > MAX_TASK_STORE_BYTES {
206            return Err(std::io::Error::new(
207                std::io::ErrorKind::InvalidData,
208                "task store exceeds size limit",
209            ));
210        }
211        let store: Self = serde_json::from_str(&content).map_err(std::io::Error::other)?;
212        if store.idempotency.len() > MAX_IDEMPOTENCY_RECORDS {
213            return Err(std::io::Error::new(
214                std::io::ErrorKind::InvalidData,
215                "task idempotency entry limit exceeded",
216            ));
217        }
218        store.validate_restored()?;
219        Ok(store)
220    }
221
222    fn validate_restored(&self) -> std::io::Result<()> {
223        if self.tasks.len() > MAX_TASKS {
224            return invalid_data("task store entry limit exceeded");
225        }
226        let mut task_ids = std::collections::HashSet::with_capacity(self.tasks.len());
227        for task in &self.tasks {
228            validate_restored_identifier(&task.id, "task id")?;
229            if !task_ids.insert(task.id.as_str()) {
230                return invalid_data("task store contains duplicate task ids");
231            }
232            validate_restored_identifier(&task.from_agent, "from_agent")?;
233            validate_restored_identifier(&task.to_agent, "to_agent")?;
234            validate_bounded_nonempty(
235                &task.description,
236                "task description",
237                MAX_DESCRIPTION_BYTES,
238            )?;
239            if task.created_at > task.updated_at {
240                return invalid_data("task timestamps are inconsistent");
241            }
242            if task.messages.len() > MAX_MESSAGES
243                || task.artifacts.len() > MAX_PARTS
244                || task.history.is_empty()
245                || task.history.len() > MAX_HISTORY
246                || task.metadata.len() > MAX_METADATA_ENTRIES
247            {
248                return invalid_data("task collection limit exceeded");
249            }
250            for message in &task.messages {
251                validate_restored_identifier(&message.role, "message role")?;
252                if message.timestamp < task.created_at || message.timestamp > task.updated_at {
253                    return invalid_data("message timestamp is outside task lifetime");
254                }
255                if message.parts.len() > MAX_PARTS_PER_MESSAGE {
256                    return invalid_data("message part limit exceeded");
257                }
258                for part in &message.parts {
259                    validate_part(part)?;
260                }
261            }
262            for artifact in &task.artifacts {
263                validate_part(artifact)?;
264            }
265            for (key, value) in &task.metadata {
266                validate_restored_identifier(key, "metadata key")?;
267                validate_bounded(value, "metadata value", MAX_METADATA_VALUE_BYTES)?;
268            }
269            validate_history(task)?;
270        }
271        Ok(())
272    }
273
274    fn save_to_path(&self, path: &Path) -> std::io::Result<()> {
275        // Every writer must preserve the same invariants required on reload.
276        self.validate_restored()?;
277        if let Ok(metadata) = std::fs::symlink_metadata(path)
278            && (metadata.file_type().is_symlink() || !metadata.is_file())
279        {
280            return Err(std::io::Error::new(
281                std::io::ErrorKind::InvalidData,
282                "task store must be a regular file",
283            ));
284        }
285
286        let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?;
287        if json.len() as u64 > MAX_TASK_STORE_BYTES {
288            return Err(std::io::Error::new(
289                std::io::ErrorKind::InvalidData,
290                "task store exceeds size limit",
291            ));
292        }
293        let tmp = path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4()));
294        let mut options = OpenOptions::new();
295        options.write(true).create_new(true);
296        #[cfg(unix)]
297        {
298            use std::os::unix::fs::OpenOptionsExt;
299            options.mode(0o600);
300        }
301        let mut file = options.open(&tmp)?;
302        file.write_all(json.as_bytes())?;
303        file.sync_all()?;
304        drop(file);
305        std::fs::rename(&tmp, path)?;
306        sync_parent(path)?;
307        Ok(())
308    }
309
310    pub fn create_task(&mut self, from: &str, to: &str, description: &str) -> String {
311        let task = Task::new(from, to, description);
312        let id = task.id.clone();
313        self.tasks.push(task);
314        self.updated_at = Some(Utc::now());
315        id
316    }
317
318    /// [`Self::create_task`] for a description typed by a caller: admitted
319    /// for the store first, so what the gateway masks is stored masked and
320    /// restricted or withheld text creates no task (E3). Returns the new task.
321    pub(crate) fn create_admitted(
322        &mut self,
323        from: &str,
324        to: &str,
325        description: &str,
326    ) -> Result<&mut Task, Withheld> {
327        let task = Task::new(from, to, &admit_for_store(description)?);
328        let index = self.tasks.len();
329        self.tasks.push(task);
330        self.updated_at = Some(Utc::now());
331        Ok(&mut self.tasks[index])
332    }
333
334    /// Durably materialize an already-authorized remote task descriptor.
335    ///
336    /// The description is admitted for the store like any new caller text: a
337    /// descriptor whose description is restricted or withheld is refused with
338    /// `PermissionDenied` and a content-free message, and neither a task nor
339    /// an idempotency record is written.
340    ///
341    /// The idempotency ledger lives in the same file as the tasks and is
342    /// written under the same lock and atomic rename, so a delivery is either
343    /// visible with its ledger entry after a restart or not at all — never
344    /// acknowledged without being stored.
345    ///
346    /// Callers MUST verify [`TaskDescriptorV1::verify_authority`] first; this
347    /// re-validates shape only, and cannot re-derive authority on its own.
348    pub fn materialize_remote_task(
349        path: &Path,
350        descriptor: &TaskDescriptorV1,
351    ) -> std::io::Result<RemoteTaskMaterialization> {
352        descriptor
353            .validate_shape()
354            .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?;
355        let digest = descriptor.content_digest();
356        Self::mutate_locked(path, |store| {
357            let now = Utc::now();
358            store.prune_idempotency(now);
359            if let Some(existing) = store
360                .idempotency
361                .iter()
362                .find(|record| record.key == descriptor.idempotency_key)
363            {
364                if existing.matches_authority(descriptor) && existing.descriptor_digest == digest {
365                    return Ok(RemoteTaskMaterialization {
366                        task_id: existing.task_id.clone(),
367                        duplicate: true,
368                    });
369                }
370                return Err(std::io::Error::new(
371                    std::io::ErrorKind::AlreadyExists,
372                    "conflicting idempotency key reuse",
373                ));
374            }
375            if store.idempotency.len() >= MAX_IDEMPOTENCY_RECORDS || store.tasks.len() >= MAX_TASKS
376            {
377                return Err(std::io::Error::new(
378                    std::io::ErrorKind::InvalidData,
379                    "task idempotency entry limit exceeded",
380                ));
381            }
382            let task_id = format!("task-{}", uuid::Uuid::new_v4());
383            let task =
384                Task::from_remote_descriptor(descriptor, task_id.clone()).map_err(|withheld| {
385                    std::io::Error::new(
386                        std::io::ErrorKind::PermissionDenied,
387                        withheld.message("task text"),
388                    )
389                })?;
390            store.tasks.push(task);
391            store.idempotency.push(TaskIdempotencyRecordV1 {
392                schema_version: TASK_DESCRIPTOR_VERSION,
393                key: descriptor.idempotency_key.clone(),
394                sender: descriptor.sender.clone(),
395                recipient: descriptor.recipient.clone(),
396                tenant_id: descriptor.tenant_id.clone(),
397                project_id: descriptor.project_id.clone(),
398                action: descriptor.action.clone(),
399                key_id: descriptor.key_id.clone(),
400                descriptor_digest: digest,
401                task_id: task_id.clone(),
402                created_at: now,
403            });
404            Ok(RemoteTaskMaterialization {
405                task_id,
406                duplicate: false,
407            })
408        })
409    }
410
411    /// Drop idempotency records that no live descriptor can still reference.
412    ///
413    /// A descriptor may live at most [`MAX_TASK_LIFETIME_SECONDS`] and is
414    /// rejected once expired, so a record older than that plus the accepted
415    /// clock skew can never turn a replay into a duplicate. Records with a
416    /// creation time in the future (clock moved backwards) are kept.
417    fn prune_idempotency(&mut self, now: DateTime<Utc>) {
418        let cutoff = now - chrono::Duration::seconds(IDEMPOTENCY_RETENTION_SECONDS);
419        self.idempotency.retain(|record| record.created_at > cutoff);
420    }
421
422    pub fn get_task(&self, task_id: &str) -> Option<&Task> {
423        self.tasks.iter().find(|t| t.id == task_id)
424    }
425
426    pub fn get_task_mut(&mut self, task_id: &str) -> Option<&mut Task> {
427        self.tasks.iter_mut().find(|t| t.id == task_id)
428    }
429
430    pub fn tasks_for_agent(&self, agent_id: &str) -> Vec<&Task> {
431        self.tasks
432            .iter()
433            .filter(|t| t.to_agent == agent_id || t.from_agent == agent_id)
434            .collect()
435    }
436
437    pub fn pending_tasks_for(&self, agent_id: &str) -> Vec<&Task> {
438        self.tasks
439            .iter()
440            .filter(|t| t.to_agent == agent_id && !t.state.is_terminal())
441            .collect()
442    }
443
444    pub fn cleanup_old(&mut self, max_age_hours: u64) {
445        let now = Utc::now();
446        let cutoff = now - chrono::Duration::hours(max_age_hours as i64);
447        self.tasks
448            .retain(|t| !t.state.is_terminal() || t.updated_at > cutoff);
449        self.prune_idempotency(now);
450    }
451}
452
453fn invalid_data<T>(message: &str) -> std::io::Result<T> {
454    Err(std::io::Error::new(
455        std::io::ErrorKind::InvalidData,
456        message,
457    ))
458}
459
460fn validate_bounded(value: &str, field: &str, max_bytes: usize) -> std::io::Result<()> {
461    if value.len() > max_bytes {
462        return invalid_data(&format!("invalid {field}"));
463    }
464    Ok(())
465}
466
467fn validate_restored_identifier(value: &str, field: &str) -> std::io::Result<()> {
468    validate_bounded_nonempty(value, field, MAX_ID_BYTES)?;
469    if value.chars().any(char::is_control) {
470        return invalid_data(&format!("invalid {field}"));
471    }
472    Ok(())
473}
474
475fn validate_bounded_nonempty(value: &str, field: &str, max_bytes: usize) -> std::io::Result<()> {
476    if value.is_empty() {
477        return invalid_data(&format!("invalid {field}"));
478    }
479    validate_bounded(value, field, max_bytes)
480}
481
482fn validate_part(part: &TaskPart) -> std::io::Result<()> {
483    match part {
484        TaskPart::Text { text } => validate_bounded(text, "text part", MAX_TEXT_BYTES),
485        TaskPart::Data { mime_type, data } => {
486            validate_restored_identifier(mime_type, "data mime_type")?;
487            validate_bounded(data, "data part", MAX_TEXT_BYTES)
488        }
489        TaskPart::File {
490            name,
491            mime_type,
492            data,
493            uri,
494        } => {
495            validate_restored_identifier(name, "file name")?;
496            if let Some(value) = mime_type {
497                validate_restored_identifier(value, "file mime_type")?;
498            }
499            if let Some(value) = data {
500                validate_bounded(value, "file data", MAX_TEXT_BYTES)?;
501            }
502            if let Some(value) = uri {
503                validate_bounded_nonempty(value, "file uri", MAX_TEXT_BYTES)?;
504            }
505            if data.is_none() && uri.is_none() {
506                return invalid_data("file part requires data or uri");
507            }
508            Ok(())
509        }
510    }
511}
512
513fn validate_history(task: &Task) -> std::io::Result<()> {
514    let first = &task.history[0];
515    if first.from != TaskState::Created || first.to != TaskState::Created {
516        return invalid_data("task history has invalid origin");
517    }
518    if let Some(reason) = &first.reason {
519        validate_bounded(reason, "transition reason", MAX_DESCRIPTION_BYTES)?;
520    }
521    let mut state = TaskState::Created;
522    let mut timestamp = first.timestamp;
523    for transition in task.history.iter().skip(1) {
524        if transition.from != state
525            || !transition.from.can_transition_to(&transition.to)
526            || transition.timestamp < timestamp
527        {
528            return invalid_data("task history is inconsistent");
529        }
530        if let Some(reason) = &transition.reason {
531            validate_bounded(reason, "transition reason", MAX_DESCRIPTION_BYTES)?;
532        }
533        state = transition.to.clone();
534        timestamp = transition.timestamp;
535    }
536    if state != task.state || first.timestamp < task.created_at || timestamp > task.updated_at {
537        return invalid_data("task state does not match history");
538    }
539    Ok(())
540}
541
542fn task_store_path() -> std::io::Result<PathBuf> {
543    // GH #439: the A2A task store is agent DATA — resolve through the typed dir
544    // so a post-split install writes to $XDG_DATA_HOME/lean-ctx/agents instead
545    // of a re-created ~/.lean-ctx. Legacy single-dir installs resolve in place.
546    crate::core::data_dir::lean_ctx_data_dir()
547        .map(|d| d.join("agents").join("tasks.json"))
548        .map_err(std::io::Error::other)
549}
550
551fn open_read_nofollow(path: &Path) -> std::io::Result<File> {
552    let mut options = OpenOptions::new();
553    options.read(true);
554    #[cfg(unix)]
555    {
556        use std::os::unix::fs::OpenOptionsExt;
557        options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC);
558    }
559    let file = options.open(path)?;
560    if !file.metadata()?.is_file() {
561        return Err(std::io::Error::new(
562            std::io::ErrorKind::InvalidData,
563            "task store must be a regular file",
564        ));
565    }
566    Ok(file)
567}
568
569fn validate_existing_directory(path: &Path) -> std::io::Result<()> {
570    match std::fs::symlink_metadata(path) {
571        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
572            Err(std::io::Error::new(
573                std::io::ErrorKind::InvalidData,
574                "task store path components must be real directories",
575            ))
576        }
577        Ok(_) => Ok(()),
578        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
579        Err(error) => Err(error),
580    }
581}
582
583#[cfg_attr(not(unix), allow(unused_variables))] // only the unix branch reads it
584fn sync_parent(path: &Path) -> std::io::Result<()> {
585    #[cfg(unix)]
586    if let Some(parent) = path.parent() {
587        File::open(parent)?.sync_all()?;
588    }
589    Ok(())
590}