Crash reports: replayable, bounded, local, reachable by agents (#384)

Every panic (in a command, in the kernel, anywhere else) and every unclean
exit (abort, stack overflow, kill) leaves a report in a local crash folder:
message, file:line, a trimmed backtrace, version, commit, system and renderer,
the command and recent commands, and when possible the design and replay.json
that reproduce it (solvecraft-cli crashes replay <id>).

- Cost: nothing until the app installs the hook (tests, CLI and web never
  do); then one small journal line per top-level command of the design on
  screen, taken back with a truncate when the command changed nothing.
- Unclean exits: each run holds a lock on its journal; a journal nobody holds
  at the next start belongs to an app that died. Autosaves restart the
  journal from the autosaved design.
- Bounded: a crash's signature (place + message, numbers ignored) is counted,
  not rewritten; at most 5 new reports per run, 20 reports, 50 MB, 90 days;
  recovered kernel panics are pruned first.
- Agents: diagnostics.crash_reports / crash_report / crash_reports.clear
  commands, the MCP crash_reports tool, solvecraft-cli crashes, solvecraft
  --crash-log; diagnostics.crash_test (with SOLVECRAFT_CRASH_TEST) checks it.
- People: the Crash Reports window opens after a crash (Help > Crash
  Reports…) with a reviewed issue draft per report; nothing is sent.
- Release builds keep line tables for file:line in backtraces.

Tests: child processes that really panic in a command, in the kernel, on a
thread, and abort; dedup counts, limits, hostile folders, journal parsing,
the UI window and draft, the MCP tool. docs/crash-reports.md.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 16:50:49 -04:00
1 parent 55f2f29fff
commit 27415e4dd7
24 files changed
+1998 -9

No files matched your search

+13
View File
@@ -29,6 +29,18 @@ fn tool(s: &mut Server, name: &str, args: Value) -> Value {
if c["type"] == "text" { serde_json::from_str(c["text"].as_str().unwrap()).unwrap_or(Value::Null) } else { c.clone() }
}
#[test]
fn crash_reports_are_listed_and_read_without_changing_the_design() {
let mut s = server();
let list = tool(&mut s, "crash_reports", json!({"limit": 3}));
assert!(list["reports"].as_array().is_some_and(|r| r.len() <= 3), "{list}");
assert!(list["dir"].is_string(), "{list}");
let r = raw_tool(&mut s, "crash_reports", json!({"id": "../../etc"}));
assert_eq!(r["result"]["isError"], json!(true), "{r}");
let r = raw_tool(&mut s, "crash_reports", json!({"id": 5}));
assert!(r.get("error").is_some() || r["result"]["isError"] == json!(true), "{r}");
}
fn volume(s: &mut Server) -> f64 {
tool(s, "measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap()
}
@@ -67,6 +79,7 @@ fn handshake_and_tools_list() {
"export",
"undo",
"redo",
"crash_reports",
"new_design",
"open",
"save",
+22
View File
@@ -183,6 +183,24 @@ pub fn tool_definitions() -> Vec<Value> {
false,
false,
),
tool(
"crash_reports",
"Crash reports",
"SolidCraft's crash reports on this computer (the app's when connected to it). Without `id`: the list, newest \
first (id, kind, time, message, command, count, version, path, replayable). With `id`: the whole report: \
message, location, backtrace, app version and commit, system, the command that crashed and the recent ones, \
and design_path / replay_path when it can be replayed: `solidcraft-cli crashes replay <id>`, or \
`solidcraft-cli run <replay_path> --in <design_path>`. Reports never leave this computer.",
obj(
json!({
"id": string("A report id from the list"),
"limit": {"type": "integer", "description": "Reports to list (default 20)"},
}),
&[],
),
true,
false,
),
tool("undo", "Undo", "Undo the last change to the design.", obj(json!({}), &[]), false, false),
tool("redo", "Redo", "Redo the last undone change.", obj(json!({}), &[]), false, false),
tool(
@@ -352,6 +370,10 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map<String, Value>) -> Result<V
exec(b, "parameters.change", p)
}
"export" => exec(b, "file.export", pick(a, &["path", "format", "bodies"])),
"crash_reports" => match a.get("id") {
Some(_) => exec(b, "diagnostics.crash_report", pick(a, &["id"])),
None => exec(b, "diagnostics.crash_reports", pick(a, &["limit"])),
},
"undo" => exec(b, "edit.undo", json!({})),
"redo" => exec(b, "edit.redo", json!({})),
"new_design" => exec(b, "file.new", pick(a, &["name"])),