Skip to main content

agentmux_srv/backend/attachments/
mod.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Attachments for the agent composer: ingest from paths, process on
5//! a bounded blocking pool, report progress on the event bus, and keep a
6//! content-addressed store with retention by last use.
7//! docs/specs/SPEC_AGENT_PANE_IMAGE_ATTACHMENTS_2026_09_26.md §6,
8//! SPEC_AGENT_PANE_FILE_ATTACHMENTS_2026_09_26.md.
9
10pub 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
38/// Folder drops are walked for images; stop after this many entries so a
39/// drop of a home directory can't hang the server.
40const MAX_WALK_ENTRIES: usize = 10_000;
41/// Estimated decode memory all running jobs may hold at once, in MiB. A job
42/// reserves `process::decode_cost_bytes` of it from the image header before
43/// decoding; one bigger than the whole budget takes all of it and runs alone.
44const DECODE_BUDGET_MIB: u32 = 2048;
45/// Minimum gap between two progress events for the same item.
46const PROGRESS_INTERVAL: Duration = Duration::from_millis(100);
47const SWEEP_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60);
48
49/// Folders skipped when a dropped folder is expanded: dependency, build and
50/// VCS trees that would fill the 128-file limit with noise.
51/// SPEC_AGENT_PANE_FILE_ATTACHMENTS_2026_09_26.md §8.
52const 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/// Limits and knobs, read from settings on every ingest.
65#[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    /// Images Claude gets inline (the rest by path only). 0 = paths only.
72    pub claude_inline_max: usize,
73    /// Base64 bytes Claude may get inline across one session.
74    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            // 0 is meaningful here (paths only), so not `num`.
113            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    /// Bounds concurrent jobs (CPU).
134    permits: Arc<tokio::sync::Semaphore>,
135    /// Bounds the decode memory of concurrent jobs, in MiB.
136    memory: Arc<tokio::sync::Semaphore>,
137    /// Cancellation flags of batches still running.
138    batches: Mutex<HashMap<String, Arc<AtomicBool>>>,
139    /// Jobs currently processing each id. Identical images processed at
140    /// once share one stored original, so a failing job may only discard it
141    /// when no other job is still using it. Placing an original and the
142    /// discard check both happen under this lock.
143    inflight: Arc<Mutex<HashMap<String, usize>>>,
144    /// Serializes the per-session inline-budget read, spend and write, so two
145    /// overlapping turns of one Claude session can't both spend the same
146    /// remaining budget or overwrite each other's count.
147    session_budget: Mutex<()>,
148}
149
150/// One job's claim on an id in [`Service::inflight`], released on drop.
151struct 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
170/// The process-wide service. The first call creates the store and starts
171/// the retention timer; later calls return the same instance.
172pub 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
183/// The service when it has been initialised (by the RPC or HTTP layer).
184/// Prompt delivery uses this: it has no `AppState` to initialise from.
185pub 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            // Let startup finish first.
224            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    /// Classify `paths`, apply the limits, and (unless `dry_run`) start
240    /// processing the accepted images in the background. Returns at once.
241    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    /// Info for each id still in the store, in request order. May decode
281    /// (after a send-edge change), so call it off the async workers.
282    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    /// Metadata under the current fingerprint. When the send edge or the
293    /// pipeline changed since the attachment was processed, re-derive it
294    /// from the stored original under the same CPU and memory limits as
295    /// ingest, so a transcript full of old thumbnails can't start an
296    /// unbounded number of decodes at once.
297    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        // Another request may have derived it while this one waited.
306        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    /// Derive a placed original. Images reserve decode memory from their
314    /// header first; other files are bounded by the extraction limits
315    /// (`extract.rs`) and PDFs run out of process. The caller holds a CPU slot.
316    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    /// Run `f` with the inline budget left in Claude session `key` (out of
337    /// `max`), and record what it reports spending, as one step under a
338    /// lock. `carry_from` is a per-block key counted before the session had
339    /// an id; its count moves into `key` first. Blocking.
340    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    /// What the agent gets for attachment `id` sent under the user's `name`,
358    /// marking it used: an image's send-copy, or for any other file a
359    /// freshly made copy named `name`, with a note for the agent's list.
360    /// Doesn't decode: call [`Self::ensure_derived`] first so a stale
361    /// fingerprint is re-derived under the limits.
362    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    /// Reserve decode memory for a `w`×`h` image. Held until dropped.
382    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    /// Store a file already sitting in `incoming/` (an upload): place it,
389    /// reserve decode memory, derive. Returns its info.
390    pub async fn commit_upload(
391        self: &Arc<Self>,
392        tmp: PathBuf,
393        id: String,
394        name: String,
395    ) -> Result<AttachmentInfo, process::ProcessError> {
396        // Same CPU limit as batch jobs, so a burst of pastes can't run more
397        // decodes at once than a drop can.
398        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    /// [`Store::place`], registering this job as a user of the id first,
429    /// under the same lock the discard check takes. Blocking.
430    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    /// After a failed job: discard the original unless another job for the
456    /// same bytes is still processing it ([`Store::discard_if_unprocessed`]
457    /// also keeps it when any fingerprint has metadata). Consumes the guard.
458    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    /// One item: wait for a CPU slot, copy + hash + place, reserve decode
520    /// memory from the header, derive. The cancel flag is checked before
521    /// each step and between copy chunks; a decode already running finishes
522    /// but is reported as cancelled.
523    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            // A decode failure or a cancel after the original was stored
567            // must not leave it behind with no metadata.
568            self.discard_unprocessed(guard).await;
569        }
570        result
571    }
572
573    /// The part of [`Self::run_item`] after the original is in `blobs/`:
574    /// reserve decode memory from the header and derive.
575    #[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    /// Copy the source into `incoming/` with progress and cancellation, then
613    /// move it into `blobs/`. Runs on a blocking thread.
614    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            // A file that grows while we copy it must not blow the limit.
640            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/// One attachment as delivered to an agent.
672#[derive(Debug, Clone, PartialEq, Eq)]
673pub struct SendTarget {
674    /// The file the agent opens: an image's send-copy, or a named copy.
675    pub path: PathBuf,
676    pub mime: String,
677    pub page_count: Option<u32>,
678    /// Shown in square brackets after the name in the agent's list.
679    pub note: Option<String>,
680}
681
682/// "PDF, 12 pages; text version: /…/text.txt", "Word document; no text
683/// version, save it as .docx", "spreadsheet; contains macros". `None` for
684/// images and for kinds with nothing to add.
685fn 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
718/// Run blocking work off the async workers, turning a panic into an error.
719async 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/// The outcome of classifying an ingest request against the limits.
731#[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
744/// Files under `dir`, depth-first, sorted by name. Hidden files and folders
745/// (a leading dot: `.git`, `.env`, …) and [`SKIPPED_DIRS`] are left out —
746/// only a file dropped on its own is attached whatever its name.
747fn 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
774/// Every dropped file (and every file in a dropped folder) against the
775/// limits. `non_images` is always empty now that any file can be attached;
776/// the field stays for older frontends. Pure apart from reading metadata.
777pub 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        // A PNG signature is enough for classification by content.
867        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        // Two identical, undecodable-but-sniffable images in flight at once.
882        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        // Eight turns race for a 100-byte budget, each wanting up to 30.
905        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}