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}