diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json index a254a42..0725257 100644 --- a/DISASMER_PUBLIC_TREE.json +++ b/DISASMER_PUBLIC_TREE.json @@ -1,7 +1,7 @@ { "kind": "disasmer-filtered-public-tree", - "source_commit": "9b295127cfd7e4e87d4518a817563ca8e695ed0b", - "release_name": "dryrun-9b295127cfd7", + "source_commit": "3d37a45472228c553477f8462927dfa6a6622882", + "release_name": "dryrun-3d37a4547222", "filtered_out": [ "private/**", "experiments/**", diff --git a/crates/disasmer-cli/src/main.rs b/crates/disasmer-cli/src/main.rs index 641ae1e..5350d04 100644 --- a/crates/disasmer-cli/src/main.rs +++ b/crates/disasmer-cli/src/main.rs @@ -1807,6 +1807,8 @@ fn human_report(value: &Value) -> String { )); } } + push_machine_error_line(&mut lines, value.get("machine_error")); + push_machine_error_line(&mut lines, value.pointer("/run_start/machine_error")); if let Some(flow) = value.get("human_flow") { if let Some(device) = flow.get("Device") { lines.push("flow: device".to_owned()); @@ -2013,10 +2015,221 @@ fn push_nested_string_field(lines: &mut Vec, value: &Value, key: &str, l } } +fn push_machine_error_line(lines: &mut Vec, machine_error: Option<&Value>) { + let Some(machine_error) = machine_error else { + return; + }; + if machine_error.is_null() { + return; + } + let category = machine_error + .get("category") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let exit_code = machine_error + .get("stable_exit_code") + .and_then(Value::as_i64) + .unwrap_or(1); + lines.push(format!("error category: {category} (exit {exit_code})")); + if let Some(next_actions) = machine_error.get("next_actions").and_then(Value::as_array) { + let actions = next_actions + .iter() + .filter_map(Value::as_str) + .collect::>(); + if !actions.is_empty() { + lines.push(format!("error next: {}", actions.join("; "))); + } + } +} + fn compact_json(value: &Value) -> String { serde_json::to_string(value).unwrap_or_else(|_| "".to_owned()) } +fn cli_error_summary(message: &str) -> Value { + let category = classify_cli_error_message(message); + cli_error_summary_for_category(category, message) +} + +fn cli_error_summary_with_default(message: &str, default_category: &'static str) -> Value { + let category = classify_cli_error_message(message); + let category = if category == "unknown" { + default_category + } else { + category + }; + cli_error_summary_for_category(category, message) +} + +fn cli_error_summary_for_category(category: &'static str, message: &str) -> Value { + json!({ + "category": category, + "stable_exit_code": cli_error_exit_code(category), + "process_exit_code_applied": false, + "retryable_after_user_action": cli_error_retryable_after_user_action(category), + "message": message, + "safe_failure": true, + "next_actions": cli_error_next_actions(category), + }) +} + +fn classify_cli_error_message(message: &str) -> &'static str { + let message = message.to_ascii_lowercase(); + if message.contains("already has active virtual process") || message.contains("active process") + { + return "active_process"; + } + if message.contains("no capable node") + || message.contains("missing capability") + || message.contains("capability") + || message.contains("placement failed") + || message.contains("cmd.run") + || message.contains("node required") + { + return "capability"; + } + if message.contains("quota") + || message.contains("resource limit") + || message.contains("limit exceeded") + || message.contains("metering") + { + return "quota"; + } + if message.contains("policy") + || message.contains("disallowed") + || message.contains("not allowed") + || message.contains("suspended") + || message.contains("blocked by") + || message.contains("denied") + { + return "policy"; + } + if message.contains("unauthenticated") + || message.contains("not authenticated") + || message.contains("login required") + || message.contains("browser login required") + || message.contains("missing session") + || message.contains("no cli session") + || message.contains("401") + { + return "authentication"; + } + if message.contains("unauthorized") + || message.contains("not authorized") + || message.contains("forbidden") + || message.contains("permission") + || message.contains("tenant mismatch") + || message.contains("project mismatch") + { + return "authorization"; + } + if message.contains("failed to connect") + || message.contains("connection refused") + || message.contains("closed session") + || message.contains("timed out") + || message.contains("timeout") + || message.contains("name resolution") + || message.contains("network unreachable") + || message.contains("dns") + { + return "connectivity"; + } + if message.contains("missing environment") + || message.contains("environment mismatch") + || message.contains("incompatible environment") + || message.contains("containerfile") + || message.contains("dockerfile") + || message.contains("envs/") + { + return "environment"; + } + if message.contains("task exited") + || message.contains("exit status") + || message.contains("status code") + || message.contains("stderr") + || message.contains("panic") + || message.contains("program error") + || message.contains("command failed") + { + return "program"; + } + "unknown" +} + +fn cli_error_exit_code(category: &str) -> i64 { + match category { + "authentication" => 20, + "authorization" => 21, + "quota" => 22, + "policy" => 23, + "capability" => 24, + "connectivity" => 25, + "environment" => 26, + "program" => 27, + "active_process" => 28, + _ => 1, + } +} + +fn cli_error_retryable_after_user_action(category: &str) -> bool { + matches!( + category, + "authentication" + | "authorization" + | "quota" + | "policy" + | "capability" + | "connectivity" + | "environment" + | "program" + | "active_process" + ) +} + +fn cli_error_next_actions(category: &str) -> Vec<&'static str> { + match category { + "authentication" => vec!["disasmer login --browser", "disasmer auth status"], + "authorization" => vec![ + "disasmer auth status", + "check tenant/project selection", + "ask an admin to grant access", + ], + "quota" => vec![ + "disasmer quota status", + "reduce concurrent work or wait for usage to fall", + ], + "policy" => vec![ + "disasmer doctor", + "check coordinator policy for this action", + ], + "capability" => vec![ + "disasmer node list", + "attach a node with the required capabilities", + "check tenant/project on the attached node", + ], + "connectivity" => vec![ + "disasmer doctor", + "check the coordinator endpoint and network reachability", + ], + "environment" => vec![ + "disasmer inspect", + "check envs//Containerfile or envs//Dockerfile", + ], + "program" => vec!["disasmer logs", "fix the program or task command and rerun"], + "active_process" => vec![ + "disasmer process status", + "disasmer logs", + "disasmer process restart --yes", + "disasmer process cancel --yes", + "wait for the active process to finish", + ], + _ => vec![ + "disasmer doctor", + "rerun with --json for machine-readable details", + ], + } +} + fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { @@ -2568,6 +2781,7 @@ fn task_summaries(task_events: Option<&Value>) -> Value { "explanation_available": placement.is_some(), }, "failure_reason": task_failure_reason(event), + "machine_error": task_failure_machine_error(event), "stdout_bytes": event_u64(event, "stdout_bytes").unwrap_or(0), "stderr_bytes": event_u64(event, "stderr_bytes").unwrap_or(0), }) @@ -2594,6 +2808,20 @@ fn task_failure_reason(event: &Value) -> Value { } } +fn task_failure_machine_error(event: &Value) -> Value { + match event.get("terminal_state").and_then(Value::as_str) { + Some("failed") => { + let reason = task_failure_reason(event) + .as_str() + .unwrap_or("task failed") + .to_owned(); + cli_error_summary_with_default(&reason, "program") + } + Some("cancelled") => cli_error_summary_for_category("program", "task cancelled"), + _ => Value::Null, + } +} + fn process_state_from_tasks(task_events: Option<&Value>) -> &'static str { let events = task_event_values(task_events); if events.is_empty() { @@ -2933,7 +3161,11 @@ fn quota_next_blocked_action(current_usage: &Value) -> Value { "action": "node_work_requires_online_attached_node", "category": "capability", "quota_related": false, - "message": "no online attached node is visible for work that requires a user node" + "message": "no online attached node is visible for work that requires a user node", + "machine_error": cli_error_summary_for_category( + "capability", + "no online attached node is visible for work that requires a user node" + ) }); } Value::Null @@ -3568,10 +3800,26 @@ fn run_start_summary(response: &Value) -> Value { .and_then(Value::as_str) .unwrap_or("coordinator rejected run"); let active_conflict = message.contains("already has active virtual process"); + let machine_error = if active_conflict { + cli_error_summary_for_category("active_process", message) + } else { + cli_error_summary(message) + }; + let error_category = machine_error + .get("category") + .cloned() + .unwrap_or_else(|| json!("unknown")); + let stable_exit_code = machine_error + .get("stable_exit_code") + .cloned() + .unwrap_or_else(|| json!(1)); json!({ "status": if active_conflict { "blocked_active_process" } else { "coordinator_rejected" }, "accepted": false, "category": if active_conflict { "active_process_already_running" } else { "coordinator" }, + "error_category": error_category, + "stable_exit_code": stable_exit_code, + "machine_error": machine_error, "message": message, "restart": false, "single_active_process_boundary": true, @@ -4034,7 +4282,21 @@ impl JsonLineSession { fn request(&mut self, value: Value) -> Result { let response = self.request_allow_error(value)?; if response.get("type").and_then(Value::as_str) == Some("error") { - anyhow::bail!("coordinator error: {response}"); + let message = response + .get("message") + .and_then(Value::as_str) + .map(str::to_owned) + .unwrap_or_else(|| response.to_string()); + let machine_error = cli_error_summary(&message); + let category = machine_error + .get("category") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let exit_code = machine_error + .get("stable_exit_code") + .and_then(Value::as_i64) + .unwrap_or(1); + anyhow::bail!("coordinator error ({category}, exit {exit_code}): {response}"); } Ok(response) } @@ -4111,6 +4373,48 @@ mod tests { Cli::parse_from(args) } + #[test] + fn cli_error_classifier_distinguishes_mvp_failure_categories() { + for (message, category, exit_code) in [ + ( + "login required before running this command", + "authentication", + 20, + ), + ("unauthorized tenant action", "authorization", 21), + ("quota unavailable: resource limit exceeded", "quota", 22), + ("policy denied native command execution", "policy", 23), + ( + "scheduler placement failed: no capable node for placement: project mismatch", + "capability", + 24, + ), + ( + "failed to connect to coordinator: connection refused", + "connectivity", + 25, + ), + ( + "missing environment envs/linux/Containerfile", + "environment", + 26, + ), + ("task exited with status 101 after panic", "program", 27), + ( + "project already has active virtual process vp-current", + "active_process", + 28, + ), + ] { + let summary = cli_error_summary(message); + assert_eq!(summary["category"], category, "{message}"); + assert_eq!(summary["stable_exit_code"], exit_code, "{message}"); + assert_eq!(summary["safe_failure"], true); + assert_eq!(summary["process_exit_code_applied"], false); + assert!(summary["next_actions"].as_array().unwrap().len() >= 2); + } + } + #[test] fn top_level_version_is_available() { let command = Cli::command(); @@ -4417,6 +4721,16 @@ mod tests { blocked["run_start"]["category"], "active_process_already_running" ); + assert_eq!(blocked["run_start"]["error_category"], "active_process"); + assert_eq!(blocked["run_start"]["stable_exit_code"], 28); + assert_eq!( + blocked["run_start"]["machine_error"]["category"], + "active_process" + ); + assert_eq!( + blocked["run_start"]["machine_error"]["stable_exit_code"], + 28 + ); assert_eq!(blocked["run_start"]["safe_failure"], true); assert!(blocked["run_start"]["next_actions"] .as_array() @@ -4425,6 +4739,24 @@ mod tests { .any(|action| action == "disasmer process restart --yes")); } + #[test] + fn run_rejection_reports_machine_readable_error_category() { + let rejected = run_start_summary(&json!({ + "type": "error", + "message": "quota unavailable: resource limit exceeded for api_calls" + })); + + assert_eq!(rejected["status"], "coordinator_rejected"); + assert_eq!(rejected["error_category"], "quota"); + assert_eq!(rejected["stable_exit_code"], 22); + assert_eq!(rejected["machine_error"]["category"], "quota"); + assert!(rejected["machine_error"]["next_actions"] + .as_array() + .unwrap() + .iter() + .any(|action| action == "disasmer quota status")); + } + #[test] fn local_only_run_executes_ephemeral_local_services() { let Cli { @@ -5312,6 +5644,14 @@ mod tests { status["next_blocked_action"]["action"], "node_work_requires_online_attached_node" ); + assert_eq!( + status["next_blocked_action"]["machine_error"]["category"], + "capability" + ); + assert_eq!( + status["next_blocked_action"]["machine_error"]["stable_exit_code"], + 24 + ); } #[test] @@ -5449,6 +5789,8 @@ mod tests { assert!(rendered_tasks.contains("placement task-a: node-a")); assert!(rendered_tasks.contains("source snapshot already local")); assert_eq!(tasks["tasks"][1]["failure_reason"], "boom"); + assert_eq!(tasks["tasks"][1]["machine_error"]["category"], "program"); + assert_eq!(tasks["tasks"][1]["machine_error"]["stable_exit_code"], 27); assert_eq!(logs["log_entries"].as_array().unwrap().len(), 1); assert_eq!(logs["log_entries"][0]["task"], "task-a"); assert_eq!(logs["log_entries"][0]["stdout_tail"], "ok"); diff --git a/scripts/cli-first-contract-smoke.js b/scripts/cli-first-contract-smoke.js index abe76bc..b23ab7a 100644 --- a/scripts/cli-first-contract-smoke.js +++ b/scripts/cli-first-contract-smoke.js @@ -115,6 +115,8 @@ for (const [name, pattern] of [ ["project local config coverage", /fn project_init_select_and_status_use_local_project_config\(\)/], ["project coordinator status coverage", /fn project_status_queries_public_coordinator_state\(\)/], ["run coordinator active-process coverage", /fn run_contacts_configured_coordinator_and_reports_active_process_conflicts\(\)/], + ["CLI error classifier coverage", /fn cli_error_classifier_distinguishes_mvp_failure_categories\(\)/], + ["CLI run rejection category coverage", /fn run_rejection_reports_machine_readable_error_category\(\)/], ["node attach grant disclosure coverage", /fn node_attach_discloses_dangerous_capability_grants\(\)/], ["quota local status coverage", /fn quota_status_uses_project_config_and_generic_public_limits\(\)/], ["quota coordinator usage coverage", /fn quota_status_queries_public_coordinator_usage\(\)/], @@ -218,6 +220,26 @@ expect( "CLI renders task placement reasons", /fn push_task_placement_reasons[\s\S]*placement \{task_name\}: \{node\}/ ); +expect( + cli, + "CLI classifies machine-readable error categories", + /fn classify_cli_error_message[\s\S]*"authentication"[\s\S]*"authorization"[\s\S]*"quota"[\s\S]*"policy"[\s\S]*"capability"[\s\S]*"connectivity"[\s\S]*"environment"[\s\S]*"program"/ +); +expect( + cli, + "CLI reports stable error-code contract", + /fn cli_error_exit_code[\s\S]*"authentication" => 20[\s\S]*"authorization" => 21[\s\S]*"quota" => 22[\s\S]*"policy" => 23[\s\S]*"capability" => 24[\s\S]*"connectivity" => 25[\s\S]*"environment" => 26[\s\S]*"program" => 27/ +); +expect( + cli, + "CLI attaches machine errors to run and task reports", + /run_start_summary[\s\S]*"machine_error": machine_error/ +); +expect( + cli, + "CLI attaches machine errors to task failures", + /task_failure_machine_error[\s\S]*cli_error_summary_with_default/ +); expect( cli, "CLI parses dangerous capability overrides", diff --git a/scripts/public-local-demo-matrix-smoke.js b/scripts/public-local-demo-matrix-smoke.js index b0d8bf9..8f58264 100755 --- a/scripts/public-local-demo-matrix-smoke.js +++ b/scripts/public-local-demo-matrix-smoke.js @@ -42,7 +42,7 @@ for (const script of [publicAcceptance, publicSplit]) { expect(cliInstall, "CLI install from project path", /cargo[\s\S]*install[\s\S]*crates\/disasmer-cli/); expect(cliInstall, "CLI install smoke targets flagship project", /const project = path\.join\(repo, "examples\/launch-build-demo"\)/); -expect(cliInstall, "installed CLI inspects flagship project", /installedBin[\s\S]*\["bundle", "inspect", "--project", project\]/); +expect(cliInstall, "installed CLI inspects flagship project", /installedBin[\s\S]*\["bundle", "inspect", "--project", project, "--json"\]/); expect(flagship, "flagship project source is Rust workflow", /examples\/launch-build-demo[\s\S]*src\/build\.rs/); expect(flagship, "flagship source avoids local machine assumptions", /forbiddenSourceAssumptions/); diff --git a/scripts/website-inventory-contract-smoke.js b/scripts/website-inventory-contract-smoke.js index 1b6995d..e052410 100644 --- a/scripts/website-inventory-contract-smoke.js +++ b/scripts/website-inventory-contract-smoke.js @@ -30,7 +30,7 @@ function criterionLines() { expect("header", /^# Disasmer Minimal Website Inventory/m); expect( "acceptance-style status", - /\*\*Status:\*\* working MVP website inventory aligned with `acceptance_criteria\.md`, `acceptance_criteria_phase2\.md`, and `cli_acceptance_criteria\.md`/ + /\*\*Status:\*\* hosted website addendum to `acceptance_criteria\.md`, `acceptance_criteria_phase2\.md`, and `cli_acceptance_criteria\.md`/ ); expect( "hosted signup exception",