Skip to main content

mecha_core/
outbox.rs

1//! The outbox: staged outbound actions awaiting the user's review.
2//!
3//! An outbox-routed tool call is never executed by the agent loop — it is
4//! written here as a draft, and nothing leaves the machine until the user
5//! reads exactly what would be sent and releases it (`mecha outbox send`).
6//! That is "draft-only, never send" made structural: the gate lives in core,
7//! so an email or calendar tool — including a third-party MCP server's —
8//! needs no knowledge of it to be covered by it.
9//!
10//! The item keeps the drafted arguments (`args_before`) separate from the
11//! arguments the release will execute (`args`), because the difference is a
12//! measurement: a user edit before sending is a writing correction, and
13//! `mecha reflect` mines `diff(args_before, args)` into the learning store.
14//!
15//! **Staging is sink-agnostic; reviewing is not.** The outbox generalised to a
16//! new kind of outbound action — publishing a bundle to the public surface —
17//! without a line changing here, which was the design goal. Its *review*
18//! affordances did not: `show` printing arguments, `edit` opening them in
19//! `$EDITOR`, and the writing miner reading the diff all assume the staged
20//! thing is a message someone wrote. That is why an item carries an
21//! [`OutboxKind`], set at staging from `[outbox] publish_tools`, and why the
22//! miner keys on it — see [`OutboxKind::Publish`].
23//!
24//! Storage follows the learning store's rules: one pretty-printed JSON file
25//! per item so `$EDITOR` and `git diff` work on it, temp-sibling-and-rename
26//! for every rewrite so a reader never sees a half-written file, and an
27//! advisory flock for writers — taken *before reading the state acted on*,
28//! and never held across an editor invocation. Staging takes no lock at all:
29//! a fresh item is a fresh file with a unique id, and the agent loop must
30//! never block on a human's review session.
31
32use anyhow::{Context, Result};
33use serde::{Deserialize, Serialize};
34use serde_json::Value;
35use std::path::{Path, PathBuf};
36
37use crate::agent::Taint;
38use crate::session::Session;
39
40/// What kind of outbound action a staged item is, which decides how it is
41/// *reviewed* rather than how it is staged.
42#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "lowercase")]
44pub enum OutboxKind {
45    /// Prose somebody wrote and somebody else will read: an email, a calendar
46    /// invitation, a message. The reviewable object *is* the arguments, and an
47    /// edit before release is a writing correction worth learning from.
48    #[default]
49    Message,
50    /// A publication to the public surface: a rendered bundle, an alias move,
51    /// a request-type push. Three things follow, and each is a bug if undone:
52    ///
53    /// - The reviewable object is the **rendered page**, not the arguments —
54    ///   which are a path and a visibility flag.
55    /// - `edit` is refused. Editing the content means editing the source and
56    ///   re-rendering, which stages a new item; rewriting the path is not
57    ///   editing the draft.
58    /// - The writing miner **excludes it**. Feeding `diff(args_before, args)`
59    ///   of a changed directory path to a reflector that mines voice rules
60    ///   would carry noise into every future run's cached prefix. Same mistake
61    ///   as learning from `"Blocked by a hook:"`, in a new costume.
62    Publish,
63}
64
65impl OutboxKind {
66    pub fn as_str(&self) -> &'static str {
67        match self {
68            OutboxKind::Message => "message",
69            OutboxKind::Publish => "publish",
70        }
71    }
72}
73
74/// One staged outbound action.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct OutboxItem {
77    pub id: String,
78    /// `pending` | `sent` | `rejected`.
79    pub status: String,
80    /// The tool a release will execute, by registry name (`web__fetch`).
81    pub tool: String,
82    /// How this is reviewed. Defaulted rather than required, so items staged
83    /// before the field existed load as the kind they in fact were.
84    #[serde(default)]
85    pub kind: OutboxKind,
86    /// The arguments as the agent drafted them. Never modified — this is the
87    /// baseline the learning capture diffs against.
88    pub args_before: Value,
89    /// The arguments a release will execute. Starts equal to `args_before`;
90    /// `mecha outbox edit` rewrites it.
91    pub args: Value,
92    /// One line for `mecha outbox list`.
93    pub summary: String,
94    /// The session that drafted this, when the front-end knew it.
95    #[serde(default)]
96    pub session_id: Option<String>,
97    /// The path jail the call was drafted under.
98    ///
99    /// A staged call is a *deferred* tool call, and a tool call only means
100    /// anything relative to the workspace it was made in: `bundle` here is a
101    /// directory under the drafting run's jail. Release happens in another
102    /// process, minutes or hours later, from whatever directory the reviewer
103    /// happens to be standing in — so without this the release resolves the
104    /// argument against the wrong root. An absolute path fails loudly; a
105    /// relative one is worse, because a same-named directory beside the
106    /// reviewer would quietly publish the wrong bytes.
107    ///
108    /// Recording it also keeps the release inside the jail the *agent* was
109    /// held to, rather than the reviewer's, which is the stricter of the two
110    /// and the one the interlock reasoned about.
111    ///
112    /// Defaulted, like `kind`: items staged before the field existed load as
113    /// `None` and release exactly as they did before.
114    #[serde(default)]
115    pub workspace: Option<PathBuf>,
116    /// The conversation's taint at the moment of staging. An armed snapshot
117    /// means third-party text was in context when this draft was written —
118    /// review it as possibly an attacker's words, not the assistant's.
119    #[serde(default)]
120    pub taint: Taint,
121    pub created_at: String,
122    #[serde(default)]
123    pub resolved_at: Option<String>,
124    /// Why it was rejected, when it was.
125    #[serde(default)]
126    pub reason: Option<String>,
127    /// The last release attempt's failure, if any. A failed send stays
128    /// `pending` — the draft is still good; the delivery was not.
129    #[serde(default)]
130    pub error: Option<String>,
131}
132
133impl OutboxItem {
134    pub fn edited(&self) -> bool {
135        self.args != self.args_before
136    }
137
138    /// Whether `mecha reflect` may mine this item as a **writing** correction.
139    ///
140    /// A `writing`-domain reflection can become a consolidated rule, and a rule
141    /// rides in every future run's system prompt inside the cached prefix. That
142    /// is the longest half-life anything in this project has, so what feeds it
143    /// is filtered structurally rather than by a prompt asking the reflector to
144    /// use its judgement:
145    ///
146    /// - **Sent, and edited.** An unedited release is not a correction (that it
147    ///   is *positive* evidence is a separate, unread signal); a rejected one
148    ///   never went out.
149    /// - **A message.** A publish's `diff(args_before, args)` is a changed
150    ///   filesystem path or visibility flag. Mining it would teach voice rules
151    ///   from bookkeeping — the same mistake as learning from
152    ///   `"Blocked by a hook:"`, which is machine policy read as a human
153    ///   correction.
154    pub fn mineable_as_writing(&self) -> bool {
155        self.kind == OutboxKind::Message && self.status == "sent" && self.edited()
156    }
157}
158
159/// What the agent loop consults per call: the store plus the routed names.
160///
161/// `session_id` is interior-mutable because the front-end learns it after the
162/// agent (and its default [`RunContext`](crate::agent::RunContext)) is built:
163/// the session is created at run start, the route at setup. Best-effort —
164/// `None` on front-ends that record no session (batch, eval).
165pub struct OutboxRoute {
166    pub store: OutboxStore,
167    routed: std::collections::BTreeSet<String>,
168    publishes: std::collections::BTreeSet<String>,
169    session_id: std::sync::Mutex<Option<String>>,
170}
171
172impl OutboxRoute {
173    pub fn new(
174        store: OutboxStore,
175        routed: impl IntoIterator<Item = String>,
176        publishes: impl IntoIterator<Item = String>,
177    ) -> Self {
178        OutboxRoute {
179            store,
180            routed: routed.into_iter().collect(),
181            publishes: publishes.into_iter().collect(),
182            session_id: std::sync::Mutex::new(None),
183        }
184    }
185
186    pub fn routes(&self, tool: &str) -> bool {
187        self.routed.contains(tool)
188    }
189
190    pub fn routed(&self) -> impl Iterator<Item = &str> {
191        self.routed.iter().map(String::as_str)
192    }
193
194    /// A tool's kind, which is config's to declare and never the tool's: the
195    /// loop must not learn what a publish is, and a third-party MCP server
196    /// cannot be trusted to say. Anything unnamed is a message, which is the
197    /// conservative default — it keeps the arguments reviewable and the item
198    /// mineable, and the cost of getting it wrong is a voice rule learned from
199    /// a path rather than a page nobody could review.
200    pub fn kind_of(&self, tool: &str) -> OutboxKind {
201        if self.publishes.contains(tool) {
202            OutboxKind::Publish
203        } else {
204            OutboxKind::Message
205        }
206    }
207
208    /// Names declared as publishes. Used at startup to warn about one that is
209    /// not routed at all — it would execute unstaged, which is the
210    /// silently-degrading-sandbox shape the routed-name warning already
211    /// catches.
212    pub fn publishes(&self) -> impl Iterator<Item = &str> {
213        self.publishes.iter().map(String::as_str)
214    }
215
216    pub fn set_session_id(&self, id: &str) {
217        if let Ok(mut slot) = self.session_id.lock() {
218            *slot = Some(id.to_string());
219        }
220    }
221
222    pub fn session_id(&self) -> Option<String> {
223        self.session_id.lock().ok().and_then(|s| s.clone())
224    }
225}
226
227pub struct OutboxStore {
228    root: PathBuf,
229}
230
231/// Holds the store's writer lock for as long as it lives.
232pub struct OutboxLock {
233    _file: std::fs::File,
234}
235
236impl OutboxStore {
237    pub fn default_root() -> Result<PathBuf> {
238        if let Ok(dir) = std::env::var("MECHA_OUTBOX_DIR") {
239            return Ok(PathBuf::from(dir));
240        }
241        Ok(crate::work::mecha_home()?.join("outbox"))
242    }
243
244    pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
245        let root = root.into();
246        crate::create_private_dir(&root).with_context(|| format!("creating {}", root.display()))?;
247        Ok(OutboxStore { root })
248    }
249
250    /// Open at the default location only if it already exists — for read
251    /// paths that must not create state as a side effect.
252    pub fn open_existing_default() -> Option<Self> {
253        let root = Self::default_root().ok()?;
254        root.is_dir().then_some(OutboxStore { root })
255    }
256
257    pub fn root(&self) -> &Path {
258        &self.root
259    }
260
261    /// Stage a drafted call. No lock: the id is fresh, so there is no state
262    /// to race on, and the agent loop must never wait on a review session.
263    pub fn stage(
264        &self,
265        tool: &str,
266        kind: OutboxKind,
267        args: Value,
268        taint: Taint,
269        session_id: Option<String>,
270        workspace: Option<PathBuf>,
271    ) -> Result<OutboxItem> {
272        let item = OutboxItem {
273            id: Session::new_id(),
274            status: "pending".into(),
275            tool: tool.to_string(),
276            kind,
277            summary: summarize(tool, &args),
278            args_before: args.clone(),
279            args,
280            session_id,
281            workspace,
282            taint,
283            created_at: chrono::Utc::now().to_rfc3339(),
284            resolved_at: None,
285            reason: None,
286            error: None,
287        };
288        self.write_item(&item)?;
289        Ok(item)
290    }
291
292    /// Every item, oldest first.
293    pub fn items(&self) -> Result<Vec<OutboxItem>> {
294        let mut out = Vec::new();
295        for entry in std::fs::read_dir(&self.root)? {
296            let path = entry?.path();
297            if path.extension().and_then(|e| e.to_str()) != Some("json") {
298                continue;
299            }
300            match serde_json::from_str(&std::fs::read_to_string(&path)?) {
301                Ok(item) => out.push(item),
302                Err(e) => {
303                    tracing::warn!("skipping unreadable outbox item {}: {e}", path.display())
304                }
305            }
306        }
307        out.sort_by(|a: &OutboxItem, b: &OutboxItem| a.id.cmp(&b.id));
308        Ok(out)
309    }
310
311    /// Find one item by id or unique prefix. Ambiguity is an error rather
312    /// than a guess, same as session and proposal lookup.
313    pub fn item(&self, id: &str) -> Result<OutboxItem> {
314        let all = self.items()?;
315        let matches: Vec<&OutboxItem> = all.iter().filter(|i| i.id.starts_with(id)).collect();
316        match matches.len() {
317            0 => anyhow::bail!("no outbox item matching `{id}`"),
318            1 => Ok(matches[0].clone()),
319            n => anyhow::bail!(
320                "`{id}` matches {n} outbox items: {}",
321                matches
322                    .iter()
323                    .map(|i| i.id.as_str())
324                    .collect::<Vec<_>>()
325                    .join(", ")
326            ),
327        }
328    }
329
330    /// One item by its exact store-minted id — a single file read, never a
331    /// directory scan. For the hot paths (a button press on an event loop)
332    /// that already hold the full id and must not pay `items()`'s
333    /// read-and-parse of every draft ever staged. Prefix lookup stays
334    /// [`OutboxStore::item`]'s business.
335    ///
336    /// The id is validated by shape *before* it is joined onto the store
337    /// root: ids arrive here from button payloads, and a value shaped like a
338    /// path (`../…`) must be refused, not resolved. A hostile shape is an
339    /// error; a missing item is `Ok(None)`; a torn file is an error, so a
340    /// caller that maps errors to "unreadable" keeps failing closed.
341    pub fn item_exact(&self, id: &str) -> Result<Option<OutboxItem>> {
342        anyhow::ensure!(is_item_id(id), "`{id}` is not shaped like an outbox id");
343        let path = self.root.join(format!("{id}.json"));
344        let text = match std::fs::read_to_string(&path) {
345            Ok(text) => text,
346            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
347            Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
348        };
349        Ok(Some(
350            serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display()))?,
351        ))
352    }
353
354    /// Replace a pending item's release arguments. `args_before` is untouched
355    /// — it is the baseline the learning capture diffs against.
356    pub fn update_args(&self, id: &str, args: Value) -> Result<OutboxItem> {
357        let mut item = self.item(id)?;
358        anyhow::ensure!(
359            item.status == "pending",
360            "outbox item {} is {}, not pending",
361            item.id,
362            item.status
363        );
364        item.args = args;
365        item.summary = summarize(&item.tool, &item.args);
366        self.write_item(&item)?;
367        Ok(item)
368    }
369
370    /// Resolve a pending item as `sent` or `rejected`, in place — the file is
371    /// its own audit record, so nothing moves to an archive.
372    pub fn resolve(&self, id: &str, status: &str, reason: Option<String>) -> Result<OutboxItem> {
373        let mut item = self.item(id)?;
374        anyhow::ensure!(
375            item.status == "pending",
376            "outbox item {} is {}, not pending",
377            item.id,
378            item.status
379        );
380        item.status = status.to_string();
381        item.resolved_at = Some(chrono::Utc::now().to_rfc3339());
382        item.reason = reason;
383        item.error = None;
384        self.write_item(&item)?;
385        Ok(item)
386    }
387
388    /// Record a failed release attempt. The item stays `pending`: the draft
389    /// is still good, and the next `send` retries.
390    pub fn record_error(&self, id: &str, error: &str) -> Result<()> {
391        let mut item = self.item(id)?;
392        item.error = Some(error.to_string());
393        self.write_item(&item)
394    }
395
396    fn write_item(&self, item: &OutboxItem) -> Result<()> {
397        let path = self.root.join(format!("{}.json", item.id));
398        let tmp = path.with_extension("json.tmp");
399        std::fs::write(&tmp, serde_json::to_string_pretty(item)?)?;
400        std::fs::rename(&tmp, &path)?;
401        Ok(())
402    }
403
404    /// Writer lock for read-modify-write paths (edit, send, reject). Taken
405    /// before reading the item acted on; never held across `$EDITOR`.
406    pub fn lock(&self) -> Result<OutboxLock> {
407        use std::os::unix::io::AsRawFd;
408        let file = std::fs::OpenOptions::new()
409            .create(true)
410            .truncate(false)
411            .write(true)
412            .open(self.root.join(".lock"))?;
413        // SAFETY: flock on an fd we own, held open by the returned guard.
414        if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
415            return Err(std::io::Error::last_os_error()).context("locking the outbox");
416        }
417        Ok(OutboxLock { _file: file })
418    }
419}
420
421/// The shape of a store-minted id (`Session::new_id`: a timestamp, a hyphen,
422/// a uuid fragment). Checked before an id from the outside is joined onto the
423/// store root — nothing with a separator or a dot can name a file elsewhere.
424fn is_item_id(id: &str) -> bool {
425    !id.is_empty()
426        && id.len() <= 80
427        && id
428            .chars()
429            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
430}
431
432/// A per-line diff of two argument renderings, `- `/`+ ` prefixed. Line-set
433/// based: enough to show *what* changed in a draft without a diff crate, and
434/// the full before/after always survives on the item itself. Used by both
435/// `mecha outbox show` and the reflect pass that mines edits.
436pub fn diff_args(before: &Value, after: &Value) -> String {
437    let pretty = |v: &Value| serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string());
438    let b = pretty(before);
439    let a = pretty(after);
440    let b_lines: Vec<&str> = b.lines().collect();
441    let a_lines: Vec<&str> = a.lines().collect();
442    let mut out = String::new();
443    for line in &b_lines {
444        if !a_lines.contains(line) {
445            out.push_str(&format!("  - {line}\n"));
446        }
447    }
448    for line in &a_lines {
449        if !b_lines.contains(line) {
450            out.push_str(&format!("  + {line}\n"));
451        }
452    }
453    if out.is_empty() {
454        out.push_str("  (no textual change)\n");
455    }
456    out
457}
458
459/// One line for the list view: who and what when the arguments say, the
460/// compact JSON when they do not.
461///
462/// Keyed on well-known argument *names*, never on the tool — the store stays
463/// tool-agnostic, but a queue of mail drafts whose rows all lead with
464/// `{"body_markdown":…` made every review surface start with the least
465/// informative bytes of each item. Anything without the conventional fields
466/// falls back to what it always was.
467fn summarize(tool: &str, args: &Value) -> String {
468    let text = headline(args).unwrap_or_else(|| serde_json::to_string(args).unwrap_or_default());
469    format!("{tool} {}", clip(text, 80))
470}
471
472/// "to a@x — \"subject\"", when the arguments carry the conventional names.
473fn headline(args: &Value) -> Option<String> {
474    let map = args.as_object()?;
475    let field = |key: &str| {
476        map.get(key)
477            .and_then(|v| match v {
478                Value::String(s) => Some(s.clone()),
479                // `to` is a list on some surfaces and a string on others.
480                Value::Array(a) => Some(
481                    a.iter()
482                        .filter_map(|x| x.as_str())
483                        .collect::<Vec<_>>()
484                        .join(", "),
485                ),
486                _ => None,
487            })
488            .filter(|s| !s.trim().is_empty())
489    };
490    let to = field("to");
491    let subject = field("subject").or_else(|| field("title"));
492    match (to, subject) {
493        (Some(to), Some(subject)) => Some(format!("to {to} — \"{subject}\"")),
494        (Some(to), None) => Some(format!("to {to}")),
495        (None, Some(subject)) => Some(format!("\"{subject}\"")),
496        (None, None) => None,
497    }
498}
499
500/// Truncate on a char boundary; the text can be any UTF-8.
501fn clip(mut text: String, max: usize) -> String {
502    if text.len() > max {
503        let cut = (0..=max)
504            .rev()
505            .find(|&i| text.is_char_boundary(i))
506            .unwrap_or(0);
507        text.truncate(cut);
508        text.push('…');
509    }
510    text
511}
512
513/// A staged message, shaped the way a person reads one.
514///
515/// [`OutboxKind::Publish`]'s lesson generalises: **a message's reviewable
516/// object is the message**, not the JSON carrying it. A review surface that
517/// prints `{"body_markdown": "Dear Dirk,\n\nThank you…"}` asks the reviewer to
518/// decode escape sequences to find out what would be said in their name — and
519/// "approve without reading" is the exact failure the outbox exists to
520/// prevent, so a draft that is hard to read is a security cost rather than a
521/// cosmetic one. It is also what an editor should open: editing prose inside a
522/// JSON string literal is where a real newline becomes `\n`, a stray quote
523/// becomes a parse error, and the whole edit is refused for a reason that has
524/// nothing to do with what the person meant to say.
525///
526/// Keyed on well-known argument *names*, like [`headline`] and for the same
527/// reason: the store stays tool-agnostic, so a tool nobody anticipated is
528/// still reviewable — its fields land in `other` rather than vanishing.
529///
530/// **Nothing is dropped.** Every key of the arguments appears in exactly one
531/// of `headers`, `body` or `other`, and there is a test on that, because a
532/// field the reviewer cannot see is a field they approved without reading.
533/// That is the whole difference between reshaping a draft and summarising it.
534#[derive(Debug, Default, PartialEq, Eq)]
535pub struct DraftView {
536    /// Addressing and the other short scalars, in reading order.
537    pub headers: Vec<(String, String)>,
538    /// The prose, with its real newlines.
539    pub body: Option<String>,
540    /// Which argument the prose came from, so an edit writes it back to the
541    /// same key rather than guessing a second time.
542    pub body_field: Option<String>,
543    /// Everything else, unshaped — shown after the body, never hidden.
544    pub other: Vec<(String, String)>,
545}
546
547/// Header-ish arguments, in the order a person reads them rather than the
548/// order a map hands them back.
549/// Deliberately short. `thread_id` and `message_id` address the *provider*,
550/// not a person, and a reviewer answering "would I send this?" needs them the
551/// way a letter writer needs the postcode format — which is to say later, and
552/// not above the prose. They fall through to `other`, which every surface
553/// shows below the body.
554const HEADER_FIELDS: [&str; 8] = [
555    "to", "cc", "bcc", "channel", "subject", "title", "when", "account",
556];
557
558/// Arguments that carry the prose, most specific first. Exactly one wins; the
559/// runners-up are ordinary arguments and are shown as such.
560const BODY_FIELDS: [&str; 8] = [
561    "body_markdown",
562    "body_text",
563    "body_html",
564    "body",
565    "text",
566    "markdown",
567    "message",
568    "content",
569];
570
571impl DraftView {
572    pub fn of(args: &Value) -> DraftView {
573        let mut view = DraftView::default();
574        let Some(map) = args.as_object() else {
575            // Not an object: there is nothing to shape, and showing it raw is
576            // still showing all of it.
577            view.other.push(("arguments".into(), args.to_string()));
578            return view;
579        };
580        for key in HEADER_FIELDS {
581            if let Some(value) = map.get(key) {
582                view.headers.push((key.to_string(), render(value)));
583            }
584        }
585        for key in BODY_FIELDS {
586            if let Some(text) = map.get(key).and_then(Value::as_str) {
587                view.body = Some(text.to_string());
588                view.body_field = Some(key.to_string());
589                break;
590            }
591        }
592        for (key, value) in map {
593            if HEADER_FIELDS.contains(&key.as_str())
594                || view.body_field.as_deref() == Some(key.as_str())
595            {
596                continue;
597            }
598            view.other.push((key.clone(), render(value)));
599        }
600        view
601    }
602}
603
604/// One argument as a person should see it: a string as itself, a list of
605/// addresses joined, anything else as its JSON. An empty value says so rather
606/// than rendering as nothing — a blank `to` is a fact about the draft, and a
607/// label with nothing after it reads as a display bug instead.
608fn render(value: &Value) -> String {
609    let text = match value {
610        Value::String(s) => s.clone(),
611        Value::Array(a) if a.iter().all(Value::is_string) => a
612            .iter()
613            .filter_map(Value::as_str)
614            .collect::<Vec<_>>()
615            .join(", "),
616        other => other.to_string(),
617    };
618    if text.trim().is_empty() {
619        "(empty)".to_string()
620    } else {
621        text
622    }
623}
624
625/// The arguments that address the **provider** rather than a person.
626///
627/// The leftovers of the classification [`DraftView`] already makes: not
628/// addressing, not the prose, and a string. What remains — `thread_id`,
629/// `message_id`, a Slack `ts` — names the *object* the call acts on, which is
630/// exactly what two calls about the same object have in common. That makes it
631/// the join key from a staged draft back to the read that produced it
632/// ([`crate::outbox_source`]), and it lives here so the one decision about
633/// which argument is which is still made once.
634///
635/// `account` and the other headers are excluded on purpose, and it is the
636/// exclusion that makes the join worth anything: `{"account": "dartmouth"}`
637/// is shared by every mail call in the session and would match all of them,
638/// which is a filter that filters nothing. Provider ids are high-entropy
639/// because they have to be.
640pub fn provider_ids(args: &Value) -> Vec<(String, String)> {
641    let Some(map) = args.as_object() else {
642        return Vec::new();
643    };
644    let body = DraftView::of(args).body_field;
645    map.iter()
646        .filter(|(key, _)| !HEADER_FIELDS.contains(&key.as_str()))
647        .filter(|(key, _)| body.as_deref() != Some(key.as_str()))
648        .filter_map(|(key, value)| {
649            let text = value.as_str()?;
650            (!text.trim().is_empty()).then(|| (key.clone(), text.to_string()))
651        })
652        .collect()
653}
654
655/// Write an edited body back into the arguments it came from.
656///
657/// The inverse of [`DraftView::body_field`], and it lives here so the one
658/// decision about which key holds the prose is made once. Returns the changed
659/// arguments, or `None` when there was no body to replace — a caller must
660/// then fall back to editing the arguments themselves rather than silently
661/// changing nothing.
662pub fn with_body(args: &Value, body: &str) -> Option<Value> {
663    let field = DraftView::of(args).body_field?;
664    let mut args = args.clone();
665    args.as_object_mut()?
666        .insert(field, Value::String(body.to_string()));
667    Some(args)
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673    use serde_json::json;
674
675    fn scratch(name: &str) -> PathBuf {
676        let dir =
677            std::env::temp_dir().join(format!("mecha-outbox-test-{name}-{}", std::process::id()));
678        let _ = std::fs::remove_dir_all(&dir);
679        dir
680    }
681
682    #[test]
683    fn a_summary_leads_with_who_and_what_when_the_arguments_say() {
684        // The conventional fields, in every combination they arrive in.
685        assert_eq!(
686            summarize(
687                "mail__send",
688                &json!({"to": "a@x.org", "subject": "Tuesday?", "body_markdown": "long…"})
689            ),
690            "mail__send to a@x.org — \"Tuesday?\""
691        );
692        assert_eq!(
693            summarize(
694                "mail__send",
695                &json!({"to": ["a@x.org", "b@x.org"], "body": "hi"})
696            ),
697            "mail__send to a@x.org, b@x.org"
698        );
699        assert_eq!(
700            summarize(
701                "cal__event_create",
702                &json!({"title": "Standup", "start": "…"})
703            ),
704            "cal__event_create \"Standup\""
705        );
706
707        // Without them, the compact JSON it always was — and still bounded.
708        let plain = summarize("factory__bundle_publish", &json!({"bundle": "/tmp/x"}));
709        assert!(plain.contains("bundle"), "{plain}");
710        let long = summarize("t", &json!({"to": "x".repeat(200)}));
711        assert!(long.len() < 120, "{}", long.len());
712        assert!(long.ends_with('…'), "{long}");
713
714        // An empty `to` is absence, not an addressee.
715        assert_eq!(
716            summarize("t", &json!({"to": "", "body": "x"})),
717            r#"t {"body":"x","to":""}"#
718        );
719    }
720
721    #[test]
722    fn an_item_round_trips_and_lists_in_id_order() {
723        let root = scratch("roundtrip");
724        let store = OutboxStore::open(&root).unwrap();
725
726        let a = store
727            .stage(
728                "web__fetch",
729                OutboxKind::Message,
730                json!({"url": "https://a"}),
731                Taint::default(),
732                None,
733                None,
734            )
735            .unwrap();
736        let b = store
737            .stage(
738                "email__send",
739                OutboxKind::Message,
740                json!({"to": "x@y"}),
741                Taint {
742                    private: true,
743                    untrusted: true,
744                },
745                Some("sess-1".into()),
746                None,
747            )
748            .unwrap();
749
750        let items = store.items().unwrap();
751        assert_eq!(items.len(), 2);
752        // Ids sort by creation time, so listing order is staging order.
753        assert_eq!(items[0].id, a.id.min(b.id.clone()));
754
755        let loaded = store.item(&b.id).unwrap();
756        assert_eq!(loaded.tool, "email__send");
757        assert!(loaded.taint.trifecta_armed());
758        assert_eq!(loaded.session_id.as_deref(), Some("sess-1"));
759        assert_eq!(loaded.args, loaded.args_before);
760        assert!(!loaded.edited());
761
762        let _ = std::fs::remove_dir_all(&root);
763    }
764
765    #[test]
766    fn a_prefix_that_matches_two_items_is_an_error_not_a_guess() {
767        let root = scratch("prefix");
768        let store = OutboxStore::open(&root).unwrap();
769        store
770            .stage(
771                "t",
772                OutboxKind::Message,
773                json!({}),
774                Taint::default(),
775                None,
776                None,
777            )
778            .unwrap();
779        store
780            .stage(
781                "t",
782                OutboxKind::Message,
783                json!({}),
784                Taint::default(),
785                None,
786                None,
787            )
788            .unwrap();
789
790        // Both ids share the timestamp prefix of the second they were made in.
791        let err = store.item("2").unwrap_err();
792        assert!(err.to_string().contains("matches 2"), "{err}");
793
794        let _ = std::fs::remove_dir_all(&root);
795    }
796
797    #[test]
798    fn editing_replaces_args_and_never_touches_the_baseline() {
799        let root = scratch("edit");
800        let store = OutboxStore::open(&root).unwrap();
801        let item = store
802            .stage(
803                "web__fetch",
804                OutboxKind::Message,
805                json!({"url": "https://a"}),
806                Taint::default(),
807                None,
808                None,
809            )
810            .unwrap();
811
812        let edited = store
813            .update_args(&item.id, json!({"url": "https://b"}))
814            .unwrap();
815        assert!(edited.edited());
816        assert_eq!(edited.args_before, json!({"url": "https://a"}));
817        assert_eq!(edited.args, json!({"url": "https://b"}));
818
819        let _ = std::fs::remove_dir_all(&root);
820    }
821
822    /// The rule that protects every future run's system prompt. A publish's
823    /// edit diff is a changed filesystem path, and a `writing` reflection
824    /// becomes a rule in the cached prefix — the same class of mistake as
825    /// mining `"Blocked by a hook:"` as if a human had said it. Fails on the
826    /// old behaviour, which mined any sent-and-edited item.
827    #[test]
828    fn the_writing_miner_takes_edited_messages_and_never_publishes() {
829        let root = scratch("mineable");
830        let store = OutboxStore::open(&root).unwrap();
831
832        let cases = [
833            (OutboxKind::Message, "sent", true, true),
834            // A publish, edited and sent — the one the old filter accepted.
835            (OutboxKind::Publish, "sent", true, false),
836            // Unedited is not a correction; rejected never went out.
837            (OutboxKind::Message, "sent", false, false),
838            (OutboxKind::Message, "rejected", true, false),
839            (OutboxKind::Message, "pending", true, false),
840        ];
841        for (kind, status, edited, expected) in cases {
842            let mut item = store
843                .stage(
844                    "x__send",
845                    kind,
846                    json!({"path": "/tmp/a"}),
847                    Taint::default(),
848                    None,
849                    None,
850                )
851                .unwrap();
852            item.status = status.into();
853            if edited {
854                item.args = json!({"path": "/tmp/b"});
855            }
856            assert_eq!(
857                item.mineable_as_writing(),
858                expected,
859                "{kind:?} / {status} / edited={edited}"
860            );
861        }
862
863        let _ = std::fs::remove_dir_all(&root);
864    }
865
866    /// The kind is config's to declare, and anything unnamed stays a message —
867    /// which keeps the arguments reviewable rather than silently hiding them.
868    #[test]
869    fn a_routes_kind_comes_from_config_and_defaults_to_message() {
870        let root = scratch("kindof");
871        let store = OutboxStore::open(&root).unwrap();
872        let route = OutboxRoute::new(
873            store,
874            [
875                "mail__send".to_string(),
876                "factory__bundle_publish".to_string(),
877            ],
878            ["factory__bundle_publish".to_string()],
879        );
880        assert_eq!(
881            route.kind_of("factory__bundle_publish"),
882            OutboxKind::Publish
883        );
884        assert_eq!(route.kind_of("mail__send"), OutboxKind::Message);
885        assert_eq!(route.kind_of("never__heard_of_it"), OutboxKind::Message);
886
887        let _ = std::fs::remove_dir_all(&root);
888    }
889
890    /// Items written before the field existed must load as what they in fact
891    /// were, or an upgrade would reclassify every staged email as unknown.
892    #[test]
893    fn an_item_recorded_before_kinds_existed_loads_as_a_message() {
894        let item: OutboxItem = serde_json::from_value(json!({
895            "id": "20260101-000000-abc",
896            "status": "sent",
897            "tool": "mail__send",
898            "args_before": {"body": "a"},
899            "args": {"body": "b"},
900            "summary": "mail__send",
901            "created_at": "2026-01-01T00:00:00Z",
902        }))
903        .unwrap();
904        assert_eq!(item.kind, OutboxKind::Message);
905        assert!(item.mineable_as_writing());
906        // And the same for the jail it was drafted under: an older item names
907        // none, and a release falls back to the reviewer's workspace, which is
908        // exactly what it did before the field existed.
909        assert_eq!(item.workspace, None);
910    }
911
912    /// A staged call is a deferred tool call, and the release happens in
913    /// another process from another directory. Without the drafting jail on
914    /// the item, `{"bundle": "site"}` resolves against wherever the reviewer
915    /// stands — an absolute path fails loudly, and a relative one silently
916    /// publishes whatever `./site` happens to be there.
917    #[test]
918    fn a_staged_call_records_the_jail_it_was_drafted_under() {
919        let root = scratch("workspace");
920        let store = OutboxStore::open(&root).unwrap();
921        let jail = PathBuf::from("/home/someone/.mecha/work/morning");
922
923        let item = store
924            .stage(
925                "factory__bundle_publish",
926                OutboxKind::Publish,
927                json!({"bundle": "site", "id": "brief"}),
928                Taint::default(),
929                None,
930                Some(jail.clone()),
931            )
932            .unwrap();
933        assert_eq!(item.workspace.as_ref(), Some(&jail));
934
935        // And it survives the round-trip through the file, which is the only
936        // form the reviewing process ever sees.
937        let loaded = store.item(&item.id).unwrap();
938        assert_eq!(loaded.workspace.as_ref(), Some(&jail));
939
940        let _ = std::fs::remove_dir_all(&root);
941    }
942
943    #[test]
944    fn resolution_rewrites_in_place_and_only_pending_resolves() {
945        let root = scratch("resolve");
946        let store = OutboxStore::open(&root).unwrap();
947        let item = store
948            .stage(
949                "t",
950                OutboxKind::Message,
951                json!({}),
952                Taint::default(),
953                None,
954                None,
955            )
956            .unwrap();
957
958        let sent = store.resolve(&item.id, "sent", None).unwrap();
959        assert_eq!(sent.status, "sent");
960        assert!(sent.resolved_at.is_some());
961        assert_eq!(
962            store.items().unwrap().len(),
963            1,
964            "resolved in place, not archived"
965        );
966
967        let err = store.resolve(&item.id, "rejected", None).unwrap_err();
968        assert!(err.to_string().contains("not pending"), "{err}");
969        let err = store.update_args(&item.id, json!({"x": 1})).unwrap_err();
970        assert!(err.to_string().contains("not pending"), "{err}");
971
972        let _ = std::fs::remove_dir_all(&root);
973    }
974
975    #[test]
976    fn a_failed_release_records_the_error_and_stays_pending() {
977        let root = scratch("error");
978        let store = OutboxStore::open(&root).unwrap();
979        let item = store
980            .stage(
981                "t",
982                OutboxKind::Message,
983                json!({}),
984                Taint::default(),
985                None,
986                None,
987            )
988            .unwrap();
989
990        store.record_error(&item.id, "server unreachable").unwrap();
991        let loaded = store.item(&item.id).unwrap();
992        assert_eq!(loaded.status, "pending");
993        assert_eq!(loaded.error.as_deref(), Some("server unreachable"));
994
995        // A later successful resolution clears the stale error.
996        let sent = store.resolve(&item.id, "sent", None).unwrap();
997        assert_eq!(sent.error, None);
998
999        let _ = std::fs::remove_dir_all(&root);
1000    }
1001
1002    /// The targeted read behind the hot paths: one file, found or honestly
1003    /// missing, and a value shaped like a path is refused before it can name
1004    /// a file outside the store — even one that exists and would parse.
1005    #[test]
1006    fn an_exact_lookup_reads_one_file_and_refuses_a_hostile_id() {
1007        let root = scratch("exact");
1008        let store = OutboxStore::open(&root).unwrap();
1009        let staged = store
1010            .stage(
1011                "mail__send",
1012                OutboxKind::Message,
1013                json!({"to": "a@x.org"}),
1014                Taint::default(),
1015                None,
1016                None,
1017            )
1018            .unwrap();
1019
1020        let found = store.item_exact(&staged.id).unwrap().expect("found");
1021        assert_eq!(found.id, staged.id);
1022        assert_eq!(found.tool, "mail__send");
1023
1024        // Missing is None, not an error: the caller decides what absence means.
1025        assert!(store
1026            .item_exact("20990101T000000-deadbeef")
1027            .unwrap()
1028            .is_none());
1029
1030        // A perfectly valid item file sitting *beside* the store, reachable
1031        // only by traversal — the refusal below is not vacuous.
1032        let outside = root.parent().unwrap().join("mecha-outbox-evil.json");
1033        std::fs::write(&outside, serde_json::to_string_pretty(&staged).unwrap()).unwrap();
1034        for hostile in [
1035            "../mecha-outbox-evil",
1036            "a/b",
1037            "a.b",
1038            ".",
1039            "",
1040            &"x".repeat(200),
1041        ] {
1042            assert!(
1043                store.item_exact(hostile).is_err(),
1044                "{hostile:?} must be refused, not resolved"
1045            );
1046        }
1047        let _ = std::fs::remove_file(&outside);
1048
1049        let _ = std::fs::remove_dir_all(&root);
1050    }
1051
1052    /// The invariant that separates reshaping a draft from summarising one:
1053    /// every argument reaches the reviewer somewhere. A field that falls
1054    /// between the three buckets is a field released unread.
1055    #[test]
1056    fn a_draft_view_drops_no_argument() {
1057        let args = json!({
1058            "to": ["a@x.org", "b@x.org"],
1059            "subject": "Tuesday?",
1060            "body_markdown": "Dear A,\n\nHello.\n\nLuke",
1061            "account": "dartmouth",
1062            "importance": "high",
1063            "attachments": [{"name": "f.pdf"}],
1064        });
1065        let view = DraftView::of(&args);
1066        let mut seen: Vec<String> = view
1067            .headers
1068            .iter()
1069            .map(|(k, _)| k.clone())
1070            .chain(view.body_field.clone())
1071            .chain(view.other.iter().map(|(k, _)| k.clone()))
1072            .collect();
1073        seen.sort();
1074        let mut keys: Vec<String> = args.as_object().unwrap().keys().cloned().collect();
1075        keys.sort();
1076        assert_eq!(seen, keys);
1077        assert_eq!(view.body.as_deref(), Some("Dear A,\n\nHello.\n\nLuke"));
1078        // Reading order, not map order.
1079        assert_eq!(
1080            view.headers
1081                .iter()
1082                .map(|(k, _)| k.as_str())
1083                .collect::<Vec<_>>(),
1084            ["to", "subject", "account"]
1085        );
1086        assert_eq!(view.headers[0].1, "a@x.org, b@x.org");
1087    }
1088
1089    /// A tool nobody anticipated is still reviewable: no headers, no body, and
1090    /// every argument shown.
1091    #[test]
1092    fn an_unrecognised_draft_shows_everything_as_other() {
1093        let view = DraftView::of(&json!({"emoji": "wave", "ts": 17}));
1094        assert!(view.headers.is_empty() && view.body.is_none());
1095        assert_eq!(
1096            view.other,
1097            vec![
1098                ("emoji".to_string(), "wave".to_string()),
1099                ("ts".to_string(), "17".to_string())
1100            ]
1101        );
1102    }
1103
1104    /// A blank value is shown as blank-on-purpose. The alternative is a label
1105    /// with nothing after it, which reads as a broken display rather than as
1106    /// an empty recipient list.
1107    #[test]
1108    fn an_empty_argument_says_so() {
1109        let view = DraftView::of(&json!({"to": "", "body": "hi"}));
1110        assert_eq!(
1111            view.headers,
1112            vec![("to".to_string(), "(empty)".to_string())]
1113        );
1114    }
1115
1116    /// An edit writes back to the key the body came from, and to nothing else.
1117    #[test]
1118    fn an_edited_body_returns_to_its_own_field() {
1119        let args = json!({"thread_id": "t1", "body_markdown": "old", "account": "personal"});
1120        let edited = with_body(&args, "new").unwrap();
1121        assert_eq!(edited["body_markdown"], "new");
1122        assert_eq!(edited["thread_id"], "t1");
1123        assert_eq!(edited["account"], "personal");
1124        // No prose, no body edit — the caller must fall back rather than
1125        // silently save nothing.
1126        assert!(with_body(&json!({"event_id": "e1", "response": "accept"}), "x").is_none());
1127    }
1128}