forked from 1b-agent/fieldcraft-upstream
CLI: fieldcraft-cli crashes [list|show|replay|dir|clear] (#107)
Lists, shows, replays and deletes the crash reports the desktop app wrote. A replay opens the report's model, runs its commands with writes confined to a scratch folder, and says whether the same internal error happens again. Adapted from SolidCraft's solidcraft-cli crashes. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
080ff048c6
commit
cb7e3a2711
3 files changed
+164
No files matched your search
@@ -0,0 +1,80 @@
|
||||
//! `fieldcraft-cli crashes`: the crash reports the desktop app wrote on this computer
|
||||
//! (docs/crash-reports.md): list them, show one, replay one, delete them.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use fieldcraft_engine::crash;
|
||||
use serde_json::json;
|
||||
|
||||
pub const USAGE: &str = "usage: fieldcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]";
|
||||
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
let dir = crash::default_dir().ok_or("no crash folder on this system (set FIELDCRAFT_CRASH_DIR)")?;
|
||||
let id = || args.get(1).map(String::as_str).ok_or_else(|| USAGE.to_string());
|
||||
match args.first().map(String::as_str) {
|
||||
None | Some("list") => {
|
||||
let reports = crash::list(&dir);
|
||||
if args.iter().any(|a| a == "--json") {
|
||||
return print_json(&json!({ "dir": dir.to_string_lossy(), "reports": reports }));
|
||||
}
|
||||
println!("crash reports in {}", dir.display());
|
||||
if reports.is_empty() {
|
||||
println!(" (none)");
|
||||
}
|
||||
for r in reports {
|
||||
let times = if r.count > 1 { format!(" ×{}", r.count) } else { String::new() };
|
||||
let cmd = r.command.map(|c| format!(" in {c}")).unwrap_or_default();
|
||||
let replay = if r.replayable { " [replayable]" } else { "" };
|
||||
println!(" {} {}{times}{cmd} v{}{replay}\n {}", r.id, r.kind, r.version, r.message);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Some("show") => print_json(&crash::read(&dir, id()?)?),
|
||||
Some("replay") => replay(&dir, id()?),
|
||||
Some("dir") => {
|
||||
println!("{}", dir.display());
|
||||
Ok(())
|
||||
}
|
||||
Some("clear") => {
|
||||
println!("deleted {} report(s)", crash::clear(&dir, args.get(1).map(String::as_str)));
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(USAGE.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_json(v: &serde_json::Value) -> Result<(), String> {
|
||||
println!("{}", serde_json::to_string_pretty(v).map_err(|e| e.to_string())?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a report's commands on its model and say whether the crash happens again. Files the
|
||||
/// commands write go to a scratch folder, never over the user's files.
|
||||
fn replay(dir: &Path, id: &str) -> Result<(), String> {
|
||||
let report = crash::read(dir, id)?;
|
||||
let scratch = std::env::temp_dir().join(format!("fieldcraft-replay-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&scratch).map_err(|e| format!("{}: {e}", scratch.display()))?;
|
||||
if report["kind"] == "unclean_exit" {
|
||||
println!("this crash ended the app without a report: if this process ends abruptly, it reproduced");
|
||||
}
|
||||
println!("replaying on {}", report["model_path"].as_str().unwrap_or("a new model"));
|
||||
let r = crash::replay(&report, &scratch);
|
||||
let _ = std::fs::remove_dir_all(&scratch);
|
||||
let r = r?;
|
||||
for (i, (cmd, outcome)) in r.steps.iter().enumerate() {
|
||||
match outcome {
|
||||
Ok(()) => println!(" {:>3}. {cmd}: ok", i + 1),
|
||||
Err(e) => println!(" {:>3}. {cmd}: {e}", i + 1),
|
||||
}
|
||||
}
|
||||
let said = |k: &str| report[k].as_str().unwrap_or_default().to_string();
|
||||
if r.reproduced {
|
||||
println!("reproduced: the same internal error ({}) at {}", said("message"), said("location"));
|
||||
} else if let Some((l, m)) = r.panics.first() {
|
||||
println!("an internal error happened, but not the reported one: {m} at {l}");
|
||||
println!("(the report says: {} at {})", said("message"), said("location"));
|
||||
} else {
|
||||
println!("did not reproduce: no internal error (fixed, or it depends on something the replay does not have)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -2,10 +2,13 @@
|
||||
//! fieldcraft-cli run <script.json>
|
||||
//! fieldcraft-cli features
|
||||
//! fieldcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
|
||||
//! fieldcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]
|
||||
//! fieldcraft-cli --version
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod crashes;
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::ExitCode;
|
||||
|
||||
@@ -28,6 +31,11 @@ usage:
|
||||
--automation-read-root DIR files may be read only inside DIR
|
||||
--automation-write-root DIR files may be written only inside DIR (with either root,
|
||||
the access a root left out is not granted)
|
||||
fieldcraft-cli crashes [list|show <id>|replay <id>|dir|clear [<id>]]
|
||||
the crash reports the desktop app wrote on this
|
||||
computer (docs/crash-reports.md): list them (--json),
|
||||
show one, replay one (does the crash happen again?),
|
||||
print their folder, delete them
|
||||
fieldcraft-cli --version print the version
|
||||
";
|
||||
|
||||
@@ -52,6 +60,7 @@ fn main() -> ExitCode {
|
||||
Ok(())
|
||||
}
|
||||
Some("mcp") => mcp(args.get(1..).unwrap_or(&[])),
|
||||
Some("crashes") => crashes::run(args.get(1..).unwrap_or(&[])),
|
||||
Some("run") => args.get(1).ok_or_else(|| format!("run needs a script\n\n{USAGE}")).and_then(|p| run(p)),
|
||||
Some("-V" | "--version") => {
|
||||
println!("fieldcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
//! `fieldcraft-cli crashes` on a crash folder of its own (`FIELDCRAFT_CRASH_DIR`).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use fieldcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const ID: &str = "20261014-101500-00000000deadbeef";
|
||||
|
||||
/// A replayable report: a model with two nodes and a replay that adds a third.
|
||||
fn folder() -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("fieldcraft-cli-crashes-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
let r = d.join(ID);
|
||||
std::fs::create_dir_all(&r).unwrap();
|
||||
let mut s = Session::default();
|
||||
for l in 1..=2 {
|
||||
s.execute("mesh.node.add", &json!({"label": l, "xyz": [l, 0, 0]})).unwrap();
|
||||
}
|
||||
std::fs::write(r.join("model.fieldcraft"), s.file_bytes(None).unwrap()).unwrap();
|
||||
let replay = json!({"commands": [
|
||||
{"command": "mesh.node.add", "params": {"label": 3, "xyz": [3, 0, 0]}},
|
||||
{"command": "file.save", "params": {"path": d.join("overwritten.fieldcraft")}},
|
||||
]});
|
||||
std::fs::write(r.join("replay.json"), replay.to_string()).unwrap();
|
||||
let report = json!({
|
||||
"format": "fieldcraft-crash/1", "id": ID, "kind": "unclean_exit", "signature": "00000000deadbeef", "count": 2,
|
||||
"first_seen": "2026-10-14T10:15:00Z", "message": "FieldCraft ended without a clean exit while running `mesh.node.add`",
|
||||
"location": "unclean exit in mesh.node.add", "command": {"command": "mesh.node.add", "params": {}},
|
||||
"app": {"version": "9.9.9"}, "backtrace": [],
|
||||
});
|
||||
std::fs::write(r.join("report.json"), report.to_string()).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn cli(dir: &Path, args: &[&str]) -> (String, bool) {
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_fieldcraft-cli")).args(args).env("FIELDCRAFT_CRASH_DIR", dir).output().unwrap();
|
||||
(format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)), out.status.success())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crashes_list_show_replay_and_clear() {
|
||||
let d = folder();
|
||||
let (out, ok) = cli(&d, &["crashes"]);
|
||||
assert!(ok, "{out}");
|
||||
assert!(out.contains(ID) && out.contains("unclean_exit ×2 in mesh.node.add") && out.contains("[replayable]"), "{out}");
|
||||
let (out, ok) = cli(&d, &["crashes", "list", "--json"]);
|
||||
assert!(ok);
|
||||
let v: Value = serde_json::from_str(&out).unwrap();
|
||||
assert_eq!(v["reports"][0]["id"], ID);
|
||||
let (out, ok) = cli(&d, &["crashes", "show", ID]);
|
||||
assert!(ok);
|
||||
let v: Value = serde_json::from_str(&out).unwrap();
|
||||
assert!(v["model_path"].as_str().unwrap().ends_with("model.fieldcraft"));
|
||||
let (out, ok) = cli(&d, &["crashes", "replay", ID]);
|
||||
assert!(ok, "{out}");
|
||||
assert!(out.contains("1. mesh.node.add: ok"), "{out}");
|
||||
// A command that writes a file is refused outside the scratch folder: the replay never
|
||||
// writes over the user's files.
|
||||
assert!(out.contains("2. file.save: ") && out.contains("outside the write root"), "{out}");
|
||||
assert!(!d.join("overwritten.fieldcraft").exists());
|
||||
assert!(out.contains("did not reproduce"), "{out}");
|
||||
let (out, ok) = cli(&d, &["crashes", "show", "../etc"]);
|
||||
assert!(!ok && out.contains("no crash report"), "{out}");
|
||||
let (out, ok) = cli(&d, &["crashes", "dir"]);
|
||||
assert!(ok && out.trim() == d.to_string_lossy(), "{out}");
|
||||
let (out, ok) = cli(&d, &["crashes", "clear"]);
|
||||
assert!(ok && out.contains("deleted 1 report(s)"), "{out}");
|
||||
let (out, _) = cli(&d, &["crashes"]);
|
||||
assert!(out.contains("(none)"), "{out}");
|
||||
let (out, ok) = cli(&d, &["crashes", "nope"]);
|
||||
assert!(!ok && out.contains("usage"), "{out}");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
Reference in new issue
Block a user