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>(
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 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 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 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 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 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))] fn 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}