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:
@@ -92,6 +92,7 @@ mod tests {
|
||||
"/config",
|
||||
"/state",
|
||||
"/state/core/journal.sqlite",
|
||||
"/logs/core/journal.sqlite",
|
||||
] {
|
||||
assert!(!std::path::Path::new(path).exists(), "{path}");
|
||||
}
|
||||
|
||||
+22
-1
@@ -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"})))
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -376,6 +376,7 @@ fn dispatch(request: &Value, lines: &mut Lines) -> Option<Value> {
|
||||
let params = &request["params"];
|
||||
let result = match request["method"].as_str().unwrap() {
|
||||
"batch" => batch(lines),
|
||||
"trap" => std::arch::wasm32::unreachable(),
|
||||
"nested_environment" => nested_environment(lines),
|
||||
"forget" => forget(lines),
|
||||
"forgotten" => {
|
||||
|
||||
Reference in New Issue
Block a user