diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json index f607668..43d93cf 100644 --- a/DISASMER_PUBLIC_TREE.json +++ b/DISASMER_PUBLIC_TREE.json @@ -1,7 +1,7 @@ { "kind": "disasmer-filtered-public-tree", - "source_commit": "8a8f27c7bdaed2573256af31204dd4142be3319d", - "release_name": "dryrun-8a8f27c7bdae", + "source_commit": "c852e659435428b69cf3991a3fad015f60d121ff", + "release_name": "dryrun-c852e6594354", "filtered_out": [ "private/**", "experiments/**", diff --git a/crates/disasmer-cli/src/main.rs b/crates/disasmer-cli/src/main.rs index c8c142c..f8a8e5d 100644 --- a/crates/disasmer-cli/src/main.rs +++ b/crates/disasmer-cli/src/main.rs @@ -1921,6 +1921,10 @@ fn artifact_download_report(args: ArtifactDownloadArgs) -> Result { "status": "requires_coordinator", "link_issued": false, "explicit_user_action_required": true, + "machine_error": cli_error_summary_for_category( + "connectivity", + "artifact download requires a coordinator" + ), }, "grant_disclosures": [], })) @@ -1949,6 +1953,11 @@ fn artifact_export_report(args: ArtifactExportArgs) -> Result { "explicit_user_action": true, "local_path": &args.to, "local_bytes_written_by_cli": false, + "machine_error": artifact_response_machine_error( + &response, + "coordinator rejected artifact export", + "connectivity" + ), }) }; apply_local_export_summary(&mut export_plan, &local_export); @@ -1980,6 +1989,10 @@ fn artifact_export_report(args: ArtifactExportArgs) -> Result { "explicit_user_action": true, "local_bytes_written_by_cli": false, "default_durable_store_assumed": false, + "machine_error": cli_error_summary_for_category( + "connectivity", + "artifact export requires a coordinator" + ), }, "grant_disclosures": [], })) @@ -2014,6 +2027,11 @@ fn artifact_export_local_write_followup( "content_bytes_available": false, "download_session": artifact_download_session_summary(&link_response), "grant_disclosures": [], + "machine_error": artifact_response_machine_error( + &link_response, + "coordinator rejected artifact download for local export", + "connectivity" + ), "link_response": link_response, })); } @@ -2045,6 +2063,11 @@ fn artifact_export_local_write_followup( "content_bytes_available": false, "download_session": download_session, "grant_disclosures": grant_disclosures, + "machine_error": artifact_response_machine_error( + &stream_response, + "coordinator rejected artifact download stream", + "connectivity" + ), "stream": artifact_stream_summary(&stream_response), })); } @@ -2061,6 +2084,10 @@ fn artifact_export_local_write_followup( "content_bytes_available": false, "download_session": download_session, "grant_disclosures": grant_disclosures, + "machine_error": cli_error_summary_for_category( + "program", + "artifact download stream opened without content" + ), "stream": artifact_stream_summary(&stream_response), })); }; @@ -2089,7 +2116,11 @@ fn artifact_export_local_write_followup( } fn artifact_stream_summary(response: &Value) -> Value { - json!({ + let status = response + .get("type") + .and_then(Value::as_str) + .unwrap_or("coordinator_response"); + let mut summary = json!({ "status": response.get("type").and_then(Value::as_str).unwrap_or("coordinator_response"), "streamed_bytes": response.get("streamed_bytes").cloned().unwrap_or_else(|| json!(0)), "charged_download_bytes": response.get("charged_download_bytes").cloned().unwrap_or_else(|| json!(0)), @@ -2097,7 +2128,20 @@ fn artifact_stream_summary(response: &Value) -> Value { "content_source": response.get("content_source").cloned().unwrap_or(Value::Null), "content_material_returned_in_report": false, "error": response.get("message").cloned().unwrap_or(Value::Null), - }) + }); + if status != "artifact_download_stream" { + if let Some(object) = summary.as_object_mut() { + object.insert( + "machine_error".to_owned(), + artifact_response_machine_error( + response, + "coordinator rejected artifact download stream", + "connectivity", + ), + ); + } + } + summary } fn apply_local_export_summary(export_plan: &mut Value, local_export: &Value) { @@ -2592,6 +2636,17 @@ 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")); + push_machine_error_line(&mut lines, value.pointer("/download_session/machine_error")); + push_machine_error_line(&mut lines, value.pointer("/export_plan/machine_error")); + push_machine_error_line(&mut lines, value.pointer("/local_export/machine_error")); + push_machine_error_line( + &mut lines, + value.pointer("/local_export/download_session/machine_error"), + ); + push_machine_error_line( + &mut lines, + value.pointer("/local_export/stream/machine_error"), + ); if let Some(flow) = value.get("human_flow") { if let Some(device) = flow.get("Device") { lines.push("flow: device".to_owned()); @@ -2931,6 +2986,11 @@ fn apply_command_report_exit_code(value: &mut Value) -> Option { "/restart_request/machine_error", "/cancel_request/machine_error", "/task_restart/machine_error", + "/download_session/machine_error", + "/export_plan/machine_error", + "/local_export/machine_error", + "/local_export/download_session/machine_error", + "/local_export/stream/machine_error", ] { let Some(machine_error) = value.pointer_mut(pointer) else { continue; @@ -4126,6 +4186,15 @@ fn response_error_message(response: &Value, fallback: &str) -> String { .unwrap_or_else(|| fallback.to_owned()) } +fn artifact_response_machine_error( + response: &Value, + fallback: &str, + default_category: &'static str, +) -> Value { + let message = response_error_message(response, fallback); + cli_error_summary_with_default(&message, default_category) +} + fn process_restart_request_summary(response: &Value, requires_confirmation: bool) -> Value { if response.get("type").and_then(Value::as_str) != Some("process_started") { let message = response_error_message(response, "coordinator rejected process restart"); @@ -4248,11 +4317,13 @@ fn task_restart_request_summary(response: &Value, requires_confirmation: bool) - fn artifact_download_session_summary(response: &Value) -> Value { if response.get("type").and_then(Value::as_str) != Some("artifact_download_link") { + let message = response_error_message(response, "coordinator rejected artifact download"); return json!({ "status": response.get("type").and_then(Value::as_str).unwrap_or("coordinator_response"), "link_issued": false, "explicit_user_action_required": true, - "error": response.get("message").cloned().unwrap_or(Value::Null), + "error": message.clone(), + "machine_error": cli_error_summary_with_default(&message, "connectivity"), }); } @@ -4317,13 +4388,15 @@ fn artifact_download_grant_disclosures(response: &Value) -> Value { fn artifact_export_plan_summary(response: &Value, to: &Path) -> Value { if response.get("type").and_then(Value::as_str) != Some("artifact_export_plan") { + let message = response_error_message(response, "coordinator rejected artifact export"); return json!({ "status": response.get("type").and_then(Value::as_str).unwrap_or("coordinator_response"), "explicit_user_action": true, "local_path": to, "local_bytes_written_by_cli": false, "default_durable_store_assumed": false, - "error": response.get("message").cloned().unwrap_or(Value::Null), + "error": message.clone(), + "machine_error": cli_error_summary_with_default(&message, "connectivity"), }); } @@ -8563,6 +8636,65 @@ mod tests { assert_eq!(export["export_plan"]["authorization_digest_present"], true); } + #[test] + fn artifact_failure_reports_apply_stable_exit_codes() { + let mut download = json!({ + "command": "artifact download", + "download_session": artifact_download_session_summary(&json!({ + "type": "error", + "message": "artifact download unauthorized for project", + })), + }); + assert_eq!(apply_command_report_exit_code(&mut download), Some(21)); + assert_eq!( + download["download_session"]["machine_error"]["category"], + "authorization" + ); + assert_eq!( + download["download_session"]["machine_error"]["process_exit_code_applied"], + true + ); + + let mut export = json!({ + "command": "artifact export", + "export_plan": artifact_export_plan_summary( + &json!({ + "type": "error", + "message": "direct connectivity unavailable for artifact export", + }), + Path::new("dist/app.txt"), + ), + }); + assert_eq!(apply_command_report_exit_code(&mut export), Some(25)); + assert_eq!( + export["export_plan"]["machine_error"]["category"], + "connectivity" + ); + assert_eq!( + export["export_plan"]["machine_error"]["process_exit_code_applied"], + true + ); + + let mut stream = json!({ + "command": "artifact export", + "local_export": { + "stream": artifact_stream_summary(&json!({ + "type": "error", + "message": "quota unavailable: resource limit exceeded for artifact_download_bytes", + })), + }, + }); + assert_eq!(apply_command_report_exit_code(&mut stream), Some(22)); + assert_eq!( + stream["local_export"]["stream"]["machine_error"]["category"], + "quota" + ); + assert_eq!( + stream["local_export"]["stream"]["machine_error"]["process_exit_code_applied"], + true + ); + } + #[test] fn process_restart_and_cancel_reports_expose_control_boundaries() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/scripts/cli-error-exit-smoke.js b/scripts/cli-error-exit-smoke.js index ba95367..f47809b 100755 --- a/scripts/cli-error-exit-smoke.js +++ b/scripts/cli-error-exit-smoke.js @@ -34,6 +34,27 @@ function runDisasmer(args) { }); } +async function runWithOneCoordinatorResponse(buildArgs, response) { + let request = ""; + const server = net.createServer((socket) => { + socket.setEncoding("utf8"); + socket.on("data", (chunk) => { + request += chunk; + if (!request.includes("\n")) return; + socket.write(JSON.stringify(response) + "\n"); + socket.end(); + server.close(); + }); + }); + + const address = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve(server.address())); + }); + const coordinator = `http://${address.address}:${address.port}`; + const result = await runDisasmer(buildArgs(coordinator)); + return { request, result }; +} + async function main() { const nonInteractive = await runDisasmer([ "run", @@ -120,6 +141,62 @@ async function main() { report.run_start.machine_error.next_actions.includes("disasmer quota status") ); + const artifactDownload = await runWithOneCoordinatorResponse( + (coordinator) => [ + "artifact", + "download", + "app.txt", + "--coordinator", + coordinator, + "--json", + ], + { + type: "artifact_download_denied", + message: "artifact download unauthorized for project", + } + ); + assert.strictEqual(artifactDownload.result.signal, null, artifactDownload.result.stderr); + assert.strictEqual(artifactDownload.result.code, 21, artifactDownload.result.stderr); + assert.match(artifactDownload.request, /"type":"create_artifact_download_link"/); + const artifactDownloadReport = JSON.parse(artifactDownload.result.stdout); + assert.strictEqual( + artifactDownloadReport.download_session.machine_error.category, + "authorization" + ); + assert.strictEqual( + artifactDownloadReport.download_session.machine_error.process_exit_code_applied, + true + ); + + const artifactExport = await runWithOneCoordinatorResponse( + (coordinator) => [ + "artifact", + "export", + "app.txt", + "--to", + path.join(project, "target", "blocked-artifact.txt"), + "--coordinator", + coordinator, + "--json", + ], + { + type: "artifact_export_unavailable", + message: "direct connectivity unavailable for artifact export", + } + ); + assert.strictEqual(artifactExport.result.signal, null, artifactExport.result.stderr); + assert.strictEqual(artifactExport.result.code, 25, artifactExport.result.stderr); + assert.match(artifactExport.request, /"type":"export_artifact_to_node"/); + const artifactExportReport = JSON.parse(artifactExport.result.stdout); + assert.strictEqual( + artifactExportReport.export_plan.machine_error.category, + "connectivity" + ); + assert.strictEqual( + artifactExportReport.export_plan.machine_error.process_exit_code_applied, + true + ); + const confirmation = await runDisasmer([ "process", "cancel", diff --git a/scripts/cli-first-contract-smoke.js b/scripts/cli-first-contract-smoke.js index 504db67..7c3def1 100644 --- a/scripts/cli-first-contract-smoke.js +++ b/scripts/cli-first-contract-smoke.js @@ -430,7 +430,12 @@ expect( expect( cli, "CLI applies process exit code after printing command failure report", - /fn emit_report[\s\S]*apply_command_report_exit_code[\s\S]*std::process::exit\(exit_code\)/ + /fn emit_report[\s\S]*apply_command_report_exit_code[\s\S]*std::process::exit\(exit_code\)[\s\S]*fn human_report/ +); +expect( + cli, + "CLI applies artifact nested failure exit codes", + /fn apply_command_report_exit_code[\s\S]*"\/download_session\/machine_error"[\s\S]*"\/export_plan\/machine_error"[\s\S]*"\/local_export\/machine_error"[\s\S]*"\/local_export\/download_session\/machine_error"[\s\S]*"\/local_export\/stream\/machine_error"/ ); expect( cli, @@ -541,6 +546,8 @@ for (const [name, pattern] of [ ["actual quota resource-category assertion", /machine_error\.resource_category, "api_calls"/], ["actual quota community-tier label assertion", /machine_error\.community_tier_label,[\s\S]*"community tier"/], ["actual quota abuse-heuristics assertion", /machine_error\.private_abuse_heuristics_exposed,[\s\S]*false/], + ["artifact download rejection exit code", /artifactDownload[\s\S]*assert\.strictEqual\(artifactDownload\.result\.code, 21/], + ["artifact export rejection exit code", /artifactExport[\s\S]*assert\.strictEqual\(artifactExport\.result\.code, 25/], ["exit-code application in JSON", /process_exit_code_applied[\s\S]*true/], ]) { expect(errorExitSmoke, name, pattern);