forked from 1b-agent/fieldcraft-upstream
diagnostics.crash_reports.delete deletes one report or all. The window lists each report (when, what, how many times) with Report…, Copy Path and Delete, and Delete All; ui.prompt answers it over the control channel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
199 lines
9.3 KiB
Rust
199 lines
9.3 KiB
Rust
//! Crash reports and bug report drafts (docs/crash-reports.md): list and read the reports the
|
||
//! desktop app wrote, draft a bug report, and (with `FIELDCRAFT_CRASH_TEST=1`) crash on purpose
|
||
//! to check that reporting works.
|
||
|
||
use std::path::PathBuf;
|
||
|
||
use fieldcraft_io::roots::Access;
|
||
use serde_json::{Value, json};
|
||
|
||
use super::{file_param, opt, req};
|
||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session, bug_report, crash};
|
||
|
||
pub const CRASH_REPORTS: CommandSpec = CommandSpec {
|
||
id: "diagnostics.crash_reports",
|
||
label: "Crash Reports",
|
||
params: r#"{"limit"?: 20, "dir"?: "…"} — the crash reports the desktop app wrote on this computer, newest first: id, kind (command_panic, recovered_panic, panic, unclean_exit), time, message, command, count, version, path and whether it can be replayed (default folder: FIELDCRAFT_CRASH_DIR, a temporary folder in runs driven by automation, else the per-user data folder)"#,
|
||
form: Some(super::forms::CRASH_REPORTS),
|
||
kind: CommandKind::Session,
|
||
requires: &[],
|
||
run: crash_reports,
|
||
};
|
||
|
||
pub const CRASH_REPORT: CommandSpec = CommandSpec {
|
||
id: "diagnostics.crash_report",
|
||
label: "Crash Report",
|
||
params: r#"{"id": "20261014-101500-…", "dir"?: "…"} — one crash report: what happened, where, the backtrace, the command and recent commands, and the paths of its model, replay and log files"#,
|
||
form: Some(super::forms::CRASH_REPORT),
|
||
kind: CommandKind::Session,
|
||
requires: &[],
|
||
run: crash_report,
|
||
};
|
||
|
||
pub const CRASH_REPORTS_DELETE: CommandSpec = CommandSpec {
|
||
id: "diagnostics.crash_reports.delete",
|
||
label: "Delete Crash Reports",
|
||
params: r#"{"id": "20261014-101500-…"} or {"all": true}; "dir"?: "…" — deletes one crash report, or all of them, from this computer: how many were deleted"#,
|
||
form: Some(super::forms::CRASH_REPORTS_DELETE),
|
||
kind: CommandKind::Session,
|
||
requires: &[],
|
||
run: crash_reports_delete,
|
||
};
|
||
|
||
pub const BUG_REPORT: CommandSpec = CommandSpec {
|
||
id: "diagnostics.bug_report",
|
||
label: "Report a Bug",
|
||
params: r#"{"title"?: "…", "observed"?: "what happened", "crash"?: "<crash report id>", "share_session"?: false, "include_model"?: false} — drafts a GitHub bug report: the issue link (the bug form filled with the version, the OS, what happened and, for a crash, where), and the files to attach. Only with share_session does it carry the model's commands, and only with include_model as well the model file. Nothing is sent: the desktop app's Help › Report a Bug shows the draft and opens the link when the user asks"#,
|
||
form: Some(super::forms::BUG_REPORT),
|
||
kind: CommandKind::Session,
|
||
requires: &[],
|
||
run: bug_report_draft,
|
||
};
|
||
|
||
pub const CRASH_TEST: CommandSpec = CommandSpec {
|
||
id: "diagnostics.crash_test",
|
||
label: "Crash Test",
|
||
params: r#"{"mode": "panic|recovered|abort"} — crashes on purpose to check that crash reports work (only with FIELDCRAFT_CRASH_TEST=1): panic, an internal error in this command; recovered, one caught inside it; abort, the app ends at once (reported at its next start)"#,
|
||
form: Some(super::forms::CRASH_TEST),
|
||
kind: CommandKind::Session,
|
||
requires: &[],
|
||
run: crash_test,
|
||
};
|
||
|
||
fn crash_dir(s: &Session, p: &Value) -> Result<PathBuf> {
|
||
crash_dir_for(s, p, Access::Read)
|
||
}
|
||
|
||
fn crash_dir_for(s: &Session, p: &Value, access: Access) -> Result<PathBuf> {
|
||
if p.get("dir").is_some() {
|
||
return Ok(PathBuf::from(file_param(s, p, "dir", access)?));
|
||
}
|
||
crash::default_dir().ok_or_else(|| EngineError::Params("no crash folder: set FIELDCRAFT_CRASH_DIR or give `dir`".into()))
|
||
}
|
||
|
||
fn crash_reports(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let dir = crash_dir(s, p)?;
|
||
let limit = opt::<usize>(p, "limit")?.unwrap_or(crash::MAX_REPORTS);
|
||
let mut reports = crash::list(&dir);
|
||
reports.truncate(limit);
|
||
Ok(json!({"dir": dir.to_string_lossy(), "reports": reports}))
|
||
}
|
||
|
||
fn crash_report(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let dir = crash_dir(s, p)?;
|
||
let id = req::<String>(p, "id")?;
|
||
crash::read(&dir, &id).map_err(EngineError::Params)
|
||
}
|
||
|
||
fn crash_reports_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let id = opt::<String>(p, "id")?;
|
||
let all = opt::<bool>(p, "all")?.unwrap_or(false);
|
||
let dir = crash_dir_for(s, p, Access::Write)?;
|
||
let deleted = match (id.as_deref(), all) {
|
||
(Some(id), false) => match crash::clear(&dir, Some(id)) {
|
||
0 => return Err(EngineError::Params(format!("no crash report `{}`", crash::limited(id, 64)))),
|
||
n => n,
|
||
},
|
||
(None, true) => crash::clear(&dir, None),
|
||
_ => return Err(EngineError::Params("give one of `id` (a crash report) or `all: true`".into())),
|
||
};
|
||
Ok(json!({"deleted": deleted}))
|
||
}
|
||
|
||
/// The draft input for `p` from the session: its commands and model (when shared), or a crash
|
||
/// report's replay and model.
|
||
pub fn bug_report_input(s: &Session, p: &Value) -> Result<bug_report::Input> {
|
||
let share = opt::<bool>(p, "share_session")?.unwrap_or(false);
|
||
let include_model = opt::<bool>(p, "include_model")?.unwrap_or(false);
|
||
let mut input = bug_report::Input {
|
||
title: opt::<String>(p, "title")?.unwrap_or_default(),
|
||
observed: opt::<String>(p, "observed")?.unwrap_or_default(),
|
||
share_session: share,
|
||
include_model,
|
||
..Default::default()
|
||
};
|
||
match opt::<String>(p, "crash")? {
|
||
Some(id) => {
|
||
let report = crash::read(&crash_dir(s, p)?, &id).map_err(EngineError::Params)?;
|
||
if share {
|
||
let read = |k: &str| {
|
||
report
|
||
.get(k)
|
||
.and_then(Value::as_str)
|
||
.and_then(|f| fieldcraft_io::fs::read_capped(std::path::Path::new(f), crash::MAX_MODEL_BYTES as u64).ok())
|
||
};
|
||
let replay: Value = read("replay_path").and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or(Value::Null);
|
||
input.commands = replay.get("commands").and_then(Value::as_array).cloned().unwrap_or_default();
|
||
input.model = if include_model { read("model_path") } else { None };
|
||
}
|
||
input.crash = Some(report);
|
||
}
|
||
None if share => {
|
||
input.commands = s.journal.iter().map(|(command, params)| json!({"command": command, "params": params})).collect();
|
||
input.model = if include_model { Some(s.file_bytes(None)?) } else { None };
|
||
}
|
||
None => {}
|
||
}
|
||
Ok(input)
|
||
}
|
||
|
||
fn bug_report_draft(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let input = bug_report_input(s, p)?;
|
||
Ok(bug_report::draft(&input, &crash::Build::current().version_line()).to_json())
|
||
}
|
||
|
||
// The one deliberate panic and abort: they prove that crash reports work, and run only when
|
||
// asked for with FIELDCRAFT_CRASH_TEST=1.
|
||
#[allow(clippy::panic)]
|
||
fn crash_test(_: &mut Session, p: &Value) -> Result<Value> {
|
||
let mode = req::<String>(p, "mode")?;
|
||
if std::env::var_os("FIELDCRAFT_CRASH_TEST").is_none_or(|v| v != "1") {
|
||
return Err(EngineError::Params("the crash test runs only with FIELDCRAFT_CRASH_TEST=1".into()));
|
||
}
|
||
match mode.as_str() {
|
||
"panic" => std::panic::panic_any(String::from("crash test: an internal error in a command")),
|
||
"recovered" => {
|
||
let caught = std::panic::catch_unwind(|| std::panic::panic_any(String::from("crash test: an internal error caught inside a command")));
|
||
Ok(json!({"caught": caught.is_err()}))
|
||
}
|
||
"abort" => std::process::abort(),
|
||
_ => Err(EngineError::invalid("mode", "must be panic, recovered or abort")),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// `diagnostics.crash_reports.delete` deletes one report by id, then the rest; an unknown or
|
||
/// hostile id and a missing choice are errors that delete nothing.
|
||
#[test]
|
||
fn crash_reports_are_deleted_one_or_all() {
|
||
let dir = std::env::temp_dir().join(format!("fieldcraft-crash-delete-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
let ids = ["20261014-101500-0000000000000001", "20261014-101600-0000000000000002", "20261014-101700-0000000000000003"];
|
||
for id in ids {
|
||
std::fs::create_dir_all(dir.join(id)).unwrap();
|
||
std::fs::write(dir.join(id).join("report.json"), r#"{"kind": "panic", "message": "x"}"#).unwrap();
|
||
}
|
||
let mut s = Session::default();
|
||
let d = dir.to_string_lossy().to_string();
|
||
let listed = |s: &mut Session| s.execute("diagnostics.crash_reports", &json!({"dir": d})).unwrap()["reports"].as_array().map_or(0, Vec::len);
|
||
assert_eq!(listed(&mut s), 3);
|
||
for bad in [
|
||
json!({"dir": d}),
|
||
json!({"dir": d, "id": "../x"}),
|
||
json!({"dir": d, "id": "20261014-101500-00000000000000ff"}),
|
||
json!({"dir": d, "id": ids[0], "all": true}),
|
||
] {
|
||
assert!(s.execute("diagnostics.crash_reports.delete", &bad).is_err(), "{bad}");
|
||
}
|
||
assert_eq!(listed(&mut s), 3);
|
||
assert_eq!(s.execute("diagnostics.crash_reports.delete", &json!({"dir": d, "id": ids[1]})).unwrap()["deleted"], 1);
|
||
assert_eq!(listed(&mut s), 2);
|
||
assert_eq!(s.execute("diagnostics.crash_reports.delete", &json!({"dir": d, "all": true})).unwrap()["deleted"], 2);
|
||
assert_eq!(listed(&mut s), 0);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
}
|