1use 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "lowercase")]
44pub enum OutboxKind {
45 #[default]
49 Message,
50 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#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct OutboxItem {
77 pub id: String,
78 pub status: String,
80 pub tool: String,
82 #[serde(default)]
85 pub kind: OutboxKind,
86 pub args_before: Value,
89 pub args: Value,
92 pub summary: String,
94 #[serde(default)]
96 pub session_id: Option<String>,
97 #[serde(default)]
115 pub workspace: Option<PathBuf>,
116 #[serde(default)]
120 pub taint: Taint,
121 pub created_at: String,
122 #[serde(default)]
123 pub resolved_at: Option<String>,
124 #[serde(default)]
126 pub reason: Option<String>,
127 #[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 pub fn mineable_as_writing(&self) -> bool {
155 self.kind == OutboxKind::Message && self.status == "sent" && self.edited()
156 }
157}
158
159pub 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 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 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
231pub 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 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 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 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 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 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 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 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 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 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 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
421fn 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
432pub 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
459fn 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
472fn 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 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
500fn 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#[cfg(test)]
514mod tests {
515 use super::*;
516 use serde_json::json;
517
518 fn scratch(name: &str) -> PathBuf {
519 let dir =
520 std::env::temp_dir().join(format!("mecha-outbox-test-{name}-{}", std::process::id()));
521 let _ = std::fs::remove_dir_all(&dir);
522 dir
523 }
524
525 #[test]
526 fn a_summary_leads_with_who_and_what_when_the_arguments_say() {
527 assert_eq!(
529 summarize(
530 "mail__send",
531 &json!({"to": "a@x.org", "subject": "Tuesday?", "body_markdown": "long…"})
532 ),
533 "mail__send to a@x.org — \"Tuesday?\""
534 );
535 assert_eq!(
536 summarize(
537 "mail__send",
538 &json!({"to": ["a@x.org", "b@x.org"], "body": "hi"})
539 ),
540 "mail__send to a@x.org, b@x.org"
541 );
542 assert_eq!(
543 summarize(
544 "cal__event_create",
545 &json!({"title": "Standup", "start": "…"})
546 ),
547 "cal__event_create \"Standup\""
548 );
549
550 let plain = summarize("factory__bundle_publish", &json!({"bundle": "/tmp/x"}));
552 assert!(plain.contains("bundle"), "{plain}");
553 let long = summarize("t", &json!({"to": "x".repeat(200)}));
554 assert!(long.len() < 120, "{}", long.len());
555 assert!(long.ends_with('…'), "{long}");
556
557 assert_eq!(
559 summarize("t", &json!({"to": "", "body": "x"})),
560 r#"t {"body":"x","to":""}"#
561 );
562 }
563
564 #[test]
565 fn an_item_round_trips_and_lists_in_id_order() {
566 let root = scratch("roundtrip");
567 let store = OutboxStore::open(&root).unwrap();
568
569 let a = store
570 .stage(
571 "web__fetch",
572 OutboxKind::Message,
573 json!({"url": "https://a"}),
574 Taint::default(),
575 None,
576 None,
577 )
578 .unwrap();
579 let b = store
580 .stage(
581 "email__send",
582 OutboxKind::Message,
583 json!({"to": "x@y"}),
584 Taint {
585 private: true,
586 untrusted: true,
587 },
588 Some("sess-1".into()),
589 None,
590 )
591 .unwrap();
592
593 let items = store.items().unwrap();
594 assert_eq!(items.len(), 2);
595 assert_eq!(items[0].id, a.id.min(b.id.clone()));
597
598 let loaded = store.item(&b.id).unwrap();
599 assert_eq!(loaded.tool, "email__send");
600 assert!(loaded.taint.trifecta_armed());
601 assert_eq!(loaded.session_id.as_deref(), Some("sess-1"));
602 assert_eq!(loaded.args, loaded.args_before);
603 assert!(!loaded.edited());
604
605 let _ = std::fs::remove_dir_all(&root);
606 }
607
608 #[test]
609 fn a_prefix_that_matches_two_items_is_an_error_not_a_guess() {
610 let root = scratch("prefix");
611 let store = OutboxStore::open(&root).unwrap();
612 store
613 .stage(
614 "t",
615 OutboxKind::Message,
616 json!({}),
617 Taint::default(),
618 None,
619 None,
620 )
621 .unwrap();
622 store
623 .stage(
624 "t",
625 OutboxKind::Message,
626 json!({}),
627 Taint::default(),
628 None,
629 None,
630 )
631 .unwrap();
632
633 let err = store.item("2").unwrap_err();
635 assert!(err.to_string().contains("matches 2"), "{err}");
636
637 let _ = std::fs::remove_dir_all(&root);
638 }
639
640 #[test]
641 fn editing_replaces_args_and_never_touches_the_baseline() {
642 let root = scratch("edit");
643 let store = OutboxStore::open(&root).unwrap();
644 let item = store
645 .stage(
646 "web__fetch",
647 OutboxKind::Message,
648 json!({"url": "https://a"}),
649 Taint::default(),
650 None,
651 None,
652 )
653 .unwrap();
654
655 let edited = store
656 .update_args(&item.id, json!({"url": "https://b"}))
657 .unwrap();
658 assert!(edited.edited());
659 assert_eq!(edited.args_before, json!({"url": "https://a"}));
660 assert_eq!(edited.args, json!({"url": "https://b"}));
661
662 let _ = std::fs::remove_dir_all(&root);
663 }
664
665 #[test]
671 fn the_writing_miner_takes_edited_messages_and_never_publishes() {
672 let root = scratch("mineable");
673 let store = OutboxStore::open(&root).unwrap();
674
675 let cases = [
676 (OutboxKind::Message, "sent", true, true),
677 (OutboxKind::Publish, "sent", true, false),
679 (OutboxKind::Message, "sent", false, false),
681 (OutboxKind::Message, "rejected", true, false),
682 (OutboxKind::Message, "pending", true, false),
683 ];
684 for (kind, status, edited, expected) in cases {
685 let mut item = store
686 .stage(
687 "x__send",
688 kind,
689 json!({"path": "/tmp/a"}),
690 Taint::default(),
691 None,
692 None,
693 )
694 .unwrap();
695 item.status = status.into();
696 if edited {
697 item.args = json!({"path": "/tmp/b"});
698 }
699 assert_eq!(
700 item.mineable_as_writing(),
701 expected,
702 "{kind:?} / {status} / edited={edited}"
703 );
704 }
705
706 let _ = std::fs::remove_dir_all(&root);
707 }
708
709 #[test]
712 fn a_routes_kind_comes_from_config_and_defaults_to_message() {
713 let root = scratch("kindof");
714 let store = OutboxStore::open(&root).unwrap();
715 let route = OutboxRoute::new(
716 store,
717 [
718 "mail__send".to_string(),
719 "factory__bundle_publish".to_string(),
720 ],
721 ["factory__bundle_publish".to_string()],
722 );
723 assert_eq!(
724 route.kind_of("factory__bundle_publish"),
725 OutboxKind::Publish
726 );
727 assert_eq!(route.kind_of("mail__send"), OutboxKind::Message);
728 assert_eq!(route.kind_of("never__heard_of_it"), OutboxKind::Message);
729
730 let _ = std::fs::remove_dir_all(&root);
731 }
732
733 #[test]
736 fn an_item_recorded_before_kinds_existed_loads_as_a_message() {
737 let item: OutboxItem = serde_json::from_value(json!({
738 "id": "20260101-000000-abc",
739 "status": "sent",
740 "tool": "mail__send",
741 "args_before": {"body": "a"},
742 "args": {"body": "b"},
743 "summary": "mail__send",
744 "created_at": "2026-01-01T00:00:00Z",
745 }))
746 .unwrap();
747 assert_eq!(item.kind, OutboxKind::Message);
748 assert!(item.mineable_as_writing());
749 assert_eq!(item.workspace, None);
753 }
754
755 #[test]
761 fn a_staged_call_records_the_jail_it_was_drafted_under() {
762 let root = scratch("workspace");
763 let store = OutboxStore::open(&root).unwrap();
764 let jail = PathBuf::from("/home/someone/.mecha/work/morning");
765
766 let item = store
767 .stage(
768 "factory__bundle_publish",
769 OutboxKind::Publish,
770 json!({"bundle": "site", "id": "brief"}),
771 Taint::default(),
772 None,
773 Some(jail.clone()),
774 )
775 .unwrap();
776 assert_eq!(item.workspace.as_ref(), Some(&jail));
777
778 let loaded = store.item(&item.id).unwrap();
781 assert_eq!(loaded.workspace.as_ref(), Some(&jail));
782
783 let _ = std::fs::remove_dir_all(&root);
784 }
785
786 #[test]
787 fn resolution_rewrites_in_place_and_only_pending_resolves() {
788 let root = scratch("resolve");
789 let store = OutboxStore::open(&root).unwrap();
790 let item = store
791 .stage(
792 "t",
793 OutboxKind::Message,
794 json!({}),
795 Taint::default(),
796 None,
797 None,
798 )
799 .unwrap();
800
801 let sent = store.resolve(&item.id, "sent", None).unwrap();
802 assert_eq!(sent.status, "sent");
803 assert!(sent.resolved_at.is_some());
804 assert_eq!(
805 store.items().unwrap().len(),
806 1,
807 "resolved in place, not archived"
808 );
809
810 let err = store.resolve(&item.id, "rejected", None).unwrap_err();
811 assert!(err.to_string().contains("not pending"), "{err}");
812 let err = store.update_args(&item.id, json!({"x": 1})).unwrap_err();
813 assert!(err.to_string().contains("not pending"), "{err}");
814
815 let _ = std::fs::remove_dir_all(&root);
816 }
817
818 #[test]
819 fn a_failed_release_records_the_error_and_stays_pending() {
820 let root = scratch("error");
821 let store = OutboxStore::open(&root).unwrap();
822 let item = store
823 .stage(
824 "t",
825 OutboxKind::Message,
826 json!({}),
827 Taint::default(),
828 None,
829 None,
830 )
831 .unwrap();
832
833 store.record_error(&item.id, "server unreachable").unwrap();
834 let loaded = store.item(&item.id).unwrap();
835 assert_eq!(loaded.status, "pending");
836 assert_eq!(loaded.error.as_deref(), Some("server unreachable"));
837
838 let sent = store.resolve(&item.id, "sent", None).unwrap();
840 assert_eq!(sent.error, None);
841
842 let _ = std::fs::remove_dir_all(&root);
843 }
844
845 #[test]
849 fn an_exact_lookup_reads_one_file_and_refuses_a_hostile_id() {
850 let root = scratch("exact");
851 let store = OutboxStore::open(&root).unwrap();
852 let staged = store
853 .stage(
854 "mail__send",
855 OutboxKind::Message,
856 json!({"to": "a@x.org"}),
857 Taint::default(),
858 None,
859 None,
860 )
861 .unwrap();
862
863 let found = store.item_exact(&staged.id).unwrap().expect("found");
864 assert_eq!(found.id, staged.id);
865 assert_eq!(found.tool, "mail__send");
866
867 assert!(store
869 .item_exact("20990101T000000-deadbeef")
870 .unwrap()
871 .is_none());
872
873 let outside = root.parent().unwrap().join("mecha-outbox-evil.json");
876 std::fs::write(&outside, serde_json::to_string_pretty(&staged).unwrap()).unwrap();
877 for hostile in [
878 "../mecha-outbox-evil",
879 "a/b",
880 "a.b",
881 ".",
882 "",
883 &"x".repeat(200),
884 ] {
885 assert!(
886 store.item_exact(hostile).is_err(),
887 "{hostile:?} must be refused, not resolved"
888 );
889 }
890 let _ = std::fs::remove_file(&outside);
891
892 let _ = std::fs::remove_dir_all(&root);
893 }
894}