1pub mod extract;
11pub mod kind;
12pub mod process;
13pub mod prompt;
14pub mod store;
15
16use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18use std::sync::atomic::{AtomicBool, Ordering};
19use std::sync::{Arc, Mutex, OnceLock};
20use std::time::{Duration, Instant};
21
22use crate::backend::mps::Broker;
23use crate::backend::rpc_types::{
24 AttachmentBatchDoneEvent, AttachmentFailedEvent, AttachmentInfo, AttachmentPending,
25 AttachmentProgressEvent, AttachmentReadyEvent, AttachmentRejected, AttachmentsIngestResult,
26 CommandAttachmentsIngestData,
27};
28use crate::backend::wconfig::ConfigState;
29
30pub use kind::FileKind;
31pub use store::{is_valid_id, Kind, Store};
32
33pub const DEFAULT_MAX_FILES: u32 = 128;
34pub const DEFAULT_MAX_TOTAL_MB: u64 = 1024;
35pub const DEFAULT_SEND_MAX_EDGE: u32 = 2000;
36pub const DEFAULT_RETENTION_DAYS: u64 = 30;
37
38const MAX_WALK_ENTRIES: usize = 10_000;
41const DECODE_BUDGET_MIB: u32 = 2048;
45const PROGRESS_INTERVAL: Duration = Duration::from_millis(100);
47const SWEEP_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60);
48
49const SKIPPED_DIRS: &[&str] = &[
53 "node_modules",
54 "target",
55 "dist",
56 "build",
57 "out",
58 "__pycache__",
59 ".venv",
60 "venv",
61 "bower_components",
62];
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub struct Limits {
67 pub max_files: u32,
68 pub max_total_bytes: u64,
69 pub send_max_edge: u32,
70 pub retention: Duration,
71 pub claude_inline_max: usize,
73 pub claude_session_inline_bytes: u64,
75}
76
77impl Default for Limits {
78 fn default() -> Self {
79 Self {
80 max_files: DEFAULT_MAX_FILES,
81 max_total_bytes: DEFAULT_MAX_TOTAL_MB * 1024 * 1024,
82 send_max_edge: DEFAULT_SEND_MAX_EDGE,
83 retention: Duration::from_secs(DEFAULT_RETENTION_DAYS * 24 * 3600),
84 claude_inline_max: prompt::DEFAULT_INLINE_MAX_COUNT,
85 claude_session_inline_bytes: prompt::DEFAULT_SESSION_INLINE_MB * 1024 * 1024,
86 }
87 }
88}
89
90impl Limits {
91 pub fn from_settings(extra: &HashMap<String, serde_json::Value>) -> Self {
92 let num = |k: &str| {
93 extra
94 .get(k)
95 .and_then(|v| v.as_f64())
96 .filter(|n| n.is_finite() && *n > 0.0)
97 };
98 let d = Self::default();
99 Self {
100 max_files: num("attachments:maxfiles")
101 .map(|n| n as u32)
102 .unwrap_or(d.max_files),
103 max_total_bytes: num("attachments:maxtotalmb")
104 .map(|n| (n * 1024.0 * 1024.0) as u64)
105 .unwrap_or(d.max_total_bytes),
106 send_max_edge: num("attachments:sendmaxedge")
107 .map(|n| (n as u32).clamp(256, 8000))
108 .unwrap_or(d.send_max_edge),
109 retention: num("attachments:retentiondays")
110 .map(|n| Duration::from_secs((n * 24.0 * 3600.0) as u64))
111 .unwrap_or(d.retention),
112 claude_inline_max: extra
114 .get("attachments:claudeinlinemax")
115 .and_then(|v| v.as_f64())
116 .filter(|n| n.is_finite() && *n >= 0.0)
117 .map(|n| (n as usize).min(100))
118 .unwrap_or(d.claude_inline_max),
119 claude_session_inline_bytes: extra
120 .get("attachments:claudesessioninlinemb")
121 .and_then(|v| v.as_f64())
122 .filter(|n| n.is_finite() && *n >= 0.0)
123 .map(|n| (n * 1024.0 * 1024.0) as u64)
124 .unwrap_or(d.claude_session_inline_bytes),
125 }
126 }
127}
128
129pub struct Service {
130 store: Store,
131 broker: Arc<Broker>,
132 config: Arc<ConfigState>,
133 permits: Arc<tokio::sync::Semaphore>,
135 memory: Arc<tokio::sync::Semaphore>,
137 batches: Mutex<HashMap<String, Arc<AtomicBool>>>,
139 inflight: Arc<Mutex<HashMap<String, usize>>>,
144 session_budget: Mutex<()>,
148}
149
150struct InflightGuard {
152 map: Arc<Mutex<HashMap<String, usize>>>,
153 id: String,
154}
155
156impl Drop for InflightGuard {
157 fn drop(&mut self) {
158 let mut map = self.map.lock().unwrap();
159 if let Some(n) = map.get_mut(&self.id) {
160 *n -= 1;
161 if *n == 0 {
162 map.remove(&self.id);
163 }
164 }
165 }
166}
167
168static SERVICE: OnceLock<Arc<Service>> = OnceLock::new();
169
170pub fn init(broker: Arc<Broker>, config: Arc<ConfigState>) -> Arc<Service> {
173 SERVICE
174 .get_or_init(|| {
175 let root = crate::backend::base::get_mux_data_dir().join("attachments");
176 let svc = Arc::new(Service::new(Store::new(root), broker, config));
177 svc.start_sweeper();
178 svc
179 })
180 .clone()
181}
182
183pub fn get() -> Option<Arc<Service>> {
186 SERVICE.get().cloned()
187}
188
189impl Service {
190 pub fn new(store: Store, broker: Arc<Broker>, config: Arc<ConfigState>) -> Self {
191 if let Err(e) = store.ensure_dirs() {
192 tracing::warn!(error = %e, root = %store.root().display(), "attachments: cannot create store");
193 }
194 let workers = std::thread::available_parallelism()
195 .map(|n| n.get())
196 .unwrap_or(2);
197 Self {
198 store,
199 broker,
200 config,
201 permits: Arc::new(tokio::sync::Semaphore::new((workers / 2).max(1))),
202 memory: Arc::new(tokio::sync::Semaphore::new(DECODE_BUDGET_MIB as usize)),
203 batches: Mutex::new(HashMap::new()),
204 inflight: Arc::new(Mutex::new(HashMap::new())),
205 session_budget: Mutex::new(()),
206 }
207 }
208
209 pub fn store(&self) -> &Store {
210 &self.store
211 }
212
213 pub fn limits(&self) -> Limits {
214 Limits::from_settings(&self.config.get_settings().extra)
215 }
216
217 fn start_sweeper(self: &Arc<Self>) {
218 let Ok(handle) = tokio::runtime::Handle::try_current() else {
219 return;
220 };
221 let svc = Arc::clone(self);
222 handle.spawn(async move {
223 tokio::time::sleep(Duration::from_secs(60)).await;
225 loop {
226 let store = svc.store.clone();
227 let retention = svc.limits().retention;
228 let removed = tokio::task::spawn_blocking(move || store.sweep(retention))
229 .await
230 .unwrap_or(0);
231 if removed > 0 {
232 tracing::info!(removed, "attachments: retention sweep");
233 }
234 tokio::time::sleep(SWEEP_INTERVAL).await;
235 }
236 });
237 }
238
239 pub fn ingest(self: &Arc<Self>, req: CommandAttachmentsIngestData) -> AttachmentsIngestResult {
242 let limits = self.limits();
243 let plan = plan(&req.paths, &limits, req.existing_count, req.existing_bytes);
244 let result = AttachmentsIngestResult {
245 batch_id: req.batch_id.clone(),
246 accepted: plan.accepted.clone(),
247 rejected: plan.rejected,
248 non_images: plan.non_images,
249 total_bytes: plan.accepted.iter().map(|a| a.bytes).sum(),
250 max_files: limits.max_files,
251 max_total_bytes: limits.max_total_bytes,
252 };
253 if req.dry_run || plan.accepted.is_empty() {
254 return result;
255 }
256 let cancel = Arc::new(AtomicBool::new(false));
257 self.batches
258 .lock()
259 .unwrap()
260 .insert(req.batch_id.clone(), Arc::clone(&cancel));
261 let svc = Arc::clone(self);
262 let items = plan.accepted;
263 tokio::spawn(async move {
264 svc.run_batch(req.block_id, req.batch_id, items, limits, cancel)
265 .await;
266 });
267 result
268 }
269
270 pub fn cancel(&self, batch_id: &str) -> bool {
271 match self.batches.lock().unwrap().get(batch_id) {
272 Some(flag) => {
273 flag.store(true, Ordering::SeqCst);
274 true
275 }
276 None => false,
277 }
278 }
279
280 pub async fn info(self: &Arc<Self>, ids: &[String]) -> Vec<AttachmentInfo> {
283 let mut out = Vec::with_capacity(ids.len());
284 for id in ids {
285 if let Some(meta) = self.ensure_derived(id).await {
286 out.push(meta.to_info(id, ""));
287 }
288 }
289 out
290 }
291
292 pub async fn ensure_derived(self: &Arc<Self>, id: &str) -> Option<store::StoredMeta> {
298 let edge = self.limits().send_max_edge;
299 let fp = store::fingerprint(edge);
300 if let Some(meta) = self.store.meta(id, &fp) {
301 return Some(meta);
302 }
303 let (blob, kind) = self.store.find_blob(id)?;
304 let _cpu = self.permits.clone().acquire_owned().await.ok()?;
305 if let Some(meta) = self.store.meta(id, &fp) {
307 return Some(meta);
308 }
309 let name = blob.file_name()?.to_string_lossy().into_owned();
310 self.derive_kind(id, blob, kind, &name, edge).await.ok()
311 }
312
313 async fn derive_kind(
317 self: &Arc<Self>,
318 id: &str,
319 blob: PathBuf,
320 kind: FileKind,
321 name: &str,
322 edge: u32,
323 ) -> Result<store::StoredMeta, process::ProcessError> {
324 let _mem = if matches!(kind, FileKind::Image(_)) {
325 let blob_for_dims = blob.clone();
326 let (w, h) = blocking(move || process::header_dimensions(&blob_for_dims)).await?;
327 self.reserve_memory(w, h).await
328 } else {
329 None
330 };
331 let svc = Arc::clone(self);
332 let (id, name) = (id.to_string(), name.to_string());
333 blocking(move || svc.store.derive_and_save(&id, &blob, kind, &name, edge)).await
334 }
335
336 pub fn with_session_inline_budget<T>(
341 &self,
342 key: &str,
343 carry_from: Option<&str>,
344 max: u64,
345 f: impl FnOnce(u64) -> (T, u64),
346 ) -> T {
347 let _guard = self.session_budget.lock().unwrap();
348 if let Some(from) = carry_from.filter(|from| *from != key) {
349 self.store.adopt_session_inline_bytes(from, key);
350 }
351 let used = self.store.session_inline_bytes(key);
352 let (out, spent) = f(max.saturating_sub(used));
353 self.store.add_session_inline_bytes(key, spent);
354 out
355 }
356
357 pub fn send_target(&self, id: &str, name: &str) -> Option<SendTarget> {
363 let fp = store::fingerprint(self.limits().send_max_edge);
364 let meta = self.store.meta(id, &fp)?;
365 let (path, mime) = if meta.is_image() {
366 self.store.file(id, &fp, Kind::Send)?
367 } else {
368 (self.store.named_link(id, name)?, meta.mime.clone())
369 };
370 let text_path = self.store.file(id, &fp, Kind::Text).map(|(p, _)| p);
371 self.store.mark_sent(id);
372 self.store.touch(id, &fp);
373 Some(SendTarget {
374 path,
375 mime,
376 page_count: meta.page_count,
377 note: file_note(&meta, text_path.as_deref()),
378 })
379 }
380
381 async fn reserve_memory(&self, w: u32, h: u32) -> Option<tokio::sync::OwnedSemaphorePermit> {
383 let mib = process::decode_cost_bytes(w, h).div_ceil(1024 * 1024);
384 let mib = (mib as u32).clamp(1, DECODE_BUDGET_MIB);
385 self.memory.clone().acquire_many_owned(mib).await.ok()
386 }
387
388 pub async fn commit_upload(
391 self: &Arc<Self>,
392 tmp: PathBuf,
393 id: String,
394 name: String,
395 ) -> Result<AttachmentInfo, process::ProcessError> {
396 let _cpu =
399 self.permits
400 .clone()
401 .acquire_owned()
402 .await
403 .map_err(|_| process::ProcessError {
404 code: "cancelled",
405 message: "Cancelled.".into(),
406 })?;
407 let edge = self.limits().send_max_edge;
408 let svc = Arc::clone(self);
409 let place_id = id.clone();
410 let place_name = name.clone();
411 let (blob, kind, guard) =
412 blocking(move || svc.place_tracked(&tmp, &place_id, &place_name)).await?;
413 let fp = store::fingerprint(edge);
414 if let Some(meta) = self.store.meta(&id, &fp) {
415 self.store.touch(&id, &fp);
416 return Ok(meta.to_info(&id, &name));
417 }
418 let result = self.derive_kind(&id, blob, kind, &name, edge).await;
419 match result {
420 Ok(meta) => Ok(meta.to_info(&id, &name)),
421 Err(e) => {
422 self.discard_unprocessed(guard).await;
423 Err(e)
424 }
425 }
426 }
427
428 fn place_tracked(
431 &self,
432 tmp: &Path,
433 id: &str,
434 name: &str,
435 ) -> Result<(PathBuf, FileKind, InflightGuard), process::ProcessError> {
436 let mut map = self.inflight.lock().unwrap();
437 *map.entry(id.to_string()).or_insert(0) += 1;
438 let guard = InflightGuard {
439 map: Arc::clone(&self.inflight),
440 id: id.to_string(),
441 };
442 match self.store.place(tmp, id, name) {
443 Ok((blob, kind)) => {
444 drop(map);
445 Ok((blob, kind, guard))
446 }
447 Err(e) => {
448 drop(map);
449 drop(guard);
450 Err(e)
451 }
452 }
453 }
454
455 async fn discard_unprocessed(&self, guard: InflightGuard) {
459 let store = self.store.clone();
460 let inflight = Arc::clone(&self.inflight);
461 let _ = tokio::task::spawn_blocking(move || {
462 let map = inflight.lock().unwrap();
463 if map.get(&guard.id).copied() == Some(1) {
464 store.discard_if_unprocessed(&guard.id);
465 }
466 drop(map);
467 drop(guard);
468 })
469 .await;
470 }
471
472 async fn run_batch(
473 self: Arc<Self>,
474 block_id: String,
475 batch_id: String,
476 items: Vec<AttachmentPending>,
477 limits: Limits,
478 cancel: Arc<AtomicBool>,
479 ) {
480 let mut tasks = Vec::with_capacity(items.len());
481 for item in items {
482 let svc = Arc::clone(&self);
483 let block_id = block_id.clone();
484 let batch_id = batch_id.clone();
485 let cancel = Arc::clone(&cancel);
486 tasks.push(tokio::spawn(async move {
487 let index = item.index;
488 let outcome = svc
489 .run_item(&block_id, &batch_id, item, limits, &cancel)
490 .await;
491 match outcome {
492 Ok(info) => svc.publish::<crate::backend::mps_events::AttachmentReady>(&block_id,
493 &AttachmentReadyEvent {
494 batch_id,
495 index,
496 info,
497 },
498 ),
499 Err(e) => svc.publish::<crate::backend::mps_events::AttachmentFailed>(&block_id,
500 &AttachmentFailedEvent {
501 batch_id,
502 index,
503 code: e.code.into(),
504 error: e.message,
505 },
506 ),
507 }
508 }));
509 }
510 for t in tasks {
511 let _ = t.await;
512 }
513 self.batches.lock().unwrap().remove(&batch_id);
514 self.publish::<crate::backend::mps_events::AttachmentBatchDone>(&block_id,
515 &AttachmentBatchDoneEvent { batch_id },
516 );
517 }
518
519 async fn run_item(
524 self: &Arc<Self>,
525 block_id: &str,
526 batch_id: &str,
527 item: AttachmentPending,
528 limits: Limits,
529 cancel: &Arc<AtomicBool>,
530 ) -> Result<AttachmentInfo, process::ProcessError> {
531 let cancelled = || process::ProcessError {
532 code: "cancelled",
533 message: "Cancelled.".into(),
534 };
535 let _cpu = self
536 .permits
537 .clone()
538 .acquire_owned()
539 .await
540 .map_err(|_| cancelled())?;
541 if cancel.load(Ordering::SeqCst) {
542 return Err(cancelled());
543 }
544
545 let svc = Arc::clone(self);
546 let (block, batch, it, flag) = (
547 block_id.to_string(),
548 batch_id.to_string(),
549 item.clone(),
550 Arc::clone(cancel),
551 );
552 let placed =
553 blocking(move || svc.copy_and_place(&block, &batch, &it, limits, &flag)).await?;
554 let (id, blob, kind, guard) = placed;
555
556 let fp = store::fingerprint(limits.send_max_edge);
557 if let Some(meta) = self.store.meta(&id, &fp) {
558 self.store.touch(&id, &fp);
559 return Ok(meta.to_info(&id, &item.name));
560 }
561
562 let result = self
563 .derive_placed(block_id, batch_id, &item, &id, blob, kind, limits, cancel)
564 .await;
565 if result.is_err() {
566 self.discard_unprocessed(guard).await;
569 }
570 result
571 }
572
573 #[allow(clippy::too_many_arguments)]
576 async fn derive_placed(
577 self: &Arc<Self>,
578 block_id: &str,
579 batch_id: &str,
580 item: &AttachmentPending,
581 id: &str,
582 blob: PathBuf,
583 kind: FileKind,
584 limits: Limits,
585 cancel: &Arc<AtomicBool>,
586 ) -> Result<AttachmentInfo, process::ProcessError> {
587 let cancelled = || process::ProcessError {
588 code: "cancelled",
589 message: "Cancelled.".into(),
590 };
591 if cancel.load(Ordering::SeqCst) {
592 return Err(cancelled());
593 }
594 self.publish::<crate::backend::mps_events::AttachmentProgress>(block_id,
595 &AttachmentProgressEvent {
596 batch_id: batch_id.to_string(),
597 index: item.index,
598 stage: "processing".into(),
599 done_bytes: item.bytes,
600 total_bytes: item.bytes,
601 },
602 );
603 let meta = self
604 .derive_kind(id, blob, kind, &item.name, limits.send_max_edge)
605 .await?;
606 if cancel.load(Ordering::SeqCst) {
607 return Err(cancelled());
608 }
609 Ok(meta.to_info(id, &item.name))
610 }
611
612 fn copy_and_place(
615 &self,
616 block_id: &str,
617 batch_id: &str,
618 item: &AttachmentPending,
619 limits: Limits,
620 cancel: &AtomicBool,
621 ) -> Result<(String, PathBuf, FileKind, InflightGuard), process::ProcessError> {
622 let mut last = Instant::now() - PROGRESS_INTERVAL;
623 let copied = self.store.copy_in(Path::new(&item.path), |done| {
624 if cancel.load(Ordering::SeqCst) {
625 return false;
626 }
627 if last.elapsed() >= PROGRESS_INTERVAL || done >= item.bytes {
628 last = Instant::now();
629 self.publish::<crate::backend::mps_events::AttachmentProgress>(block_id,
630 &AttachmentProgressEvent {
631 batch_id: batch_id.to_string(),
632 index: item.index,
633 stage: "copying".into(),
634 done_bytes: done,
635 total_bytes: item.bytes,
636 },
637 );
638 }
639 done <= limits.max_total_bytes
641 });
642 let (tmp, id, _) = copied.map_err(|e| {
643 if cancel.load(Ordering::SeqCst) {
644 process::ProcessError {
645 code: "cancelled",
646 message: "Cancelled.".into(),
647 }
648 } else {
649 process::ProcessError {
650 code: "io",
651 message: format!("Couldn't read the file: {e}"),
652 }
653 }
654 })?;
655 if cancel.load(Ordering::SeqCst) {
656 let _ = std::fs::remove_file(&tmp);
657 return Err(process::ProcessError {
658 code: "cancelled",
659 message: "Cancelled.".into(),
660 });
661 }
662 let (blob, kind, guard) = self.place_tracked(&tmp, &id, &item.name)?;
663 Ok((id, blob, kind, guard))
664 }
665
666 fn publish<E: crate::backend::mps_events::Event>(&self, block_id: &str, data: &E::Payload) {
667 self.broker.publish_event::<E>(data, vec![format!("block:{block_id}")], 0);
668 }
669}
670
671#[derive(Debug, Clone, PartialEq, Eq)]
673pub struct SendTarget {
674 pub path: PathBuf,
676 pub mime: String,
677 pub page_count: Option<u32>,
678 pub note: Option<String>,
680}
681
682fn file_note(meta: &store::StoredMeta, text_path: Option<&Path>) -> Option<String> {
686 if meta.is_image() {
687 return None;
688 }
689 let mut parts: Vec<String> = Vec::new();
690 let label = match meta.kind.as_str() {
691 "pdf" => "PDF",
692 "word" => "Word document",
693 "excel" => "spreadsheet",
694 "powerpoint" => "presentation",
695 "svg" => "SVG image",
696 "archive" => "archive",
697 "audio" => "audio",
698 "video" => "video",
699 "image_file" => "image (not decoded)",
700 _ => "",
701 };
702 match (label, meta.page_count) {
703 ("", _) => {}
704 (l, Some(n)) => parts.push(format!("{l}, {n} page{}", if n == 1 { "" } else { "s" })),
705 (l, None) => parts.push(l.to_string()),
706 }
707 if let Some(p) = text_path {
708 parts.push(format!("text version: {}", p.display()));
709 } else if let Some(n) = &meta.text_note {
710 parts.push(n.clone());
711 }
712 if meta.macros {
713 parts.push("contains macros".into());
714 }
715 (!parts.is_empty()).then(|| parts.join("; "))
716}
717
718async fn blocking<T: Send + 'static>(
720 f: impl FnOnce() -> Result<T, process::ProcessError> + Send + 'static,
721) -> Result<T, process::ProcessError> {
722 tokio::task::spawn_blocking(f).await.unwrap_or_else(|e| {
723 Err(process::ProcessError {
724 code: "decode",
725 message: format!("Processing crashed: {e}"),
726 })
727 })
728}
729
730#[derive(Debug, Default)]
732pub struct Plan {
733 pub accepted: Vec<AttachmentPending>,
734 pub rejected: Vec<AttachmentRejected>,
735 pub non_images: Vec<String>,
736}
737
738fn display_name(p: &Path) -> String {
739 p.file_name()
740 .map(|n| n.to_string_lossy().into_owned())
741 .unwrap_or_else(|| p.display().to_string())
742}
743
744fn files_in_dir(dir: &Path, budget: &mut usize, out: &mut Vec<PathBuf>) {
748 let Ok(rd) = std::fs::read_dir(dir) else {
749 return;
750 };
751 let mut entries: Vec<_> = rd.filter_map(Result::ok).collect();
752 entries.sort_by_key(|e| e.file_name());
753 for e in entries {
754 if *budget == 0 {
755 return;
756 }
757 *budget -= 1;
758 let name = e.file_name().to_string_lossy().into_owned();
759 if name.starts_with('.') {
760 continue;
761 }
762 let Ok(ft) = e.file_type() else { continue };
763 let path = e.path();
764 if ft.is_dir() {
765 if !SKIPPED_DIRS.contains(&name.as_str()) {
766 files_in_dir(&path, budget, out);
767 }
768 } else if ft.is_file() {
769 out.push(path);
770 }
771 }
772}
773
774pub fn plan(paths: &[String], limits: &Limits, existing_count: u32, existing_bytes: u64) -> Plan {
778 let mut out = Plan::default();
779 let mut count = existing_count;
780 let mut bytes = existing_bytes;
781 let mut budget = MAX_WALK_ENTRIES;
782
783 let mut candidates: Vec<PathBuf> = Vec::new();
784 for raw in paths {
785 let p = PathBuf::from(raw);
786 match std::fs::metadata(&p) {
787 Ok(m) if m.is_dir() => files_in_dir(&p, &mut budget, &mut candidates),
788 Ok(m) if m.is_file() => candidates.push(p),
789 _ => out.rejected.push(AttachmentRejected {
790 path: raw.clone(),
791 name: display_name(&p),
792 code: "not_found".into(),
793 reason: "The file doesn't exist or can't be read.".into(),
794 }),
795 }
796 }
797
798 for p in candidates {
799 let name = display_name(&p);
800 let path = p.to_string_lossy().into_owned();
801 let size = match std::fs::metadata(&p) {
802 Ok(m) => m.len(),
803 Err(_) => {
804 out.rejected.push(AttachmentRejected {
805 path,
806 name,
807 code: "unreadable".into(),
808 reason: "The file can't be read.".into(),
809 });
810 continue;
811 }
812 };
813 if count >= limits.max_files {
814 out.rejected.push(AttachmentRejected {
815 path,
816 name,
817 code: "too_many".into(),
818 reason: format!(
819 "A message can carry at most {} attachments.",
820 limits.max_files
821 ),
822 });
823 continue;
824 }
825 if bytes.saturating_add(size) > limits.max_total_bytes {
826 out.rejected.push(AttachmentRejected {
827 path,
828 name,
829 code: "too_large".into(),
830 reason: format!(
831 "Adding it would pass the {} limit for one prompt.",
832 format_limit(limits.max_total_bytes)
833 ),
834 });
835 continue;
836 }
837 out.accepted.push(AttachmentPending {
838 index: out.accepted.len() as u32,
839 path,
840 name,
841 bytes: size,
842 });
843 count += 1;
844 bytes += size;
845 }
846 out
847}
848
849fn format_limit(bytes: u64) -> String {
850 const GB: u64 = 1024 * 1024 * 1024;
851 const MB: u64 = 1024 * 1024;
852 if bytes >= GB && bytes % GB == 0 {
853 format!("{} GB", bytes / GB)
854 } else {
855 format!("{} MB", bytes / MB)
856 }
857}
858
859#[cfg(test)]
860mod tests {
861 use super::*;
862
863 fn touch_file(dir: &Path, name: &str, bytes: usize) -> String {
864 let p = dir.join(name);
865 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
866 let mut data = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
868 data.resize(bytes.max(8), 0);
869 std::fs::write(&p, data).unwrap();
870 p.to_string_lossy().into_owned()
871 }
872
873 #[tokio::test]
874 async fn a_failing_job_keeps_an_original_another_job_is_still_using() {
875 let d = tempfile::tempdir().unwrap();
876 let svc = Arc::new(Service::new(
877 Store::new(d.path().join("attachments")),
878 Arc::new(Broker::new()),
879 Arc::new(ConfigState::new()),
880 ));
881 let src = touch_file(d.path(), "same.png", 64);
883 let (tmp_a, id, _) = svc.store.copy_in(Path::new(&src), |_| true).unwrap();
884 let (tmp_b, id_b, _) = svc.store.copy_in(Path::new(&src), |_| true).unwrap();
885 assert_eq!(id, id_b);
886 let (blob, _, guard_a) = svc.place_tracked(&tmp_a, &id, "same.png").unwrap();
887 let (_, _, guard_b) = svc.place_tracked(&tmp_b, &id, "same.png").unwrap();
888
889 svc.discard_unprocessed(guard_a).await;
890 assert!(blob.is_file(), "job B still needs the shared original");
891 svc.discard_unprocessed(guard_b).await;
892 assert!(!blob.is_file(), "the last failing job cleans it up");
893 assert!(svc.inflight.lock().unwrap().is_empty());
894 }
895
896 #[test]
897 fn concurrent_turns_spend_one_session_budget_once() {
898 let d = tempfile::tempdir().unwrap();
899 let svc = Arc::new(Service::new(
900 Store::new(d.path().join("attachments")),
901 Arc::new(Broker::new()),
902 Arc::new(ConfigState::new()),
903 ));
904 let turns: Vec<_> = (0..8)
906 .map(|_| {
907 let svc = Arc::clone(&svc);
908 std::thread::spawn(move || {
909 svc.with_session_inline_budget("session:s", None, 100, |budget| {
910 std::thread::sleep(Duration::from_millis(2));
911 ((), budget.min(30))
912 })
913 })
914 })
915 .collect();
916 for t in turns {
917 t.join().unwrap();
918 }
919 assert_eq!(svc.store().session_inline_bytes("session:s"), 100);
920 }
921
922 #[test]
923 fn plan_accepts_every_file() {
924 let d = tempfile::tempdir().unwrap();
925 let img = touch_file(d.path(), "a.png", 100);
926 let noext = touch_file(d.path(), "screenshot", 100);
927 let txt = d.path().join("notes.md");
928 std::fs::write(&txt, "# hi").unwrap();
929 let svg = d.path().join("icon.svg");
930 std::fs::write(&svg, "<svg/>").unwrap();
931 let (txt, svg): (String, String) =
932 (txt.to_string_lossy().into(), svg.to_string_lossy().into());
933 let p = plan(
934 &[img.clone(), noext.clone(), txt.clone(), svg.clone()],
935 &Limits::default(),
936 0,
937 0,
938 );
939 assert_eq!(
940 p.accepted
941 .iter()
942 .map(|a| a.path.clone())
943 .collect::<Vec<_>>(),
944 vec![img, noext, txt, svg]
945 );
946 assert!(p.non_images.is_empty());
947 assert!(p.rejected.is_empty());
948 }
949
950 #[test]
951 fn plan_applies_count_limit_including_existing() {
952 let d = tempfile::tempdir().unwrap();
953 let paths: Vec<String> = (0..5)
954 .map(|i| touch_file(d.path(), &format!("{i}.png"), 10))
955 .collect();
956 let limits = Limits {
957 max_files: 4,
958 ..Limits::default()
959 };
960 let p = plan(&paths, &limits, 2, 0);
961 assert_eq!(p.accepted.len(), 2);
962 assert_eq!(p.rejected.len(), 3);
963 assert!(p.rejected.iter().all(|r| r.code == "too_many"));
964 assert_eq!(
965 p.accepted.iter().map(|a| a.index).collect::<Vec<_>>(),
966 vec![0, 1]
967 );
968 }
969
970 #[test]
971 fn plan_applies_byte_limit_and_skips_only_what_does_not_fit() {
972 let d = tempfile::tempdir().unwrap();
973 let big = touch_file(d.path(), "big.png", 800);
974 let small = touch_file(d.path(), "small.png", 100);
975 let limits = Limits {
976 max_total_bytes: 1000,
977 ..Limits::default()
978 };
979 let p = plan(&[big.clone(), big.clone(), small.clone()], &limits, 0, 0);
980 assert_eq!(
981 p.accepted
982 .iter()
983 .map(|a| a.path.clone())
984 .collect::<Vec<_>>(),
985 vec![big, small]
986 );
987 assert_eq!(p.rejected.len(), 1);
988 assert_eq!(p.rejected[0].code, "too_large");
989 }
990
991 #[test]
992 fn plan_expands_folders_sorted_skipping_hidden_and_build_trees() {
993 let d = tempfile::tempdir().unwrap();
994 touch_file(d.path(), "proj/b.png", 10);
995 touch_file(d.path(), "proj/a.jpg", 10);
996 touch_file(d.path(), "proj/.cache/x.png", 10);
997 touch_file(d.path(), "proj/node_modules/m/index.png", 10);
998 touch_file(d.path(), "proj/target/debug/out.png", 10);
999 std::fs::write(d.path().join("proj/readme.txt"), "x").unwrap();
1000 std::fs::write(d.path().join("proj/.env"), "SECRET=1").unwrap();
1001 let p = plan(
1002 &[d.path().join("proj").to_string_lossy().into()],
1003 &Limits::default(),
1004 0,
1005 0,
1006 );
1007 let names: Vec<_> = p.accepted.iter().map(|a| a.name.clone()).collect();
1008 assert_eq!(names, vec!["a.jpg", "b.png", "readme.txt"]);
1009 }
1010
1011 #[test]
1012 fn plan_reports_missing_paths() {
1013 let p = plan(
1014 &["Z:/definitely/not/here.png".into()],
1015 &Limits::default(),
1016 0,
1017 0,
1018 );
1019 assert_eq!(p.rejected.len(), 1);
1020 assert_eq!(p.rejected[0].code, "not_found");
1021 }
1022
1023 #[test]
1024 fn limits_read_from_settings_with_defaults() {
1025 let mut extra = HashMap::new();
1026 assert_eq!(Limits::from_settings(&extra), Limits::default());
1027 extra.insert("attachments:maxfiles".into(), serde_json::json!(10));
1028 extra.insert("attachments:maxtotalmb".into(), serde_json::json!(50));
1029 extra.insert("attachments:sendmaxedge".into(), serde_json::json!(99999));
1030 extra.insert("attachments:retentiondays".into(), serde_json::json!(-3));
1031 let l = Limits::from_settings(&extra);
1032 assert_eq!(l.max_files, 10);
1033 assert_eq!(l.max_total_bytes, 50 * 1024 * 1024);
1034 assert_eq!(l.send_max_edge, 8000);
1035 assert_eq!(l.retention, Limits::default().retention);
1036 assert_eq!(l.claude_inline_max, prompt::DEFAULT_INLINE_MAX_COUNT);
1037 extra.insert("attachments:claudeinlinemax".into(), serde_json::json!(0));
1038 assert_eq!(
1039 Limits::from_settings(&extra).claude_inline_max,
1040 0,
1041 "0 means paths only"
1042 );
1043 }
1044}