diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index c339624..540a2f0 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -36,6 +36,7 @@ is committed and listed here with its exports.
| assets/icons/deck.write.abaqus.svg, assets/icons/deck.write.abaqus.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/deck.write.abaqus.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/deck.write.lsdyna.svg, assets/icons/deck.write.lsdyna.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/deck.write.lsdyna.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/deck.write.nastran.svg, assets/icons/deck.write.nastran.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/deck.write.nastran.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
+| assets/icons/diagnostics.bug_report.svg, assets/icons/diagnostics.bug_report.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/diagnostics.bug_report.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/edit.redo.svg, assets/icons/edit.redo.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/edit.redo.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/edit.undo.svg, assets/icons/edit.undo.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/edit.undo.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/file.open.svg, assets/icons/file.open.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/file.open.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
@@ -186,6 +187,7 @@ is committed and listed here with its exports.
| crates/ui-egui/src/fields.rs (form rows: a label column of fixed width, then the field) | SolidCraft `crates/ui-egui/src/dialogs.rs` | MIT OR Apache-2.0 |
| crates/io/src/fs.rs (atomic writes) | SolidCraft `crates/io/src/safe_write.rs` | MIT OR Apache-2.0 |
| crates/io/src/recovery.rs (recovery folder, entries, locks) | SolidCraft `crates/engine/src/recovery.rs` | MIT OR Apache-2.0 |
+| crates/engine/src/crash.rs, crates/engine/src/crash_tests.rs (crash reports: panic hook, locked command journal, unclean-exit detection, report folders and limits, replay), apps/fieldcraft-cli/src/crashes.rs, docs/crash-reports.md (structure) | SolidCraft `crates/engine/src/crash.rs`, `crash_tests.rs`, `apps/solidcraft-cli/src/crashes.rs`, `docs/crash-reports.md` | MIT OR Apache-2.0 |
| apps/fieldcraft/src/control_server.rs | SolidCraft `apps/solidcraft/src/control_server.rs` | MIT OR Apache-2.0 |
| crates/mcp/src/ (JSON-RPC framing, MCP lifecycle, tool schema checks, the control-channel backend, base64) | SolidCraft `crates/mcp/src/` | MIT OR Apache-2.0 |
| crates/io/src/roots.rs (the rules of read and write roots) | VectorCraft's automation roots (docs/mcp.md there) | MIT OR Apache-2.0 |
diff --git a/assets/icons/diagnostics.bug_report.svg b/assets/icons/diagnostics.bug_report.svg
new file mode 100644
index 0000000..dbff238
--- /dev/null
+++ b/assets/icons/diagnostics.bug_report.svg
@@ -0,0 +1,15 @@
+
+
diff --git a/assets/icons/diagnostics.bug_report.vectorcraft b/assets/icons/diagnostics.bug_report.vectorcraft
new file mode 100644
index 0000000..5a04bc0
Binary files /dev/null and b/assets/icons/diagnostics.bug_report.vectorcraft differ
diff --git a/assets/icons/src/diagnostics.bug_report.json b/assets/icons/src/diagnostics.bug_report.json
new file mode 100644
index 0000000..0a7324e
--- /dev/null
+++ b/assets/icons/src/diagnostics.bug_report.json
@@ -0,0 +1 @@
+{"shapes": [{"rect": [8, 8.5, 8, 11], "radius": 4, "stroke": "line", "width": 1.6}, {"circle": [12, 5.8, 2.1], "stroke": "line", "width": 1.6}, {"line": [12, 10.5, 12, 18], "stroke": "accent", "width": 1.5}, {"line": [8, 11.5, 4.8, 9.8], "stroke": "line"}, {"line": [8, 14.5, 4.4, 14.5], "stroke": "line"}, {"line": [8.3, 17.4, 5, 19.4], "stroke": "line"}, {"line": [16, 11.5, 19.2, 9.8], "stroke": "line"}, {"line": [16, 14.5, 19.6, 14.5], "stroke": "line"}, {"line": [15.7, 17.4, 19, 19.4], "stroke": "line"}]}
diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs
index f385169..100593c 100644
--- a/crates/engine/src/autosave.rs
+++ b/crates/engine/src/autosave.rs
@@ -47,6 +47,7 @@ impl Autosaver {
let bytes = s.file_bytes(None)?;
self.slot.write(&bytes, s.path.as_deref(), s.model.nodes().len(), s.model.elements().len())?;
self.saved = Some(s.revision());
+ crate::crash::autosaved(s, &self.slot.file());
Ok(true)
}
diff --git a/crates/engine/src/bug_report.rs b/crates/engine/src/bug_report.rs
new file mode 100644
index 0000000..94c6048
--- /dev/null
+++ b/crates/engine/src/bug_report.rs
@@ -0,0 +1,381 @@
+//! Bug report drafts (Help › Report a Bug, and Report it after a crash): a GitHub new-issue link
+//! that fills in the bug form (`.github/ISSUE_TEMPLATE/bug.yml`), and the files to attach.
+//!
+//! Nothing here sends anything. The user reviews the draft, then their browser opens the issue
+//! form, which they submit or not.
+//!
+//! What a draft holds by default: the version, the OS, what went wrong (the user's words, the
+//! last error or the crash's message) and, for a crash, where it happened. The user's home
+//! folder is shown as `~`. Only with **Share my session** does it also carry the commands that
+//! built the model (the replayable journal, which can name the user's files), and only with
+//! **Include the model** as well the model file. A session too long for the link (GitHub
+//! refuses long new-issue links) goes out as a file to attach and on the clipboard instead; the
+//! model file is always one to attach.
+
+use serde_json::{Value, json};
+
+use crate::crash::without_home;
+
+/// The issue form.
+pub const ISSUE_URL: &str = "https://github.com/jyviko/fieldcraft/issues/new";
+/// The longest link a draft opens: GitHub refuses new-issue links much past 8 KB.
+pub const MAX_URL: usize = 7000;
+/// The session file a draft attaches.
+pub const SESSION_FILE: &str = "fieldcraft-session.json";
+/// The model file a draft attaches.
+pub const MODEL_FILE: &str = "model.fieldcraft";
+/// Backtrace frames a crash's draft quotes.
+const FRAMES: usize = 12;
+/// A field's text is cut at this many bytes.
+const MAX_FIELD: usize = 3000;
+
+/// What to put in a draft.
+#[derive(Clone, Debug, Default)]
+pub struct Input {
+ pub title: String,
+ /// What happened: the user's words, or the last error.
+ pub observed: String,
+ /// A crash report (`crash::read`), when reporting one.
+ pub crash: Option,
+ /// Attach the replayable commands.
+ pub share_session: bool,
+ /// The commands (each `{command, params}`) that rebuild the model from `model` (or from an
+ /// empty model when there is none).
+ pub commands: Vec,
+ /// Attach the model file too (only with `share_session`).
+ pub include_model: bool,
+ pub model: Option>,
+}
+
+/// A file the user attaches to the issue.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Attachment {
+ pub name: String,
+ pub bytes: Vec,
+ /// What it is, for the review.
+ pub what: String,
+}
+
+/// A draft ready for review.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Draft {
+ pub title: String,
+ /// The bug form's fields by id (`observed`, `reproduction`, `evidence`, `version`), as the
+ /// link fills them.
+ pub fields: Vec<(&'static str, String)>,
+ /// The link that opens the filled form.
+ pub url: String,
+ /// Files to attach (the session when too long for the link, the model).
+ pub attachments: Vec,
+ /// Text to put on the clipboard (the session, when it is an attachment).
+ pub clipboard: Option,
+}
+
+impl Draft {
+ /// The draft as JSON (attachments by name, size and what they are).
+ pub fn to_json(&self) -> Value {
+ json!({
+ "title": self.title,
+ "fields": self.fields.iter().map(|(k, v)| (k.to_string(), json!(v))).collect::>(),
+ "url": self.url,
+ "attachments": self.attachments.iter().map(|a| json!({"name": a.name, "bytes": a.bytes.len(), "what": a.what})).collect::>(),
+ "clipboard": self.clipboard.as_ref().map(String::len),
+ })
+ }
+}
+
+/// `v0.5.8 (abc1234), macos aarch64`.
+pub fn version_line(version: &str, commit: &str) -> String {
+ let commit = if commit.is_empty() { String::new() } else { format!(" ({commit})") };
+ format!("v{version}{commit}, {} {}", std::env::consts::OS, std::env::consts::ARCH)
+}
+
+/// Percent-encode `s` for a query value (RFC 3986 unreserved characters stay).
+pub fn encode(s: &str) -> String {
+ let mut out = String::with_capacity(s.len());
+ for b in s.bytes() {
+ if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
+ out.push(char::from(b));
+ } else {
+ out.push_str(&format!("%{b:02X}"));
+ }
+ }
+ out
+}
+
+fn cut(s: &str, n: usize) -> String {
+ if s.len() <= n {
+ return s.to_string();
+ }
+ let mut end = n;
+ while !s.is_char_boundary(end) {
+ end -= 1;
+ }
+ format!("{}\n[cut: {} bytes in all]", s.get(..end).unwrap_or_default(), s.len())
+}
+
+fn link(title: &str, fields: &[(&'static str, String)]) -> String {
+ let mut url = format!("{ISSUE_URL}?template=bug.yml&title={}", encode(title));
+ for (k, v) in fields {
+ if !v.is_empty() {
+ url.push_str(&format!("&{k}={}", encode(v)));
+ }
+ }
+ url
+}
+
+/// What a crash report says, for the evidence field: its kind, message, place, command (its id;
+/// its parameters only with the session) and the top of its backtrace.
+fn crash_evidence(report: &Value, share: bool) -> String {
+ let s = |k: &str| report.get(k).and_then(Value::as_str).unwrap_or("?");
+ let mut out =
+ format!("Crash report `{}` ({}), seen {} time(s)\n\n", s("id"), s("kind"), report.get("count").and_then(Value::as_u64).unwrap_or(1));
+ out.push_str(&format!("Message: {}\nAt: {}\n", without_home(s("message")), without_home(s("location"))));
+ if let Some(c) = report.get("command").filter(|c| !c.is_null()) {
+ let id = c.get("command").and_then(Value::as_str).unwrap_or("?");
+ match c.get("params").filter(|_| share) {
+ Some(p) => out.push_str(&format!("Command: `{id}` {}\n", without_home(&p.to_string()))),
+ None => out.push_str(&format!("Command: `{id}`\n")),
+ }
+ }
+ if let Some(a) = report.get("last_activity").and_then(Value::as_str) {
+ out.push_str(&format!("Last activity: {a}\n"));
+ }
+ let frames: Vec<&str> = report.get("backtrace").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).take(FRAMES).collect();
+ if !frames.is_empty() {
+ out.push_str("\nBacktrace (top):\n```\n");
+ for f in frames {
+ out.push_str(&without_home(f));
+ out.push('\n');
+ }
+ out.push_str("```\n");
+ }
+ out
+}
+
+/// The draft for `input`, by the [`version_line`] `version`.
+pub fn draft(input: &Input, version: &str) -> Draft {
+ let crash = input.crash.as_ref();
+ let mut title = input.title.trim().to_string();
+ if title.is_empty() {
+ title = match crash.and_then(|c| c.get("kind")).and_then(Value::as_str) {
+ Some("unclean_exit") => "FieldCraft closed unexpectedly".to_string(),
+ Some(_) => "Internal error".to_string(),
+ None => match input.observed.trim().lines().next().filter(|l| !l.is_empty()) {
+ Some(line) => format!("Bug: {}", line.chars().take(100).collect::()),
+ None => "Bug report".to_string(),
+ },
+ };
+ if let Some(c) = crash.and_then(|c| c.get("command")).and_then(|c| c.get("command")).and_then(Value::as_str) {
+ title.push_str(&format!(" in {c}"));
+ }
+ }
+ let title = cut(&without_home(&title), 200);
+ let mut observed = without_home(input.observed.trim());
+ if observed.is_empty()
+ && let Some(m) = crash.and_then(|c| c.get("message")).and_then(Value::as_str)
+ {
+ observed = without_home(m);
+ }
+ let observed = cut(&observed, MAX_FIELD);
+ let share = input.share_session;
+ let mut evidence = crash.map(|c| crash_evidence(c, share)).unwrap_or_default();
+ let mut attachments = Vec::new();
+ let mut clipboard = None;
+ let mut reproduction = String::new();
+ // The session: the replayable script, from the attached model when there is one.
+ let model = input.model.as_ref().filter(|_| share && input.include_model);
+ let script = share.then(|| {
+ let mut steps = Vec::new();
+ if model.is_some() {
+ steps.push(json!({"command": "file.open", "params": {"path": MODEL_FILE}}));
+ }
+ steps.extend(input.commands.iter().cloned());
+ Value::Array(steps)
+ });
+ if let Some(m) = model {
+ attachments.push(Attachment { name: MODEL_FILE.into(), bytes: m.clone(), what: format!("the model ({} KB)", m.len().div_ceil(1024)) });
+ }
+ if let Some(script) = &script {
+ let n = script.as_array().map_or(0, Vec::len);
+ reproduction = format!("Replay the attached session ({n} command(s)): `fieldcraft-cli run {SESSION_FILE}`");
+ if model.is_some() {
+ reproduction.push_str(&format!(" in a folder holding `{MODEL_FILE}` too"));
+ } else if crash.is_some_and(|c| c.get("files").and_then(|f| f.get("model")).is_some()) {
+ reproduction.push_str(" (the crash replays from the model, which is not attached)");
+ }
+ reproduction.push('.');
+ }
+ let inline = |evidence: &str, script: &Value| {
+ let text = serde_json::to_string_pretty(script).unwrap_or_default();
+ format!("{evidence}\nSession ({SESSION_FILE}):\n```json\n{text}\n```\n")
+ };
+ let fields = |evidence: String| {
+ vec![("observed", observed.clone()), ("reproduction", reproduction.clone()), ("evidence", evidence), ("version", version.to_string())]
+ };
+ // The session in the link when it fits, else a file to attach (also on the clipboard).
+ let mut url = String::new();
+ if let Some(script) = &script {
+ let candidate = fields(inline(&evidence, script));
+ let u = link(&title, &candidate);
+ if u.len() <= MAX_URL {
+ evidence = inline(&evidence, script);
+ url = u;
+ } else {
+ let text = serde_json::to_string_pretty(script).unwrap_or_default();
+ evidence.push_str(&format!(
+ "\nThe session is too long for this form: FieldCraft saved it as `{SESSION_FILE}` and copied it to the clipboard. Attach the file here (drag it in) or paste it.\n"
+ ));
+ attachments.insert(0, Attachment { name: SESSION_FILE.into(), bytes: text.clone().into_bytes(), what: "the session's commands".into() });
+ clipboard = Some(text);
+ }
+ }
+ if model.is_some() {
+ evidence.push_str(&format!("\nThe model is attached as `{MODEL_FILE}` (drag it in from the folder FieldCraft saved it to).\n"));
+ }
+ if url.is_empty() || model.is_some() {
+ let mut f = fields(evidence.clone());
+ url = link(&title, &f);
+ // Still too long (a very long message): cut the longest field until it fits.
+ while url.len() > MAX_URL {
+ let Some((i, len)) = f.iter().enumerate().map(|(i, (_, v))| (i, v.len())).max_by_key(|(_, l)| *l) else { break };
+ if len < 200 {
+ break;
+ }
+ if let Some((_, v)) = f.get_mut(i) {
+ *v = cut(v, len.saturating_sub(url.len() - MAX_URL + 64).min(len / 2 + 1));
+ }
+ url = link(&title, &f);
+ }
+ evidence = f.get(2).map(|(_, v)| v.clone()).unwrap_or_default();
+ }
+ let fields = fields(evidence);
+ // The fields as the link carries them (a cut field included).
+ let fields = if url == link(&title, &fields) { fields } else { decode_fields(&url) };
+ Draft { title, fields, url, attachments, clipboard }
+}
+
+/// The fields a link carries (for the review, when a field had to be cut).
+fn decode_fields(url: &str) -> Vec<(&'static str, String)> {
+ let query = url.split_once('?').map_or("", |(_, q)| q);
+ ["observed", "reproduction", "evidence", "version"]
+ .into_iter()
+ .map(|k| {
+ let v = query.split('&').find_map(|kv| kv.strip_prefix(k).and_then(|r| r.strip_prefix('='))).unwrap_or_default();
+ (k, decode(v))
+ })
+ .collect()
+}
+
+fn decode(s: &str) -> String {
+ let b = s.as_bytes();
+ let mut out = Vec::with_capacity(b.len());
+ let mut i = 0;
+ while let Some(&c) = b.get(i) {
+ let hex = b.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok());
+ match (c, hex) {
+ (b'%', Some(v)) => {
+ out.push(v);
+ i += 3;
+ }
+ _ => {
+ out.push(c);
+ i += 1;
+ }
+ }
+ }
+ String::from_utf8_lossy(&out).into_owned()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn home() -> String {
+ std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_else(|_| "/home/someone".into())
+ }
+
+ fn crash() -> Value {
+ json!({
+ "id": "20261014-101500-00000000deadbeef", "kind": "command_panic", "count": 2,
+ "message": format!("index out of bounds in {}/model.fieldcraft", home()),
+ "location": "crates/mesh/src/tet.rs:42",
+ "command": {"command": "mesh.tet.generate", "params": {"body": "Plate", "path": format!("{}/secret/plate.step", home())}},
+ "backtrace": ["fieldcraft_mesh::tet::insert (crates/mesh/src/tet.rs:42)"],
+ "files": {"model": "model.fieldcraft", "replay": "replay.json"},
+ })
+ }
+
+ #[test]
+ fn without_sharing_a_draft_names_no_paths_commands_or_model() {
+ let input = Input {
+ observed: format!("cannot open {}/parts/bracket.solidcraft", home()),
+ crash: Some(crash()),
+ commands: vec![json!({"command": "geometry.open", "params": {"path": format!("{}/parts/bracket.solidcraft", home())}})],
+ model: Some(b"model bytes".to_vec()),
+ include_model: true,
+ ..Default::default()
+ };
+ let d = draft(&input, "v1.2.3, testos x");
+ assert!(d.url.starts_with("https://github.com/jyviko/fieldcraft/issues/new?template=bug.yml&title="), "{}", d.url);
+ assert!(d.attachments.is_empty() && d.clipboard.is_none(), "nothing attached without Share my session");
+ let all = format!("{} {:?} {}", d.title, d.fields, decode(&d.url));
+ assert!(!all.contains(&home()), "no home folder: {all}");
+ assert!(!all.contains("secret") && !all.contains("geometry.open"), "no parameters or commands: {all}");
+ assert!(all.contains("~/parts/bracket.solidcraft"), "the error, its home shown as ~: {all}");
+ assert!(all.contains("mesh.tet.generate") && all.contains("tet.rs:42") && all.contains("v1.2.3, testos x"), "{all}");
+ assert_eq!(d.title, "Internal error in mesh.tet.generate");
+ assert_eq!(decode_fields(&d.url), d.fields, "the review shows what the link carries");
+ }
+
+ #[test]
+ fn a_short_session_goes_in_the_link() {
+ let input = Input {
+ title: "Solve fails".into(),
+ share_session: true,
+ commands: vec![json!({"command": "file.sample", "params": {"name": "cantilever"}}), json!({"command": "solve.run", "params": {}})],
+ ..Default::default()
+ };
+ let d = draft(&input, "v1");
+ assert!(d.attachments.is_empty() && d.clipboard.is_none());
+ let evidence = &d.fields.iter().find(|(k, _)| *k == "evidence").unwrap().1;
+ assert!(evidence.contains("\"command\": \"solve.run\""), "{evidence}");
+ assert!(d.url.len() <= MAX_URL);
+ assert!(decode(&d.url).contains("file.sample"));
+ }
+
+ #[test]
+ fn a_long_session_and_the_model_are_attachments() {
+ let commands: Vec = (0..400).map(|i| json!({"command": "mesh.node.add", "params": {"label": i + 1, "xyz": [i, 0, 0]}})).collect();
+ let input =
+ Input { share_session: true, include_model: true, commands, model: Some(vec![7; 3000]), crash: Some(crash()), ..Default::default() };
+ let d = draft(&input, "v1");
+ assert!(d.url.len() <= MAX_URL, "{}", d.url.len());
+ let names: Vec<&str> = d.attachments.iter().map(|a| a.name.as_str()).collect();
+ assert_eq!(names, [SESSION_FILE, MODEL_FILE]);
+ let session: Value = serde_json::from_slice(&d.attachments[0].bytes).unwrap();
+ assert_eq!(session[0], json!({"command": "file.open", "params": {"path": MODEL_FILE}}), "the replay opens the attached model");
+ assert_eq!(session.as_array().unwrap().len(), 401);
+ assert_eq!(d.clipboard.as_deref().map(str::as_bytes), Some(d.attachments[0].bytes.as_slice()));
+ let text = decode(&d.url);
+ assert!(text.contains("too long for this form") && text.contains(MODEL_FILE) && !text.contains("\"label\": 399"), "{text}");
+ // With the session shared, the crash's command parameters are shown (home as ~).
+ assert!(text.contains("~/secret/plate.step"), "{text}");
+ }
+
+ #[test]
+ fn a_huge_message_is_cut_to_fit_the_link() {
+ let input = Input { observed: "é".repeat(20_000), ..Default::default() };
+ let d = draft(&input, "v1");
+ assert!(d.url.len() <= MAX_URL, "{}", d.url.len());
+ assert!(d.fields[0].1.contains("[cut:"));
+ assert_eq!(decode_fields(&d.url), d.fields);
+ }
+
+ #[test]
+ fn encoding_round_trips() {
+ let s = "a b&c=d/é\n```json";
+ assert_eq!(decode(&encode(s)), s);
+ assert_eq!(encode("a b&"), "a%20b%26");
+ }
+}
diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs
index 3b22a32..0b961ad 100644
--- a/crates/engine/src/catalogue.rs
+++ b/crates/engine/src/catalogue.rs
@@ -591,6 +591,14 @@ pub static CATALOGUE: &[Capability] = &[
),
ready("edit.undo", "Undo", Tools, "History", "Undo the last command that changed the model"),
ready("edit.redo", "Redo", Tools, "History", "Redo the command undone last"),
+ ready_more(
+ "diagnostics.bug_report",
+ "Report a Bug",
+ Tools,
+ "Help",
+ "Draft a GitHub bug report with the version, the OS and the last error; you review it, and nothing is sent until you submit it",
+ &["diagnostics.crash_reports", "diagnostics.crash_report", "diagnostics.crash_test"],
+ ),
];
/// The catalogue entry with this id.
diff --git a/crates/engine/src/cmd/diagnostics.rs b/crates/engine/src/cmd/diagnostics.rs
new file mode 100644
index 0000000..ac977fe
--- /dev/null
+++ b/crates/engine/src/cmd/diagnostics.rs
@@ -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"?: "", "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 {
+ 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 {
+ let dir = crash_dir(s, p)?;
+ let limit = opt::(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 {
+ let dir = crash_dir(s, p)?;
+ let id = req::(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 {
+ let share = opt::(p, "share_session")?.unwrap_or(false);
+ let include_model = opt::(p, "include_model")?.unwrap_or(false);
+ let mut input = bug_report::Input {
+ title: opt::(p, "title")?.unwrap_or_default(),
+ observed: opt::(p, "observed")?.unwrap_or_default(),
+ share_session: share,
+ include_model,
+ ..Default::default()
+ };
+ match opt::(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 {
+ 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 {
+ let mode = req::(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")),
+ }
+}
diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs
index 6cb5dcd..9359c38 100644
--- a/crates/engine/src/cmd/forms.rs
+++ b/crates/engine/src/cmd/forms.rs
@@ -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) {
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
index 3b32799..ead6954 100644
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -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,
diff --git a/crates/engine/src/crash.rs b/crates/engine/src/crash.rs
new file mode 100644
index 0000000..287b912
--- /dev/null
+++ b/crates/engine/src/crash.rs
@@ -0,0 +1,1158 @@
+//! Crash reports: enough to reproduce a crash and fix it, without slowing the app down or
+//! filling the disk (docs/crash-reports.md).
+//!
+//! - **Panics** are caught by a hook. One inside a command becomes a report with the model as it
+//! was before the command and the command itself, so `fieldcraft-cli crashes replay `
+//! reproduces it. A panic something inside the command caught and turned into an error is
+//! reported the same way, marked recovered.
+//! - **Crashes no hook sees** (an abort, a stack overflow, a segfault, a crash inside a graphics
+//! driver, the process being killed) are found at the next start: every running app keeps a
+//! small, locked command journal; a journal nobody holds any more belongs to an app that died.
+//! Its report carries the model the journal starts from (the last autosave, the model's file,
+//! or a copy taken when the model was opened) and the commands run since, ending with the one
+//! in flight, plus the app's last control request and the dead run's lines of the log file
+//! ([`crate::logfile`]).
+//! - **Cost**: nothing until [`install`] is called (tests, the CLI and the web build never do);
+//! after that, two small unbuffered appends per top-level command of the app's own model
+//! ([`Session::crash_journal`]), and work only when something panics.
+//! - **Bounded**: the same crash (its signature: place + message, digits ignored) is counted, not
+//! written again; at most [`MAX_NEW_PER_SESSION`] new reports per run, [`MAX_REPORTS`]
+//! reports, [`MAX_TOTAL_BYTES`] on disk, nothing older than [`MAX_AGE_DAYS`] days.
+//!
+//! Reports are folders `crashes/