1use 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 pub fn load() -> std::io::Result<Self> {
71 Self::read_locked(&task_store_path()?, Self::clone)
72 }
73
74 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 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 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 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 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 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 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))] fn 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}