forked from 1b-agent/fieldcraft-upstream
Engine: crash reports, a log file and bug report drafts (#107)
crash: a panic hook and a locked per-run command journal (adapted from SolidCraft's crash reports). A panic in a command becomes a report with the model from before it and the command; one outside commands, or an unclean exit found at the next start (abort, segfault, a crash in a graphics driver, a kill: what #248 needs), a report with the model the journal starts from (autosave, file or a copy) and the commands since, the last control request and the dead run's log lines. Reports are bounded, deduplicated by signature and replayable (crash::replay, with writes confined to a scratch folder). The crash folder follows the automation temp area in --control and CI runs. logfile: a small size-rotated file logger behind the log facade. bug_report: GitHub bug-form drafts. Version, OS and what happened by default, home folder shown as ~; the command journal only with Share my session, the model only with Include the model as well; a session too long for the link becomes a file to attach and the clipboard text. Commands: diagnostics.crash_reports, diagnostics.crash_report, diagnostics.bug_report (Tools › Help, with its icon) and diagnostics.crash_test (FIELDCRAFT_CRASH_TEST=1 only). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
3d51bde9dd
commit
080ff048c6
15 files changed
+2200
-2
No files matched your search
@@ -0,0 +1,133 @@
|
||||
//! 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 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> {
|
||||
if p.get("dir").is_some() {
|
||||
return Ok(PathBuf::from(file_param(s, p, "dir", Access::Read)?));
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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")),
|
||||
}
|
||||
}
|
||||
@@ -1433,6 +1433,23 @@ pub const FILE_SAVE: &[Field] = &[path("path", "File", true).help("Where to save
|
||||
pub const FILE_SAVE_AS: &[Field] = &[path("path", "File", true).help("The file to save to.").req()];
|
||||
pub const RECOVERY_LIST: &[Field] =
|
||||
&[path("dir", "Folder", false).help("The recovery folder; the default one when empty.").hint("the recovery folder")];
|
||||
pub const CRASH_REPORTS: &[Field] = &[
|
||||
int("limit", "Limit", 1, 1000).help("At most this many reports, newest first.").hint("20"),
|
||||
path("dir", "Folder", false).help("The crash folder; the default one when empty.").hint("the crash folder"),
|
||||
];
|
||||
pub const CRASH_REPORT: &[Field] = &[
|
||||
text("id", "Report").help("The crash report to show.").req(),
|
||||
path("dir", "Folder", false).help("The crash folder; the default one when empty.").hint("the crash folder"),
|
||||
];
|
||||
pub const BUG_REPORT: &[Field] = &[
|
||||
text("title", "Title").help("The issue's title; one is made up when empty."),
|
||||
text("observed", "What happened").help("What went wrong; the last error when empty."),
|
||||
text("crash", "Crash report").help("A crash report to report (its id)."),
|
||||
flag("share_session", "Share my session").help("Attach the commands that built the model (they can name your files).").def("false"),
|
||||
flag("include_model", "Include the model").help("Attach the model file too (with Share my session).").def("false"),
|
||||
];
|
||||
pub const CRASH_TEST: &[Field] =
|
||||
&[choice("mode", "Mode", &[("panic", "Panic"), ("recovered", "Recovered panic"), ("abort", "Abort")]).help("How to crash.").req()];
|
||||
pub const RECOVER: &[Field] = &[
|
||||
text("id", "Autosave").help("The autosave to recover.").req(),
|
||||
path("dir", "Folder", false).help("The recovery folder; the default one when empty.").hint("the recovery folder"),
|
||||
@@ -1697,7 +1714,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
const STATE: &[&str] = &["open a design first", "`boundary_conditions` or `loads`", "give one of", "is not an explicit", "has a result"];
|
||||
const STATE: &[&str] = &[
|
||||
"open a design first",
|
||||
"`boundary_conditions` or `loads`",
|
||||
"give one of",
|
||||
"is not an explicit",
|
||||
"has a result",
|
||||
"no crash report",
|
||||
"FIELDCRAFT_CRASH_TEST",
|
||||
];
|
||||
|
||||
/// Fill top-level required names (a fresh one) and references (the first existing item).
|
||||
fn fill_required(s: &Session, form: &[Field], p: &mut Value) {
|
||||
|
||||
@@ -6,6 +6,7 @@ mod contact;
|
||||
pub(crate) mod datum;
|
||||
pub(crate) mod deck;
|
||||
pub(crate) mod define;
|
||||
pub mod diagnostics;
|
||||
mod edit;
|
||||
pub(crate) mod file;
|
||||
pub(crate) mod forms;
|
||||
@@ -153,6 +154,10 @@ pub static ALL: &[CommandSpec] = &[
|
||||
results::FRAME,
|
||||
results::REACTIONS,
|
||||
results::CLEAR,
|
||||
diagnostics::CRASH_REPORTS,
|
||||
diagnostics::CRASH_REPORT,
|
||||
diagnostics::BUG_REPORT,
|
||||
diagnostics::CRASH_TEST,
|
||||
];
|
||||
|
||||
/// The problem with required parameter `key`: [`ProblemKind::Required`] when it is left out,
|
||||
|
||||
Reference in new issue
Block a user