use anyhow::{bail, Context, Result};
use mecha_core::outbox::{DraftView, OutboxItem, OutboxKind, OutboxLock, OutboxStore};
use mecha_core::outbox_source::SourceRead;
use serde_json::Value;
use std::path::{Path, PathBuf};
use crate::{setup, GlobalOpts};
#[derive(clap::Args, Debug)]
pub struct Args {
#[command(subcommand)]
pub cmd: Option<Cmd>,
}
#[derive(clap::Args, Debug, Default, Clone)]
pub struct Selection {
pub ids: Vec<String>,
#[arg(long)]
pub all: bool,
#[arg(long)]
pub kind: Option<String>,
#[arg(long)]
pub via: Option<String>,
}
#[derive(clap::Subcommand, Debug)]
pub enum Cmd {
List {
#[arg(long)]
kind: Option<String>,
#[arg(long)]
via: Option<String>,
},
Show {
id: String,
#[arg(long)]
json: bool,
},
Edit {
id: String,
#[arg(long)]
json: bool,
},
Review {
#[command(flatten)]
selection: Selection,
},
#[command(alias = "send")]
Approve {
#[command(flatten)]
selection: Selection,
#[arg(long, short = 'y')]
yes: bool,
},
Reject {
#[command(flatten)]
selection: Selection,
#[arg(long)]
reason: Option<String>,
},
}
pub async fn execute(global: &GlobalOpts, args: Args) -> Result<()> {
let store = open_store()?;
match args.cmd.unwrap_or(Cmd::List {
kind: None,
via: None,
}) {
Cmd::List { kind, via } => list(&store, kind.as_deref(), via.as_deref()),
Cmd::Show { id, json } => show(&store, &id, json),
Cmd::Edit { id, json } => edit(&store, &id, json),
Cmd::Review { selection } => review(global, &store, &selection).await,
Cmd::Approve { selection, yes } => send(global, &store, &selection, yes).await,
Cmd::Reject { selection, reason } => reject(&store, &selection, reason),
}
}
pub(crate) fn open_store() -> Result<OutboxStore> {
let cwd = std::env::current_dir().context("cannot determine the working directory")?;
let cfg = mecha_core::config::Config::load(&cwd)?;
let root = match cfg.outbox.dir {
Some(dir) => dir,
None => OutboxStore::default_root()?,
};
OutboxStore::open(root)
}
fn parse_kind(kind: Option<&str>) -> Result<Option<OutboxKind>> {
match kind {
None => Ok(None),
Some("message") => Ok(Some(OutboxKind::Message)),
Some("publish") => Ok(Some(OutboxKind::Publish)),
Some(other) => bail!("`{other}` is not a kind (message | publish)"),
}
}
fn select(items: Vec<OutboxItem>, selection: &Selection) -> Result<Vec<OutboxItem>> {
let kind = parse_kind(selection.kind.as_deref())?;
let matches_filters = |item: &OutboxItem| {
kind.is_none_or(|k| item.kind == k)
&& selection
.via
.as_deref()
.is_none_or(|t| item.tool.contains(t))
};
if !selection.ids.is_empty() {
let mut out = Vec::new();
for id in &selection.ids {
let matched: Vec<&OutboxItem> = items.iter().filter(|i| i.id.starts_with(id)).collect();
match matched.len() {
0 => bail!("no outbox item matching `{id}`"),
1 => {
let item = matched[0].clone();
if !matches_filters(&item) {
bail!("`{id}` does not match the filters given alongside it");
}
if !out
.iter()
.any(|existing: &OutboxItem| existing.id == item.id)
{
out.push(item);
}
}
n => bail!(
"`{id}` matches {n} outbox items: {}",
matched
.iter()
.map(|i| i.id.as_str())
.collect::<Vec<_>>()
.join(", ")
),
}
}
return Ok(out);
}
if !selection.all {
bail!(
"name the items, or pass --all (optionally with --kind or --via). \
A command with no selection acts on nothing rather than on everything."
);
}
let chosen: Vec<OutboxItem> = items
.into_iter()
.filter(|i| i.status == "pending" && matches_filters(i))
.collect();
if chosen.is_empty() {
bail!("nothing pending matches that selection");
}
Ok(chosen)
}
fn list(store: &OutboxStore, kind: Option<&str>, via: Option<&str>) -> Result<()> {
let kind = parse_kind(kind)?;
let items = store.items()?;
if items.is_empty() {
println!("outbox empty — calls to [outbox]-routed tools are staged here");
return Ok(());
}
let filter = Selection {
all: true,
kind: kind.map(|k| k.as_str().to_string()),
via: via.map(String::from),
..Selection::default()
};
let pending = select(items.clone(), &filter).unwrap_or_default();
let resolved: Vec<OutboxItem> = items
.into_iter()
.filter(|i| i.status != "pending")
.filter(|i| kind.is_none_or(|k| i.kind == k) && via.is_none_or(|t| i.tool.contains(t)))
.collect();
for kind in [OutboxKind::Message, OutboxKind::Publish] {
let group: Vec<&OutboxItem> = pending.iter().filter(|i| i.kind == kind).collect();
if group.is_empty() {
continue;
}
println!("{} pending {}(s):", group.len(), kind.as_str());
for item in group {
println!(" {}", line(item));
}
}
if pending.is_empty() {
println!("nothing pending");
} else {
println!(
"\nreview them one at a time with `mecha outbox review --all`{}",
match (kind, via) {
(Some(k), _) => format!(" --kind {}", k.as_str()),
(_, Some(t)) => format!(" --via {t}"),
_ => String::new(),
}
);
}
if !resolved.is_empty() {
println!("\n{} resolved:", resolved.len());
for item in &resolved {
println!(" {}", line(item));
}
}
Ok(())
}
fn line(item: &OutboxItem) -> String {
format!(
"{} {:<8} {:<8} {}{}{}",
item.id,
item.status,
item.kind.as_str(),
item.summary,
if item.taint.trifecta_armed() {
" ⚠ tainted"
} else {
""
},
if item.edited() { " (edited)" } else { "" },
)
}
fn show(store: &OutboxStore, id: &str, json: bool) -> Result<()> {
let item = store.item(id)?;
if item.taint.trifecta_armed() {
println!(
"⚠ drafted in a conversation holding private data AND third-party \
content — read this as possibly an attacker's words, not the \
assistant's."
);
println!();
}
if let Some(error) = &item.error {
println!("last send attempt failed: {error}\n");
}
match item.kind {
OutboxKind::Message if json => {
println!("arguments a release would execute:");
println!("{}", indent(&pretty(&item.args)));
}
OutboxKind::Message => {
let view = DraftView::of(&item.args);
for (name, value) in &view.headers {
println!("{name:<9} {value}");
}
if let Some(body) = &view.body {
println!("\n{body}");
}
if !view.other.is_empty() {
println!("\nother arguments:");
for (name, value) in &view.other {
println!(" {name:<9} {value}");
}
}
if item.edited() {
println!("\nedited since drafting:");
println!(
"{}",
mecha_core::outbox::diff_args(&item.args_before, &item.args)
);
}
for read in source_reads(&item) {
println!("\n{}", source_heading(&read));
println!("{}", indent(&read.text));
}
}
OutboxKind::Publish => {
for (label, path) in local_paths(&item.args, item.workspace.as_deref()) {
println!("\n{label}: {}", path.display());
if let Some(entry) = entry_point(&path) {
println!("open {}", entry.display());
}
if !path.exists() {
println!(
" ⚠ gone — this was rendered into a run's work directory, \
which retention may since have swept. Re-render before \
releasing."
);
}
}
println!("\nwhat a release would publish:");
println!("{}", indent(&pretty(&item.args)));
}
}
println!(
"\noutbox item {} · {} · {} · {}",
item.id,
item.kind.as_str(),
item.tool,
item.status
);
println!("created {}", item.created_at);
if let Some(session) = &item.session_id {
println!("drafted by session {session}");
}
if let Some(resolved) = &item.resolved_at {
println!(
"resolved {resolved}{}",
item.reason
.as_deref()
.map(|r| format!(" — {r}"))
.unwrap_or_default()
);
}
if item.kind == OutboxKind::Message && !json {
println!(
"`mecha outbox show {} --json` prints the exact arguments",
item.id
);
}
if item.status == "pending" {
match item.kind {
OutboxKind::Message => println!(
"\nrelease with `mecha outbox approve {}`, or `edit` / `reject` it",
item.id
),
OutboxKind::Publish => println!(
"\nrelease with `mecha outbox approve {}`, or `reject` it. To change \
the content, edit the source and re-render — that stages a new item.",
item.id
),
}
}
Ok(())
}
pub(crate) fn source_reads(item: &OutboxItem) -> Vec<SourceRead> {
let Ok(dir) = mecha_core::session::Session::default_dir() else {
return Vec::new();
};
mecha_core::outbox_source::for_item(item, &dir)
}
pub(crate) fn source_heading(read: &SourceRead) -> String {
format!(
"replying to — third-party content via {} ({}), not part of your draft:",
read.tool,
read.keys.join(", ")
)
}
pub(crate) fn local_paths(
args: &Value,
workspace: Option<&Path>,
) -> Vec<(&'static str, std::path::PathBuf)> {
const KEYS: [(&str, &str); 6] = [
("bundle", "rendered bundle"),
("bundle_path", "rendered bundle"),
("path", "rendered bundle"),
("source", "source"),
("spec", "poll spec"),
("manifest", "form manifest"),
];
let Some(map) = args.as_object() else {
return Vec::new();
};
let mut out = Vec::new();
for (key, label) in KEYS {
if let Some(value) = map.get(key).and_then(|v| v.as_str()) {
let path = std::path::PathBuf::from(value);
let resolved = match (path.is_absolute(), workspace) {
(false, Some(jail)) => jail.join(&path),
_ => path,
};
out.push((label, resolved));
}
}
out
}
pub(crate) fn entry_point(path: &std::path::Path) -> Option<std::path::PathBuf> {
if path.is_file() {
return Some(path.to_path_buf());
}
["index.html", "index.md", "README.md"]
.iter()
.map(|name| path.join(name))
.find(|candidate| candidate.is_file())
}
fn edit(store: &OutboxStore, id: &str, json: bool) -> Result<()> {
let item = store.item(id)?;
if item.status != "pending" {
bail!("outbox item {} is {}, not pending", item.id, item.status);
}
if item.kind == OutboxKind::Publish {
bail!(
"outbox item {} is a publish, and its arguments are a path and a \
visibility flag rather than a draft — editing them would change \
neither the page nor what a reader sees.\n\
To change the content: edit the source, re-render, and publish \
again, which stages a new item. Then `reject {}`.",
item.id,
item.id
);
}
let body = if json {
None
} else {
mecha_core::outbox::DraftView::of(&item.args).body
};
let args = match body {
Some(body) => {
let reads = source_reads(&item);
let seeded = mecha_core::outbox_source::with_reference(&body, &reads);
let text =
crate::editor::edit_text(&seeded, &format!("mecha-outbox-edit-{}.md", item.id))
.context("the item is unchanged")?;
let text = if reads.is_empty() {
text.strip_suffix('\n').unwrap_or(&text).to_string()
} else {
mecha_core::outbox_source::strip_reference(&text)
.context(
"the reference marker is gone from the edited file, so where the \
reply ends cannot be told from where the quoted original begins — \
and the two ways to guess are mailing the original back or \
truncating your letter. The item is unchanged; run `edit` again \
and leave the marker line in place, or use `--json`.",
)?
.to_string()
};
mecha_core::outbox::with_body(&item.args, &text)
.context("the draft's body field went missing; the item is unchanged")?
}
None => {
if !json {
println!("no prose in this draft — opening its arguments instead");
}
let text = crate::editor::edit_text(
&pretty(&item.args),
&format!("mecha-outbox-edit-{}.json", item.id),
)
.context("the item is unchanged")?;
serde_json::from_str(&text)
.context("the edited file is not valid JSON; the item is unchanged")?
}
};
let _lock = store.lock()?;
let updated = store.update_args(&item.id, args)?;
if updated.edited() {
println!(
"edited; `send` will use the new text, and `mecha reflect` \
will mine the diff as a writing lesson once sent"
);
} else {
println!("no change");
}
Ok(())
}
fn claim_for_release(store: &OutboxStore, reviewed: &OutboxItem) -> Result<OutboxItem> {
let current = store.item(&reviewed.id)?;
anyhow::ensure!(
current.status == "pending",
"outbox item {} is {}, not pending — it was resolved while you were \
deciding, so nothing was sent",
current.id,
current.status
);
Ok(current)
}
struct Surface {
tools: setup::PreparedTools,
ctx: mecha_core::tool::ToolCtx,
}
impl Surface {
async fn build(global: &GlobalOpts, workspace: Option<&Path>) -> Result<Surface> {
let global = match workspace {
Some(dir) => GlobalOpts {
workspace: Some(dir.to_path_buf()),
..global.clone()
},
None => global.clone(),
};
let tools = setup::prepare_tools(&global, false).await?;
let ctx = mecha_core::tool::ToolCtx {
workspace: tools.workspace.clone(),
shell_timeout: std::time::Duration::from_secs(tools.config.tools.shell_timeout_secs),
security: tools.config.security.clone(),
output_budget_bytes: tools.config.tools.resolved_output_budget(None),
..mecha_core::tool::ToolCtx::default()
};
Ok(Surface { tools, ctx })
}
async fn release(&self, store: &OutboxStore, item: &OutboxItem) -> Result<String> {
let item = &claim_for_release(store, item)?;
let Some(tool) = self.tools.registry.get(&item.tool) else {
let msg = format!(
"tool `{}` is not available in this configuration. Available: {}",
item.tool,
self.tools
.registry
.iter()
.map(|t| t.name())
.collect::<Vec<_>>()
.join(", ")
);
store.record_error(&item.id, &msg)?;
bail!("{msg}");
};
let output = match tool.call(item.args.clone(), &self.ctx).await {
Ok(out) => out,
Err(e) => {
let msg = format!("{e:#}");
store.record_error(&item.id, &msg)?;
bail!("{msg}");
}
};
if output.is_error {
store.record_error(&item.id, &output.content)?;
bail!("the tool reported failure: {}", output.content);
}
store.resolve(&item.id, "sent", None)?;
Ok(output.content.trim().to_string())
}
}
#[derive(Default)]
struct Surfaces {
by_workspace: Vec<(Option<PathBuf>, Surface)>,
}
impl Surfaces {
async fn for_item(&mut self, global: &GlobalOpts, item: &OutboxItem) -> Result<&Surface> {
let key = item.workspace.clone();
if let Some(i) = self.by_workspace.iter().position(|(k, _)| *k == key) {
return Ok(&self.by_workspace[i].1);
}
let surface = Surface::build(global, key.as_deref()).await?;
self.by_workspace.push((key, surface));
Ok(&self.by_workspace.last().unwrap().1)
}
}
fn record_release_failure(
store: &OutboxStore,
_lock: &OutboxLock,
id: &str,
err: anyhow::Error,
) -> anyhow::Error {
match store.record_error(id, &format!("{err:#}")) {
Ok(()) => err,
Err(record) => {
anyhow::anyhow!("{err:#} (and recording the failure also failed: {record:#})")
}
}
}
fn confirm(question: &str) -> Result<bool> {
use std::io::Write;
print!("{question} [y/N] ");
std::io::stdout().flush()?;
let mut line = String::new();
if std::io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
println!();
return Ok(false);
}
Ok(line.trim().eq_ignore_ascii_case("y"))
}
async fn send(
global: &GlobalOpts,
store: &OutboxStore,
selection: &Selection,
yes: bool,
) -> Result<()> {
let lock = store.lock()?;
let items = select(store.items()?, selection)?;
for item in &items {
if item.status != "pending" {
bail!("outbox item {} is {}, not pending", item.id, item.status);
}
}
let tainted: Vec<&OutboxItem> = items.iter().filter(|i| i.taint.trifecta_armed()).collect();
if !yes && (items.len() > 1 || !tainted.is_empty()) {
if !tainted.is_empty() {
println!(
"⚠ {} of these drafts {} written in a conversation holding private \
data AND third-party content. If anything in them was not yours, \
an attacker may have put it there:\n",
tainted.len(),
if tainted.len() == 1 { "was" } else { "were" }
);
for item in &tainted {
println!("{} · {}", item.id, item.summary);
println!("{}\n", indent(&pretty(&item.args)));
}
}
if items.len() > 1 {
println!("about to send {} item(s):", items.len());
for item in &items {
println!(" {}", line(item));
}
}
if !confirm(&format!(
"\nsend {}?",
if items.len() == 1 {
"it".to_string()
} else {
format!("all {}", items.len())
}
))? {
println!("nothing sent; the items stay pending");
return Ok(());
}
}
let mut surfaces = Surfaces::default();
let (mut sent, mut failed) = (0usize, 0usize);
for item in &items {
let release = match surfaces.for_item(global, item).await {
Ok(surface) => surface.release(store, item).await,
Err(e) => Err(record_release_failure(store, &lock, &item.id, e)),
};
match release {
Ok(output) => {
sent += 1;
println!("sent {} via `{}`", item.id, item.tool);
if !output.is_empty() {
println!("{}", indent(&output));
}
if item.edited() {
println!(
" the draft was edited before sending — `mecha reflect` will \
mine the diff as a writing lesson"
);
}
}
Err(e) => {
failed += 1;
eprintln!("failed {}: {e:#}", item.id);
}
}
}
if items.len() > 1 || failed > 0 {
println!("\n{sent} sent, {failed} failed and still pending");
}
if failed > 0 {
bail!("{failed} of {} item(s) did not send", items.len());
}
Ok(())
}
async fn review(global: &GlobalOpts, store: &OutboxStore, selection: &Selection) -> Result<()> {
let mut selection = selection.clone();
if selection.ids.is_empty() {
selection.all = true;
}
let items = select(store.items()?, &selection)?;
let pending: Vec<OutboxItem> = items
.into_iter()
.filter(|i| i.status == "pending")
.collect();
if pending.is_empty() {
println!("nothing pending");
return Ok(());
}
let mut surfaces = Surfaces::default();
let (mut sent, mut rejected, mut kept) = (0usize, 0usize, 0usize);
for (i, item) in pending.iter().enumerate() {
let mut current = match store.item(&item.id) {
Ok(item) => item,
Err(_) => continue,
};
if current.status != "pending" {
continue;
}
loop {
println!("\n─── {} of {} ───", i + 1, pending.len());
show(store, ¤t.id, false)?;
print!("\n[s]end [e]dit [r]eject [k]eep [q]uit > ");
use std::io::Write;
std::io::stdout().flush()?;
let mut line = String::new();
if std::io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
println!("\nstopped; {} left pending", pending.len() - i);
return Ok(());
}
match line.trim().to_ascii_lowercase().as_str() {
"s" | "send" => {
let release = match surfaces.for_item(global, ¤t).await {
Ok(surface) => {
let _lock = store.lock()?;
surface.release(store, ¤t).await
}
Err(e) => match store.lock() {
Ok(lock) => Err(record_release_failure(store, &lock, ¤t.id, e)),
Err(lock_err) => Err(e.context(format!(
"(and the store lock needed to record this \
failure could not be taken: {lock_err:#})"
))),
},
};
match release {
Ok(output) => {
sent += 1;
println!("sent via `{}`", current.tool);
if !output.is_empty() {
println!("{}", indent(&output));
}
}
Err(e) => eprintln!("failed: {e:#}\nit stays pending"),
}
break;
}
"e" | "edit" => {
if let Err(e) = edit(store, ¤t.id, false) {
eprintln!("{e:#}");
}
current = store.item(¤t.id)?;
}
"r" | "reject" => {
let _lock = store.lock()?;
store.resolve(¤t.id, "rejected", None)?;
rejected += 1;
println!("rejected; nothing was sent");
break;
}
"k" | "keep" | "" => {
kept += 1;
break;
}
"q" | "quit" => {
println!("stopped; {} left pending", pending.len() - i);
return Ok(());
}
other => println!("`{other}`? one of s, e, r, k, q"),
}
}
}
println!("\n{sent} sent, {rejected} rejected, {kept} left pending");
Ok(())
}
fn reject(store: &OutboxStore, selection: &Selection, reason: Option<String>) -> Result<()> {
let _lock = store.lock()?;
let items = select(store.items()?, selection)?;
for item in &items {
let resolved = store.resolve(&item.id, "rejected", reason.clone())?;
println!("rejected {}; nothing was sent", resolved.id);
}
if items.len() > 1 {
println!("{} rejected", items.len());
}
Ok(())
}
fn pretty(v: &Value) -> String {
serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
}
fn indent(s: &str) -> String {
s.lines()
.map(|l| format!(" {l}"))
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn item(id: &str, status: &str, kind: OutboxKind, tool: &str) -> OutboxItem {
OutboxItem {
id: id.into(),
status: status.into(),
tool: tool.into(),
kind,
args_before: json!({"to": "a@example.com"}),
args: json!({"to": "a@example.com"}),
summary: format!("{tool} to a@example.com"),
session_id: None,
workspace: None,
taint: Default::default(),
created_at: "2026-08-06T07:00:00Z".into(),
resolved_at: None,
reason: None,
error: None,
}
}
fn queue() -> Vec<OutboxItem> {
vec![
item("aaa1", "pending", OutboxKind::Message, "mail__mail_send"),
item("aaa2", "pending", OutboxKind::Message, "mail__mail_reply"),
item(
"bbb1",
"pending",
OutboxKind::Publish,
"factory__bundle_publish",
),
item("ccc1", "sent", OutboxKind::Message, "mail__mail_send"),
]
}
fn temp_store() -> OutboxStore {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir()
.join("mecha-outbox-test")
.join(format!("{}-{nanos}", std::process::id()));
OutboxStore::open(dir).unwrap()
}
#[test]
fn a_draft_sent_while_you_were_deciding_is_not_sent_again() {
let store = temp_store();
let staged = store
.stage(
"mail__mail_send",
OutboxKind::Message,
json!({"to": "a@example.com"}),
Default::default(),
None,
None,
)
.unwrap();
let reviewed = staged.clone();
assert_eq!(reviewed.status, "pending");
claim_for_release(&store, &reviewed).expect("pending is releasable");
store.resolve(&staged.id, "sent", None).unwrap();
let err = claim_for_release(&store, &reviewed)
.unwrap_err()
.to_string();
assert!(err.contains("not pending"), "{err}");
assert!(err.contains("nothing was sent"), "{err}");
}
#[test]
fn the_claim_returns_the_stores_copy_rather_than_the_callers() {
let store = temp_store();
let staged = store
.stage(
"mail__mail_send",
OutboxKind::Message,
json!({"to": "a@example.com"}),
Default::default(),
None,
None,
)
.unwrap();
store
.update_args(&staged.id, json!({"to": "corrected@example.com"}))
.unwrap();
let claimed = claim_for_release(&store, &staged).unwrap();
assert_eq!(claimed.args, json!({"to": "corrected@example.com"}));
}
fn selection(ids: &[&str]) -> Selection {
Selection {
ids: ids.iter().map(|s| s.to_string()).collect(),
..Selection::default()
}
}
#[test]
fn a_kind_that_is_not_a_kind_is_refused_on_every_surface() {
let err = parse_kind(Some("publishes")).unwrap_err().to_string();
assert!(err.contains("message | publish"), "{err}");
let err = select(
queue(),
&Selection {
all: true,
kind: Some("publishes".into()),
..Selection::default()
},
)
.unwrap_err()
.to_string();
assert!(err.contains("message | publish"), "{err}");
assert_eq!(parse_kind(None).unwrap(), None);
assert_eq!(
parse_kind(Some("publish")).unwrap(),
Some(OutboxKind::Publish)
);
}
#[test]
fn a_selection_that_names_nothing_is_refused_rather_than_meaning_everything() {
let err = select(queue(), &Selection::default())
.unwrap_err()
.to_string();
assert!(err.contains("--all"), "{err}");
assert!(err.contains("acts on nothing"), "{err}");
}
#[test]
fn ids_may_be_prefixes_several_at_a_time_and_never_double_count() {
let chosen = select(queue(), &selection(&["aaa1", "bbb"])).unwrap();
assert_eq!(
chosen.iter().map(|i| i.id.as_str()).collect::<Vec<_>>(),
vec!["aaa1", "bbb1"]
);
let chosen = select(queue(), &selection(&["aaa1", "aaa1"])).unwrap();
assert_eq!(chosen.len(), 1);
let err = select(queue(), &selection(&["aaa"]))
.unwrap_err()
.to_string();
assert!(err.contains("matches 2"), "{err}");
assert!(select(queue(), &selection(&["zzz"])).is_err());
}
#[test]
fn all_means_every_pending_item_and_the_filters_narrow_it() {
let everything = Selection {
all: true,
..Selection::default()
};
let chosen = select(queue(), &everything).unwrap();
assert_eq!(
chosen.iter().map(|i| i.id.as_str()).collect::<Vec<_>>(),
vec!["aaa1", "aaa2", "bbb1"],
"the sent item is not a candidate"
);
let messages = Selection {
all: true,
kind: Some("message".into()),
..Selection::default()
};
assert_eq!(select(queue(), &messages).unwrap().len(), 2);
let one_tool = Selection {
all: true,
via: Some("bundle_publish".into()),
..Selection::default()
};
assert_eq!(select(queue(), &one_tool).unwrap()[0].id, "bbb1");
}
#[test]
fn a_filter_that_matches_nothing_is_an_error() {
let nothing = Selection {
all: true,
via: Some("mail__mail_snd".into()),
..Selection::default()
};
let err = select(queue(), ¬hing).unwrap_err().to_string();
assert!(err.contains("nothing pending matches"), "{err}");
let bad_kind = Selection {
all: true,
kind: Some("publishes".into()),
..Selection::default()
};
assert!(select(queue(), &bad_kind).is_err());
}
#[test]
fn an_id_that_contradicts_its_filters_is_refused() {
let contradictory = Selection {
ids: vec!["bbb1".into()],
kind: Some("message".into()),
..Selection::default()
};
let err = select(queue(), &contradictory).unwrap_err().to_string();
assert!(err.contains("does not match the filters"), "{err}");
}
#[test]
fn the_diff_names_changed_lines_only() {
let before = json!({"to": "a@example.com", "body": "hi"});
let after = json!({"to": "a@example.com", "body": "hello"});
let d = mecha_core::outbox::diff_args(&before, &after);
let removed = d.lines().find(|l| l.trim_start().starts_with('-')).unwrap();
let added = d.lines().find(|l| l.trim_start().starts_with('+')).unwrap();
assert!(removed.contains(r#""hi""#), "{d}");
assert!(added.contains(r#""hello""#), "{d}");
assert!(!d.contains("example.com"), "unchanged lines stay out: {d}");
let same = mecha_core::outbox::diff_args(&before, &before);
assert!(same.contains("no textual change"), "{same}");
}
#[test]
fn a_publish_points_at_the_file_that_defines_it() {
let poll = serde_json::json!({
"instrument": "book",
"poll_id": "lab-feb",
"spec": "/tmp/lab-feb.toml",
});
assert_eq!(
local_paths(&poll, None),
vec![("poll spec", std::path::PathBuf::from("/tmp/lab-feb.toml"))]
);
let form = serde_json::json!({"manifest": "/tmp/office-hours.toml"});
assert_eq!(
local_paths(&form, None),
vec![(
"form manifest",
std::path::PathBuf::from("/tmp/office-hours.toml")
)]
);
let message = serde_json::json!({"to": "a@b.c", "subject": "/etc/passwd"});
assert!(local_paths(&message, None).is_empty());
}
#[test]
fn a_relative_path_resolves_against_the_jail_it_was_drafted_in() {
let args = serde_json::json!({"spec": "retro-spec.toml"});
let jail = std::path::Path::new("/home/someone/.mecha/work/chat");
assert_eq!(
local_paths(&args, Some(jail)),
vec![("poll spec", jail.join("retro-spec.toml"))]
);
let absolute = serde_json::json!({"spec": "/tmp/elsewhere.toml"});
assert_eq!(
local_paths(&absolute, Some(jail)),
vec![("poll spec", std::path::PathBuf::from("/tmp/elsewhere.toml"))]
);
assert_eq!(
local_paths(&args, None),
vec![("poll spec", std::path::PathBuf::from("retro-spec.toml"))]
);
}
fn empty_surface() -> Surface {
use std::sync::Arc;
Surface {
tools: setup::PreparedTools {
registry: mecha_core::tool::Registry::new(),
sandbox: Arc::new(mecha_core::sandbox::Sandbox::new(Default::default())),
workspace: std::env::temp_dir(),
config: mecha_core::config::Config::default(),
approver: Arc::new(mecha_core::tool::ModeApprover {
mode: mecha_core::config::PermissionMode::Allow,
}),
todo: None,
skill: None,
mailbox: None,
_mcp: Vec::new(),
},
ctx: mecha_core::tool::ToolCtx::default(),
}
}
#[tokio::test]
async fn a_release_that_dies_before_the_tool_runs_records_the_error_on_the_item() {
let store = temp_store();
let staged = store
.stage(
"mail__mail_send",
OutboxKind::Message,
json!({"to": "a@example.com"}),
Default::default(),
None,
None,
)
.unwrap();
let err = empty_surface()
.release(&store, &staged)
.await
.unwrap_err()
.to_string();
assert!(err.contains("not available"), "{err}");
let after = store.item(&staged.id).unwrap();
assert_eq!(after.status, "pending", "the draft survives the failure");
assert!(
after
.error
.as_deref()
.is_some_and(|e| e.contains("not available")),
"the failure must be on the item, not only stderr: {:?}",
after.error
);
}
#[test]
fn a_surface_build_failure_lands_on_the_item_not_only_stderr() {
let store = temp_store();
let staged = store
.stage(
"factory__bundle_publish",
OutboxKind::Publish,
json!({"bundle": "site"}),
Default::default(),
None,
None,
)
.unwrap();
let lock = store.lock().unwrap();
let err = record_release_failure(
&store,
&lock,
&staged.id,
anyhow::anyhow!("the MCP server would not start"),
);
assert!(err.to_string().contains("would not start"), "{err:#}");
let after = store.item(&staged.id).unwrap();
assert_eq!(after.status, "pending", "record_error never resolves");
assert_eq!(
after.error.as_deref(),
Some("the MCP server would not start")
);
}
}