host: preserve journals, full traps and embedded agent output

Write new journal entries under logs/core and merge read-only legacy
history without moving SQLite/WAL files. Atomically spool failed batches,
retaining unspoolable entries for event-driven retries. Journal full trap
diagnostics before UTF-8-safe status display. Port session overflow archives
and followable line/byte excerpts to the still-shipped embedded agent.
Document retention/recovery and cover the contracts with regressions.
Include one rustfmt-only normalization in the existing SDK WASI test.

Card: SAU-143
This commit is contained in:
SAU-143 implementer
2026-10-26 13:40:15 +00:00
parent 168795b408
commit 037377fd27
13 changed files with 818 additions and 83 deletions
+22 -1
View File
@@ -489,7 +489,28 @@ async fn oversized_current_turn_preserves_the_previous_cursor() {
.unwrap();
let (body, reply) = server.next().await;
let serialized = input(&body, api).to_string();
assert!(serialized.contains("[tool output truncated"));
assert!(serialized.contains("Full output: /logs/agent/"));
let history = world.history();
let result: Value = history
.lines()
.map(|line| serde_json::from_str::<Value>(line).unwrap())
.find(|entry| {
entry["output"]
.as_str()
.is_some_and(|out| out.contains("Full output: "))
})
.unwrap();
let output = result["output"].as_str().unwrap();
let path = output
.split("Full output: ")
.nth(1)
.unwrap()
.split(". ")
.next()
.unwrap();
let archive = std::fs::read_to_string(world.0.join(path.trim_start_matches('/'))).unwrap();
assert!(archive.contains(&"x".repeat(200_000)));
assert!(output.len() <= 50 * 1024);
assert!(serialized.len() < 70_000);
reply
.send((413, json!({"type":"request_too_large"})))
+5 -5
View File
@@ -36,7 +36,7 @@ async fn status(child: &mut Child) -> std::process::ExitStatus {
}
async fn journal_exists(world: &Path) {
tokio::time::timeout(Duration::from_secs(15), async {
while !world.join("state/core/journal.sqlite").exists() {
while !world.join("logs/core/journal.sqlite").exists() {
tokio::task::yield_now().await;
}
})
@@ -251,7 +251,7 @@ async fn signals_work_during_boot() {
tokio::time::timeout(Duration::from_secs(15), async {
loop {
let db =
rusqlite::Connection::open(world.join("state/core/journal.sqlite")).unwrap();
rusqlite::Connection::open(world.join("logs/core/journal.sqlite")).unwrap();
if let Ok(2) = db.query_row(
"SELECT count(*) FROM journal WHERE line='service start core.lock'",
[],
@@ -336,7 +336,7 @@ async fn console_error_stops_host() {
.await
.unwrap();
assert_eq!(status(&mut child).await.code(), Some(1));
let db = rusqlite::Connection::open(world.join("state/core/journal.sqlite")).unwrap();
let db = rusqlite::Connection::open(world.join("logs/core/journal.sqlite")).unwrap();
let stopped: i64 = db
.query_row(
"SELECT count(*) FROM journal WHERE plugin='probe' AND line='stop'",
@@ -363,9 +363,9 @@ async fn interrupt_leaves_call_running() {
input.write_all(b"probe.busy\n").await.unwrap();
let pid = tokio::time::timeout(Duration::from_secs(20), async {
loop {
if world.join("state/core/journal.sqlite").exists() {
if world.join("logs/core/journal.sqlite").exists() {
let db =
rusqlite::Connection::open(world.join("state/core/journal.sqlite")).unwrap();
rusqlite::Connection::open(world.join("logs/core/journal.sqlite")).unwrap();
if let Ok(pid) = db.query_row(
"SELECT pid FROM journal WHERE plugin='probe' AND line='start busy {}'",
[],
+81 -5
View File
@@ -129,7 +129,7 @@ async fn stderr_flood_does_not_block_stdin() {
let payload = json!({"large":"x".repeat(1024*1024)});
assert_eq!(call(&h, "probe.echo", payload.clone()).await, payload);
h.stop().await;
let db = rusqlite::Connection::open(w.0.join("state/core/journal.sqlite")).unwrap();
let db = rusqlite::Connection::open(w.0.join("logs/core/journal.sqlite")).unwrap();
let count: i64 = db
.query_row(
"SELECT count(*) FROM journal WHERE line LIKE 'flood %'",
@@ -198,7 +198,7 @@ async fn null_error_is_success() {
async fn journal_recovers_after_sqlite_write_failure() {
let w = TestWorld::new();
let h = w.start().await;
let db = rusqlite::Connection::open(w.0.join("state/core/journal.sqlite")).unwrap();
let db = rusqlite::Connection::open(w.0.join("logs/core/journal.sqlite")).unwrap();
db.busy_timeout(std::time::Duration::from_secs(5)).unwrap();
db.execute_batch("CREATE TRIGGER reject_writes BEFORE INSERT ON journal BEGIN SELECT RAISE(FAIL,'test failure'); END;").unwrap();
assert_eq!(
@@ -206,6 +206,16 @@ async fn journal_recovers_after_sqlite_write_failure() {
json!({"failed_log":true})
);
call(&h, "core.log", json!({})).await;
let batches: Vec<Value> = std::fs::read_dir(w.0.join("logs/core/failed-batches"))
.unwrap()
.map(|file| serde_json::from_slice(&std::fs::read(file.unwrap().path()).unwrap()).unwrap())
.collect();
assert!(
batches
.iter()
.flat_map(|batch| batch.as_array().unwrap())
.any(|entry| entry["line"].as_str().unwrap().contains("failed_log"))
);
db.execute_batch("DROP TRIGGER reject_writes;").unwrap();
assert_eq!(
call(&h, "probe.echo", json!({"recovered":true})).await,
@@ -1068,7 +1078,7 @@ async fn journal_filters_and_entries_persist_in_sqlite() {
);
h.stop().await;
assert!(
std::fs::read(w.0.join("state/core/journal.sqlite"))
std::fs::read(w.0.join("logs/core/journal.sqlite"))
.unwrap()
.starts_with(b"SQLite format 3")
);
@@ -1254,8 +1264,9 @@ async fn journal_entries_have_uuid_time_identity_stream_and_line() {
async fn journal_has_wal_and_identity_indexes() {
let w = TestWorld::new();
let h = w.start().await;
assert!(!w.0.join("state/core/journal.sqlite").exists());
h.stop().await;
let db = rusqlite::Connection::open(w.0.join("state/core/journal.sqlite")).unwrap();
let db = rusqlite::Connection::open(w.0.join("logs/core/journal.sqlite")).unwrap();
assert_eq!(
db.query_row("PRAGMA journal_mode", [], |r| r.get::<_, String>(0))
.unwrap(),
@@ -1941,7 +1952,7 @@ async fn core_restart_cleanly_stops_detached_caller() {
std::fs::read_to_string(w.0.join("state/restart-stop")).unwrap(),
"persisted after EOF"
);
let db = rusqlite::Connection::open(w.0.join("state/core/journal.sqlite")).unwrap();
let db = rusqlite::Connection::open(w.0.join("logs/core/journal.sqlite")).unwrap();
let count: i64 = db
.query_row(
"SELECT count(*) FROM journal WHERE line = 'restart stop hook finished'",
@@ -1995,3 +2006,68 @@ async fn core_exit_alias_sets_executable_status_75() {
String::from_utf8_lossy(&output.stderr)
);
}
/// PROTOCOL 7: logs move, state and the old journal do not; core.log merges both.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn journal_location_preserves_legacy_history() {
let w = TestWorld::new();
let old = w.0.join("state/core/journal.sqlite");
let db = rusqlite::Connection::open(&old).unwrap();
db.execute_batch("CREATE TABLE journal (id TEXT PRIMARY KEY, time TEXT, pid TEXT, plugin TEXT, stream TEXT, line TEXT);
INSERT INTO journal VALUES ('0000','old time','old-pid','old-plugin','core','legacy history');").unwrap();
drop(db);
let before = std::fs::read(&old).unwrap();
let history = w.0.join("state/core/console-history");
std::fs::write(&history, "keep console history").unwrap();
let h = w.start().await;
call(&h, "probe.echo", json!({})).await;
assert!(w.0.join("logs/core/journal.sqlite").exists());
let entries = call(&h, "core.log", json!({"limit":-1})).await;
assert_eq!(entries[0]["line"], "legacy history");
assert!(entries.as_array().unwrap().len() > 1);
assert_eq!(
call(&h, "core.log", json!({"pid":"old-pid"})).await[0]["line"],
"legacy history"
);
h.stop().await;
assert_eq!(std::fs::read(&old).unwrap(), before);
assert_eq!(
std::fs::read_to_string(history).unwrap(),
"keep console history"
);
}
/// PROTOCOL 7: the journal retains the full trap, not just its short status reason.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn full_trap_diagnostic_is_journaled() {
let w = TestWorld::new();
let h = w.start().await;
let error = tokio::time::timeout(
std::time::Duration::from_secs(20),
h.call("probe.trap", json!({})),
)
.await
.unwrap()
.unwrap_err();
assert_eq!(error["code"], -32002);
assert!(
error["data"]["status"]
.as_str()
.unwrap()
.starts_with("trap: wasm trap:")
);
let entries = call(
&h,
"core.log",
json!({"plugin":"probe","grep":"wasm backtrace"}),
)
.await;
assert!(
entries.as_array().unwrap().iter().any(|e| {
let text = e["line"].as_str().unwrap();
text.contains("Caused by:") && text.contains("wasm trap:")
}),
"{entries}"
);
h.stop().await;
}