mod common;
use std::path::Path;
use common::salvor;
use predicates::prelude::*;
use salvor_core::{Effect, Event, EventEnvelope, RunId, SequenceNumber};
use salvor_store::{EventStore, SqliteStore};
use serde_json::json;
use tempfile::tempdir;
use uuid::Uuid;
async fn seed_running(path: &Path) -> RunId {
let store = SqliteStore::open(path).expect("store opens");
let run_id = RunId::from_uuid(Uuid::new_v4());
let envelope = EventEnvelope::new(
run_id,
SequenceNumber::new(0),
time::OffsetDateTime::UNIX_EPOCH,
Event::RunStarted {
agent_def_hash: "sha256:agent".into(),
input: json!("research otters"),
labels: None,
},
);
store.append(&envelope).await.expect("seed append");
run_id
}
async fn seed_dangling_write(path: &Path) -> RunId {
let store = SqliteStore::open(path).expect("store opens");
let run_id = RunId::from_uuid(Uuid::new_v4());
let events = vec![
Event::RunStarted {
agent_def_hash: "sha256:agent".into(),
input: json!("publish otters"),
labels: None,
},
Event::ToolCallRequested {
seq: SequenceNumber::new(1),
tool: "publish".into(),
input: json!({"doc": "otters"}),
effect: Effect::Write,
idempotency_key: None,
},
];
for (i, event) in events.into_iter().enumerate() {
let envelope = EventEnvelope::new(
run_id,
SequenceNumber::new(i as u64),
time::OffsetDateTime::UNIX_EPOCH,
event,
);
store.append(&envelope).await.expect("seed append");
}
run_id
}
#[tokio::test]
async fn abandon_retires_a_run_then_refuses_a_second() {
let dir = tempdir().expect("tempdir");
let store_path = dir.path().join("salvor.db");
let run_id = seed_running(&store_path).await;
let uuid = run_id.as_uuid().to_string();
salvor(&store_path)
.args(["abandon", &uuid, "--reason", "husk is dead forever"])
.assert()
.success()
.stdout(
predicate::str::contains("appended RunAbandoned at seq 1")
.and(predicate::str::contains("Status now abandoned")),
);
let store = SqliteStore::open(&store_path).expect("store opens");
let log = store.read_log(run_id).await.expect("log reads");
assert_eq!(log.len(), 2, "abandon appends exactly one event");
match &log[1].event {
Event::RunAbandoned {
reason,
unresolved_write,
} => {
assert_eq!(reason.as_deref(), Some("husk is dead forever"));
assert!(unresolved_write.is_none(), "no dangling write to record");
}
other => panic!("expected RunAbandoned, got {other:?}"),
}
salvor(&store_path)
.args(["abandon", &uuid])
.assert()
.code(1)
.stderr(predicate::str::contains("already terminal"));
}
#[tokio::test]
async fn abandon_of_needs_reconciliation_records_the_unresolved_write() {
let dir = tempdir().expect("tempdir");
let store_path = dir.path().join("salvor.db");
let run_id = seed_dangling_write(&store_path).await;
let uuid = run_id.as_uuid().to_string();
salvor(&store_path)
.args(["abandon", &uuid])
.assert()
.success()
.stdout(
predicate::str::contains("Status now abandoned")
.and(predicate::str::contains("publish"))
.and(predicate::str::contains("unresolved")),
);
let store = SqliteStore::open(&store_path).expect("store opens");
let log = store.read_log(run_id).await.expect("log reads");
match &log[2].event {
Event::RunAbandoned {
unresolved_write, ..
} => {
let write = unresolved_write
.as_ref()
.expect("records the dangling write");
assert_eq!(write.seq, SequenceNumber::new(1));
assert_eq!(write.tool, "publish");
}
other => panic!("expected RunAbandoned, got {other:?}"),
}
}