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

+75
View File
@@ -289,6 +289,81 @@ pub fn draft_for(app: &SolveApp, dialog: Option<&dialogs::Dialog>, error: Option
Draft { title, body, image: None, texture: None, note: String::new(), source_signature: source_signature(app, dialog) }
}
/// A draft from a crash report (`solvecraft_engine::crash`): what happened, where, the command
/// and how to replay it. The design stays on this computer; the draft says how to share it.
pub fn crash_draft(report: &serde_json::Value) -> Draft {
let s = |k: &str| report.get(k).and_then(serde_json::Value::as_str).unwrap_or("?").to_string();
let what = match report.get("kind").and_then(serde_json::Value::as_str) {
Some("command_panic") => "An internal error stopped a command (the design was left unchanged)",
Some("kernel_panic_recovered") => "The geometry kernel hit an internal error (caught; the operation failed)",
Some("unclean_exit") => "SolidCraft closed unexpectedly (abort, stack overflow or the process was killed)",
_ => "SolidCraft hit an internal error",
};
let command = report.get("command").and_then(|c| c.get("command")).and_then(serde_json::Value::as_str);
let frames: Vec<&str> =
report.get("backtrace").and_then(serde_json::Value::as_array).into_iter().flatten().filter_map(serde_json::Value::as_str).take(25).collect();
let recent: Vec<String> = report
.get("recent_commands")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(|c| {
let id = c.get("command")?.as_str()?;
Some(if c.get("ok").and_then(serde_json::Value::as_bool) == Some(false) { format!("{id} (failed)") } else { id.to_string() })
})
.collect();
let app = report.get("app");
let field = |o: Option<&serde_json::Value>, k: &str| o.and_then(|o| o.get(k)).and_then(serde_json::Value::as_str).unwrap_or("?").to_string();
let system = report.get("system");
let mut body = format!(
"## What happened\n{what}{}.\n\nMessage: {}\nLocation: {}\nThread: {}\nHappened {} time(s), first {}\n\n## Backtrace\n{}\n## Command\n{}\n## Recent commands\n{}\n\n## Reproduction\nCrash report `{}` on the reporter's computer holds {}. Replay it with `solidcraft-cli crashes replay {}`.\nOnly attach the design if it can be shared publicly.\n\n## Environment\n{}\n",
command.map(|c| format!(" while running `{c}`")).unwrap_or_default(),
limited(&s("message"), 2000),
s("location"),
s("thread"),
report.get("count").and_then(serde_json::Value::as_u64).unwrap_or(1),
s("first_seen"),
block(&frames.join("\n")),
block(&json_context(&report.get("command"), 4000)),
if recent.is_empty() { "(none recorded)".to_string() } else { recent.join(", ") },
s("id"),
match (report.get("design_path").is_some(), report.get("replay_path").is_some()) {
(true, true) => "the design as it was before the crash (design.solvecraft) and the commands that crash it (replay.json)",
(false, true) => "the commands that crash it on a new design (replay.json)",
_ => "no replay (see its notes)",
},
s("id"),
block(&format!(
"SolidCraft {} ({}, built {})\nOS: {} {}\n{}",
field(app, "version"),
field(app, "commit"),
field(app, "built"),
field(system, "os"),
field(system, "arch"),
field(system, "environment")
)),
);
if body.len() > MAX_BODY {
truncate(&mut body, MAX_BODY - 32);
body.push_str("\n[report context truncated]\n");
}
let mut title = format!("Crash: {}", s("message").lines().next().unwrap_or_default());
truncate(&mut title, 160);
Draft { title, body, image: None, texture: None, note: String::new(), source_signature: 0 }
}
/// Open a reviewed draft for a crash report (no screenshot: the view shows nothing of it).
pub fn begin_crash(app: &mut SolveApp, ctx: &egui::Context, report: &serde_json::Value) {
if app.issue_report.draft.is_none() {
app.issue_report.cad_focus = ctx.memory(|m| m.focused());
}
cancel_capture(app);
let mut draft = crash_draft(report);
// A screenshot can still be added of the view as it is now.
draft.source_signature = source_signature(app, None);
app.issue_report.draft = Some(draft);
}
fn encode(value: &str) -> String {
let mut out = String::new();
for b in value.bytes() {