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